diff --git a/.core_files.yaml b/.core_files.yaml new file mode 100644 index 0000000000000..9eb52b635f348 --- /dev/null +++ b/.core_files.yaml @@ -0,0 +1,121 @@ +# Defines a list of files that are part of main core of Home Assistant. +# Changes to these files/filters define how our CI test suite is ran. +core: &core + - homeassistant/*.py + - homeassistant/auth/** + - homeassistant/helpers/* + - homeassistant/package_constraints.txt + - homeassistant/util/* + - pyproject.yaml + - requirements.txt + - setup.cfg + +# Our base platforms, that are used by other integrations +base_platforms: &base_platforms + - homeassistant/components/air_quality/* + - homeassistant/components/alarm_control_panel/* + - homeassistant/components/binary_sensor/* + - homeassistant/components/button/* + - homeassistant/components/calendar/* + - homeassistant/components/camera/* + - homeassistant/components/climate/* + - homeassistant/components/cover/* + - homeassistant/components/device_tracker/* + - homeassistant/components/fan/* + - homeassistant/components/geo_location/* + - homeassistant/components/humidifier/* + - homeassistant/components/image_processing/* + - homeassistant/components/light/* + - homeassistant/components/lock/* + - homeassistant/components/media_player/* + - homeassistant/components/notify/* + - homeassistant/components/number/* + - homeassistant/components/remote/* + - homeassistant/components/scene/* + - homeassistant/components/select/* + - homeassistant/components/sensor/* + - homeassistant/components/siren/* + - homeassistant/components/stt/* + - homeassistant/components/switch/* + - homeassistant/components/tts/* + - homeassistant/components/vacuum/* + - homeassistant/components/water_heater/* + - homeassistant/components/weather/* + +# Extra components that trigger the full suite +components: &components + - homeassistant/components/alert/* + - homeassistant/components/alexa/* + - homeassistant/components/auth/* + - homeassistant/components/automation/* + - homeassistant/components/cloud/* + - homeassistant/components/config/* + - homeassistant/components/configurator/* + - homeassistant/components/conversation/* + - homeassistant/components/demo/* + - homeassistant/components/device_automation/* + - homeassistant/components/dhcp/* + - homeassistant/components/discovery/* + - homeassistant/components/energy/* + - homeassistant/components/ffmpeg/* + - homeassistant/components/frontend/* + - homeassistant/components/google_assistant/* + - homeassistant/components/group/* + - homeassistant/components/hassio/* + - homeassistant/components/homeassistant/** + - homeassistant/components/image/* + - homeassistant/components/input_boolean/* + - homeassistant/components/input_button/* + - homeassistant/components/input_datetime/* + - homeassistant/components/input_number/* + - homeassistant/components/input_select/* + - homeassistant/components/input_text/* + - homeassistant/components/logbook/* + - homeassistant/components/logger/* + - homeassistant/components/lovelace/* + - homeassistant/components/media_source/* + - homeassistant/components/mqtt/* + - homeassistant/components/network/* + - homeassistant/components/onboarding/* + - homeassistant/components/otp/* + - homeassistant/components/persistent_notification/* + - homeassistant/components/person/* + - homeassistant/components/recorder/* + - homeassistant/components/safe_mode/* + - homeassistant/components/script/* + - homeassistant/components/shopping_list/* + - homeassistant/components/ssdp/* + - homeassistant/components/stream/* + - homeassistant/components/sun/* + - homeassistant/components/system_health/* + - homeassistant/components/tag/* + - homeassistant/components/template/* + - homeassistant/components/timer/* + - homeassistant/components/usb/* + - homeassistant/components/webhook/* + - homeassistant/components/websocket_api/* + - homeassistant/components/zeroconf/* + - homeassistant/components/zone/* + +# Testing related files that affect the whole test/linting suite +tests: &tests + - codecov.yaml + - requirements_test_pre_commit.txt + - requirements_test.txt + - tests/common.py + - tests/conftest.py + - tests/ignore_uncaught_exceptions.py + - tests/mock/* + - tests/test_util/* + - tests/testing_config/** + +other: &other + - .github/workflows/* + - homeassistant/scripts/** + +any: + - *base_platforms + - *components + - *core + - *other + - *tests diff --git a/.coveragerc b/.coveragerc index 02cd35bcd603f..2113ea0c2022e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,8 +8,10 @@ omit = homeassistant/scripts/*.py # omit pieces of code that rely on external devices being present - homeassistant/components/acer_projector/switch.py + homeassistant/components/acer_projector/* + homeassistant/components/actiontec/const.py homeassistant/components/actiontec/device_tracker.py + homeassistant/components/actiontec/model.py homeassistant/components/acmeda/__init__.py homeassistant/components/acmeda/base.py homeassistant/components/acmeda/const.py @@ -18,37 +20,42 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py + homeassistant/components/adax/__init__.py + homeassistant/components/adax/climate.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/aemet/abstract_aemet_sensor.py homeassistant/components/aemet/weather_update_coordinator.py - homeassistant/components/aftership/sensor.py - homeassistant/components/agent_dvr/__init__.py + homeassistant/components/aftership/* homeassistant/components/agent_dvr/alarm_control_panel.py homeassistant/components/agent_dvr/camera.py - homeassistant/components/agent_dvr/const.py homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airthings/__init__.py + homeassistant/components/airthings/sensor.py + homeassistant/components/airtouch4/__init__.py + homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/const.py homeassistant/components/airvisual/__init__.py - homeassistant/components/airvisual/air_quality.py homeassistant/components/airvisual/sensor.py - homeassistant/components/aladdin_connect/cover.py + homeassistant/components/aladdin_connect/* homeassistant/components/alarmdecoder/__init__.py homeassistant/components/alarmdecoder/alarm_control_panel.py homeassistant/components/alarmdecoder/binary_sensor.py homeassistant/components/alarmdecoder/const.py homeassistant/components/alarmdecoder/sensor.py homeassistant/components/alpha_vantage/sensor.py - homeassistant/components/amazon_polly/tts.py + homeassistant/components/amazon_polly/* + homeassistant/components/amberelectric/__init__.py homeassistant/components/ambiclimate/climate.py homeassistant/components/ambient_station/* homeassistant/components/amcrest/* homeassistant/components/ampio/* homeassistant/components/android_ip_webcam/* + homeassistant/components/androidtv/__init__.py homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anthemav/media_player.py homeassistant/components/apcupsd/* @@ -67,6 +74,9 @@ omit = homeassistant/components/arris_tg2492lg/* homeassistant/components/aruba/device_tracker.py homeassistant/components/arwn/sensor.py + homeassistant/components/aseko_pool_live/__init__.py + homeassistant/components/aseko_pool_live/entity.py + homeassistant/components/aseko_pool_live/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* homeassistant/components/asuswrt/__init__.py @@ -77,7 +87,6 @@ omit = homeassistant/components/aurora/binary_sensor.py homeassistant/components/aurora/const.py homeassistant/components/aurora/sensor.py - homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py homeassistant/components/azure_devops/__init__.py @@ -85,6 +94,7 @@ omit = homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py + homeassistant/components/balboa/__init__.py homeassistant/components/beewi_smartclim/sensor.py homeassistant/components/bbb_gpio/* homeassistant/components/bbox/device_tracker.py @@ -104,6 +114,8 @@ omit = homeassistant/components/bloomsky/* homeassistant/components/bluesound/* homeassistant/components/bluetooth_tracker/* + homeassistant/components/bme280/__init__.py + homeassistant/components/bme280/const.py homeassistant/components/bme280/sensor.py homeassistant/components/bme680/sensor.py homeassistant/components/bmp280/sensor.py @@ -113,20 +125,28 @@ omit = homeassistant/components/bmw_connected_drive/lock.py homeassistant/components/bmw_connected_drive/notify.py homeassistant/components/bmw_connected_drive/sensor.py + homeassistant/components/bosch_shc/__init__.py + homeassistant/components/bosch_shc/binary_sensor.py + homeassistant/components/bosch_shc/const.py + homeassistant/components/bosch_shc/cover.py + homeassistant/components/bosch_shc/entity.py + homeassistant/components/bosch_shc/sensor.py homeassistant/components/braviatv/__init__.py homeassistant/components/braviatv/const.py homeassistant/components/braviatv/media_player.py + homeassistant/components/braviatv/remote.py homeassistant/components/broadlink/__init__.py homeassistant/components/broadlink/const.py + homeassistant/components/broadlink/light.py homeassistant/components/broadlink/remote.py homeassistant/components/broadlink/switch.py homeassistant/components/broadlink/updater.py homeassistant/components/brottsplatskartan/sensor.py homeassistant/components/browser/* + homeassistant/components/brunt/__init__.py homeassistant/components/brunt/cover.py - homeassistant/components/bsblan/__init__.py + homeassistant/components/brunt/const.py homeassistant/components/bsblan/climate.py - homeassistant/components/bsblan/const.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py homeassistant/components/buienradar/sensor.py @@ -146,8 +166,7 @@ omit = homeassistant/components/clicksend/notify.py homeassistant/components/clicksend_tts/notify.py homeassistant/components/cmus/media_player.py - homeassistant/components/co2signal/* - homeassistant/components/coinbase/* + homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py @@ -161,6 +180,13 @@ omit = homeassistant/components/coolmaster/const.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/cpuspeed/sensor.py + homeassistant/components/crownstone/__init__.py + homeassistant/components/crownstone/const.py + homeassistant/components/crownstone/listeners.py + homeassistant/components/crownstone/helpers.py + homeassistant/components/crownstone/devices.py + homeassistant/components/crownstone/entry_manager.py + homeassistant/components/crownstone/light.py homeassistant/components/cups/sensor.py homeassistant/components/currencylayer/sensor.py homeassistant/components/daikin/* @@ -177,11 +203,9 @@ omit = homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py homeassistant/components/deutsche_bahn/sensor.py - homeassistant/components/devolo_home_control/binary_sensor.py homeassistant/components/devolo_home_control/climate.py homeassistant/components/devolo_home_control/const.py homeassistant/components/devolo_home_control/cover.py - homeassistant/components/devolo_home_control/devolo_device.py homeassistant/components/devolo_home_control/devolo_multi_level_switch.py homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py @@ -195,7 +219,6 @@ omit = homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/switch.py - homeassistant/components/dlna_dmr/media_player.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* homeassistant/components/doods/* @@ -216,6 +239,7 @@ omit = homeassistant/components/ecobee/__init__.py homeassistant/components/ecobee/binary_sensor.py homeassistant/components/ecobee/climate.py + homeassistant/components/ecobee/humidifier.py homeassistant/components/ecobee/notify.py homeassistant/components/ecobee/sensor.py homeassistant/components/ecobee/weather.py @@ -233,6 +257,10 @@ omit = homeassistant/components/eight_sleep/* homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/* + homeassistant/components/elmax/__init__.py + homeassistant/components/elmax/common.py + homeassistant/components/elmax/const.py + homeassistant/components/elmax/switch.py homeassistant/components/elv/* homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py @@ -251,7 +279,10 @@ omit = homeassistant/components/enphase_envoy/__init__.py homeassistant/components/enphase_envoy/sensor.py homeassistant/components/entur_public_transport/* - homeassistant/components/environment_canada/* + homeassistant/components/environment_canada/__init__.py + homeassistant/components/environment_canada/camera.py + homeassistant/components/environment_canada/sensor.py + homeassistant/components/environment_canada/weather.py homeassistant/components/envirophat/sensor.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py @@ -262,15 +293,17 @@ omit = homeassistant/components/eq3btsmart/climate.py homeassistant/components/esphome/__init__.py homeassistant/components/esphome/binary_sensor.py + homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py homeassistant/components/esphome/climate.py homeassistant/components/esphome/cover.py homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/fan.py homeassistant/components/esphome/light.py + homeassistant/components/esphome/number.py + homeassistant/components/esphome/select.py homeassistant/components/esphome/sensor.py homeassistant/components/esphome/switch.py - homeassistant/components/essent/sensor.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/everlights/light.py @@ -279,6 +312,7 @@ omit = homeassistant/components/ezviz/camera.py homeassistant/components/ezviz/coordinator.py homeassistant/components/ezviz/const.py + homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/binary_sensor.py homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py @@ -304,8 +338,15 @@ omit = homeassistant/components/firmata/pin.py homeassistant/components/firmata/sensor.py homeassistant/components/firmata/switch.py - homeassistant/components/fitbit/sensor.py + homeassistant/components/fitbit/* homeassistant/components/fixer/sensor.py + homeassistant/components/fjaraskupan/__init__.py + homeassistant/components/fjaraskupan/binary_sensor.py + homeassistant/components/fjaraskupan/const.py + homeassistant/components/fjaraskupan/fan.py + homeassistant/components/fjaraskupan/light.py + homeassistant/components/fjaraskupan/number.py + homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py @@ -313,10 +354,10 @@ omit = homeassistant/components/flick_electric/const.py homeassistant/components/flick_electric/sensor.py homeassistant/components/flock/notify.py - homeassistant/components/flume/* + homeassistant/components/flume/__init__.py + homeassistant/components/flume/sensor.py homeassistant/components/flunearyou/__init__.py homeassistant/components/flunearyou/sensor.py - homeassistant/components/flux_led/light.py homeassistant/components/folder/sensor.py homeassistant/components/folder_watcher/* homeassistant/components/foobot/sensor.py @@ -331,23 +372,23 @@ omit = homeassistant/components/freebox/switch.py homeassistant/components/fritz/__init__.py homeassistant/components/fritz/binary_sensor.py + homeassistant/components/fritz/button.py homeassistant/components/fritz/common.py homeassistant/components/fritz/const.py homeassistant/components/fritz/device_tracker.py homeassistant/components/fritz/sensor.py + homeassistant/components/fritz/services.py + homeassistant/components/fritz/switch.py homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py - homeassistant/components/fritzbox_netmonitor/sensor.py - homeassistant/components/fronius/sensor.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py - homeassistant/components/garmin_connect/__init__.py - homeassistant/components/garmin_connect/const.py - homeassistant/components/garmin_connect/sensor.py - homeassistant/components/garmin_connect/alarm_util.py + homeassistant/components/garages_amsterdam/__init__.py + homeassistant/components/garages_amsterdam/binary_sensor.py + homeassistant/components/garages_amsterdam/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* homeassistant/components/github/sensor.py @@ -358,19 +399,15 @@ omit = homeassistant/components/glances/sensor.py homeassistant/components/gntp/notify.py homeassistant/components/goalfeed/* - homeassistant/components/goalzero/__init__.py - homeassistant/components/goalzero/binary_sensor.py - homeassistant/components/goalzero/switch.py - homeassistant/components/google/* + homeassistant/components/google/__init__.py homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py + homeassistant/components/google_pubsub/__init__.py homeassistant/components/google_travel_time/__init__.py homeassistant/components/google_travel_time/helpers.py homeassistant/components/google_travel_time/sensor.py homeassistant/components/gpmdp/media_player.py homeassistant/components/gpsd/sensor.py - homeassistant/components/greeneye_monitor/* - homeassistant/components/greeneye_monitor/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/group/notify.py homeassistant/components/growatt_server/sensor.py @@ -386,49 +423,46 @@ omit = homeassistant/components/habitica/const.py homeassistant/components/habitica/sensor.py homeassistant/components/hangouts/* - homeassistant/components/hangouts/__init__.py - homeassistant/components/hangouts/const.py - homeassistant/components/hangouts/hangouts_bot.py - homeassistant/components/hangouts/hangups_utils.py homeassistant/components/harman_kardon_avr/media_player.py homeassistant/components/harmony/const.py homeassistant/components/harmony/data.py homeassistant/components/harmony/remote.py homeassistant/components/harmony/util.py - homeassistant/components/hassio/binary_sensor.py - homeassistant/components/hassio/entity.py - homeassistant/components/hassio/sensor.py homeassistant/components/haveibeenpwned/sensor.py homeassistant/components/hdmi_cec/* homeassistant/components/heatmiser/climate.py homeassistant/components/hikvision/binary_sensor.py homeassistant/components/hikvisioncam/switch.py - homeassistant/components/hisense_aehw4a1/* + homeassistant/components/hisense_aehw4a1/__init__.py + homeassistant/components/hisense_aehw4a1/climate.py homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/__init__.py - homeassistant/components/hive/climate.py + homeassistant/components/hive/alarm_control_panel.py homeassistant/components/hive/binary_sensor.py + homeassistant/components/hive/climate.py homeassistant/components/hive/light.py homeassistant/components/hive/sensor.py homeassistant/components/hive/switch.py homeassistant/components/hive/water_heater.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py - homeassistant/components/home_connect/* + homeassistant/components/home_connect/__init__.py + homeassistant/components/home_connect/api.py + homeassistant/components/home_connect/binary_sensor.py + homeassistant/components/home_connect/entity.py + homeassistant/components/home_connect/light.py + homeassistant/components/home_connect/sensor.py + homeassistant/components/home_connect/switch.py homeassistant/components/homematic/* - homeassistant/components/homematic/climate.py - homeassistant/components/homematic/cover.py - homeassistant/components/homematic/notify.py homeassistant/components/home_plus_control/api.py - homeassistant/components/home_plus_control/helpers.py homeassistant/components/home_plus_control/switch.py homeassistant/components/homeworks/* + homeassistant/components/honeywell/__init__.py homeassistant/components/honeywell/climate.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py homeassistant/components/htu21d/sensor.py homeassistant/components/huawei_lte/* - homeassistant/components/huawei_router/device_tracker.py homeassistant/components/hue/light.py homeassistant/components/hunterdouglas_powerview/__init__.py homeassistant/components/hunterdouglas_powerview/scene.py @@ -473,7 +507,6 @@ omit = homeassistant/components/incomfort/* homeassistant/components/intesishome/* homeassistant/components/ios/* - homeassistant/components/iota/* homeassistant/components/iperf3/* homeassistant/components/iqvia/* homeassistant/components/irish_rail_transport/sensor.py @@ -492,6 +525,8 @@ omit = homeassistant/components/isy994/switch.py homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py + homeassistant/components/jellyfin/__init__.py + homeassistant/components/jellyfin/media_source.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/__init__.py homeassistant/components/juicenet/const.py @@ -512,9 +547,14 @@ omit = homeassistant/components/keyboard_remote/* homeassistant/components/kira/* homeassistant/components/kiwi/lock.py - homeassistant/components/knx/* + homeassistant/components/knx/__init__.py homeassistant/components/knx/climate.py homeassistant/components/knx/cover.py + homeassistant/components/knx/expose.py + homeassistant/components/knx/knx_entity.py + homeassistant/components/knx/light.py + homeassistant/components/knx/notify.py + homeassistant/components/knx/schema.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py @@ -524,7 +564,9 @@ omit = homeassistant/components/kostal_plenticore/__init__.py homeassistant/components/kostal_plenticore/const.py homeassistant/components/kostal_plenticore/helper.py + homeassistant/components/kostal_plenticore/select.py homeassistant/components/kostal_plenticore/sensor.py + homeassistant/components/kostal_plenticore/switch.py homeassistant/components/kwb/sensor.py homeassistant/components/lacrosse/sensor.py homeassistant/components/lametric/* @@ -532,15 +574,12 @@ omit = homeassistant/components/lastfm/sensor.py homeassistant/components/launch_library/const.py homeassistant/components/launch_library/sensor.py - homeassistant/components/lcn/__init__.py homeassistant/components/lcn/binary_sensor.py homeassistant/components/lcn/climate.py - homeassistant/components/lcn/const.py homeassistant/components/lcn/cover.py homeassistant/components/lcn/helpers.py homeassistant/components/lcn/light.py homeassistant/components/lcn/scene.py - homeassistant/components/lcn/schemas.py homeassistant/components/lcn/sensor.py homeassistant/components/lcn/services.py homeassistant/components/lcn/switch.py @@ -561,9 +600,13 @@ omit = homeassistant/components/logi_circle/const.py homeassistant/components/logi_circle/sensor.py homeassistant/components/london_underground/sensor.py - homeassistant/components/loopenergy/sensor.py + homeassistant/components/lookin/__init__.py + homeassistant/components/lookin/entity.py + homeassistant/components/lookin/models.py + homeassistant/components/lookin/sensor.py + homeassistant/components/lookin/climate.py + homeassistant/components/lookin/media_player.py homeassistant/components/luci/device_tracker.py - homeassistant/components/luftdaten/__init__.py homeassistant/components/luftdaten/sensor.py homeassistant/components/lupusec/* homeassistant/components/lutron/* @@ -575,7 +618,6 @@ omit = homeassistant/components/lutron_caseta/scene.py homeassistant/components/lutron_caseta/switch.py homeassistant/components/lw12wifi/light.py - homeassistant/components/lyft/sensor.py homeassistant/components/lyric/__init__.py homeassistant/components/lyric/api.py homeassistant/components/lyric/climate.py @@ -602,15 +644,19 @@ omit = homeassistant/components/meteo_france/sensor.py homeassistant/components/meteo_france/weather.py homeassistant/components/meteoalarm/* + homeassistant/components/meteoclimatic/__init__.py + homeassistant/components/meteoclimatic/const.py + homeassistant/components/meteoclimatic/sensor.py + homeassistant/components/meteoclimatic/weather.py homeassistant/components/metoffice/sensor.py homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/miflora/sensor.py homeassistant/components/mikrotik/hub.py homeassistant/components/mikrotik/device_tracker.py - homeassistant/components/mill/__init__.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py + homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/const.py @@ -621,8 +667,6 @@ omit = homeassistant/components/mjpeg/camera.py homeassistant/components/mochad/* homeassistant/components/modbus/climate.py - homeassistant/components/modbus/cover.py - homeassistant/components/modbus/switch.py homeassistant/components/modem_callerid/sensor.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/const.py @@ -638,9 +682,7 @@ omit = homeassistant/components/mutesync/binary_sensor.py homeassistant/components/nest/const.py homeassistant/components/mvglive/sensor.py - homeassistant/components/mychevy/* homeassistant/components/mycroft/* - homeassistant/components/mycroft/notify.py homeassistant/components/mysensors/__init__.py homeassistant/components/mysensors/binary_sensor.py homeassistant/components/mysensors/climate.py @@ -653,33 +695,43 @@ omit = homeassistant/components/mysensors/helpers.py homeassistant/components/mysensors/light.py homeassistant/components/mysensors/notify.py - homeassistant/components/mysensors/sensor.py homeassistant/components/mysensors/switch.py homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py homeassistant/components/myq/__init__.py + homeassistant/components/myq/cover.py + homeassistant/components/myq/light.py homeassistant/components/nad/media_player.py + homeassistant/components/nanoleaf/__init__.py + homeassistant/components/nanoleaf/button.py + homeassistant/components/nanoleaf/entity.py homeassistant/components/nanoleaf/light.py homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py + homeassistant/components/neato/hub.py homeassistant/components/neato/sensor.py homeassistant/components/neato/switch.py homeassistant/components/neato/vacuum.py homeassistant/components/nederlandse_spoorwegen/sensor.py - homeassistant/components/nello/lock.py homeassistant/components/nest/legacy/* homeassistant/components/netdata/sensor.py + homeassistant/components/netgear/__init__.py homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear/router.py + homeassistant/components/netgear/sensor.py homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py + homeassistant/components/nexia/climate.py homeassistant/components/nextcloud/* + homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* + homeassistant/components/nmap_tracker/__init__.py homeassistant/components/nmap_tracker/device_tracker.py homeassistant/components/nmbs/sensor.py homeassistant/components/notion/__init__.py @@ -693,11 +745,10 @@ omit = homeassistant/components/nuki/const.py homeassistant/components/nuki/binary_sensor.py homeassistant/components/nuki/lock.py - homeassistant/components/nut/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/nzbget/coordinator.py homeassistant/components/obihai/* - homeassistant/components/octoprint/* + homeassistant/components/octoprint/__init__.py homeassistant/components/oem/climate.py homeassistant/components/oasa_telematics/sensor.py homeassistant/components/ohmconnect/sensor.py @@ -720,10 +771,15 @@ omit = homeassistant/components/onvif/event.py homeassistant/components/onvif/parsers.py homeassistant/components/onvif/sensor.py + homeassistant/components/open_meteo/weather.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py + homeassistant/components/opengarage/__init__.py + homeassistant/components/opengarage/binary_sensor.py homeassistant/components/opengarage/cover.py + homeassistant/components/opengarage/entity.py + homeassistant/components/opengarage/sensor.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/media_player.py homeassistant/components/openhome/const.py @@ -739,7 +795,6 @@ omit = homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py homeassistant/components/openweathermap/weather_update_coordinator.py - homeassistant/components/openweathermap/abstract_owm_sensor.py homeassistant/components/opnsense/* homeassistant/components/opple/light.py homeassistant/components/orangepi_gpio/* @@ -747,6 +802,14 @@ omit = homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py + homeassistant/components/overkiz/__init__.py + homeassistant/components/overkiz/button.py + homeassistant/components/overkiz/coordinator.py + homeassistant/components/overkiz/entity.py + homeassistant/components/overkiz/executor.py + homeassistant/components/overkiz/lock.py + homeassistant/components/overkiz/number.py + homeassistant/components/overkiz/sensor.py homeassistant/components/ovo_energy/__init__.py homeassistant/components/ovo_energy/const.py homeassistant/components/ovo_energy/sensor.py @@ -759,6 +822,7 @@ omit = homeassistant/components/pcal9535a/* homeassistant/components/pencom/switch.py homeassistant/components/philips_js/__init__.py + homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py homeassistant/components/philips_js/remote.py homeassistant/components/pi_hole/sensor.py @@ -790,7 +854,6 @@ omit = homeassistant/components/progettihwsw/__init__.py homeassistant/components/progettihwsw/binary_sensor.py homeassistant/components/progettihwsw/switch.py - homeassistant/components/prometheus/* homeassistant/components/prowl/notify.py homeassistant/components/proxmoxve/* homeassistant/components/proxy/camera.py @@ -810,14 +873,12 @@ omit = homeassistant/components/radarr/sensor.py homeassistant/components/radiotherm/climate.py homeassistant/components/rainbird/* - homeassistant/components/rainbird/sensor.py - homeassistant/components/rainbird/switch.py homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/binary_sensor.py + homeassistant/components/rainmachine/model.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py - homeassistant/components/rainforest_eagle/sensor.py homeassistant/components/raspihats/* homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/__init__.py @@ -832,13 +893,11 @@ omit = homeassistant/components/remote_rpi_gpio/* homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py + homeassistant/components/ridwell/__init__.py + homeassistant/components/ridwell/sensor.py + homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py - homeassistant/components/rituals_perfume_genie/binary_sensor.py - homeassistant/components/rituals_perfume_genie/entity.py - homeassistant/components/rituals_perfume_genie/sensor.py - homeassistant/components/rituals_perfume_genie/switch.py - homeassistant/components/rituals_perfume_genie/__init__.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py @@ -856,7 +915,6 @@ omit = homeassistant/components/rova/sensor.py homeassistant/components/rpi_camera/* homeassistant/components/rpi_gpio/* - homeassistant/components/rpi_gpio/cover.py homeassistant/components/rpi_gpio_pwm/light.py homeassistant/components/rpi_pfio/* homeassistant/components/rpi_rf/switch.py @@ -865,21 +923,26 @@ omit = homeassistant/components/russound_rnet/media_player.py homeassistant/components/sabnzbd/* homeassistant/components/saj/sensor.py + homeassistant/components/samsungtv/bridge.py homeassistant/components/satel_integra/* homeassistant/components/schluter/* homeassistant/components/scrape/sensor.py homeassistant/components/screenlogic/__init__.py homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py + homeassistant/components/screenlogic/light.py + homeassistant/components/screenlogic/number.py homeassistant/components/screenlogic/sensor.py homeassistant/components/screenlogic/services.py homeassistant/components/screenlogic/switch.py homeassistant/components/scsgate/* - homeassistant/components/scsgate/cover.py homeassistant/components/sendgrid/notify.py - homeassistant/components/sense/* + homeassistant/components/sense/__init__.py + homeassistant/components/sense/binary_sensor.py + homeassistant/components/sense/sensor.py homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py + homeassistant/components/sensibo/__init__.py homeassistant/components/sensibo/climate.py homeassistant/components/serial/sensor.py homeassistant/components/serial_pm/sensor.py @@ -890,6 +953,7 @@ omit = homeassistant/components/shodan/sensor.py homeassistant/components/shelly/__init__.py homeassistant/components/shelly/binary_sensor.py + homeassistant/components/shelly/climate.py homeassistant/components/shelly/entity.py homeassistant/components/shelly/light.py homeassistant/components/shelly/sensor.py @@ -908,6 +972,13 @@ omit = homeassistant/components/skybeacon/sensor.py homeassistant/components/skybell/* homeassistant/components/slack/notify.py + homeassistant/components/sia/__init__.py + homeassistant/components/sia/alarm_control_panel.py + homeassistant/components/sia/binary_sensor.py + homeassistant/components/sia/const.py + homeassistant/components/sia/hub.py + homeassistant/components/sia/utils.py + homeassistant/components/sia/sia_entity_base.py homeassistant/components/sinch/* homeassistant/components/slide/* homeassistant/components/sma/__init__.py @@ -927,6 +998,7 @@ omit = homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/solaredge/__init__.py + homeassistant/components/solaredge/coordinator.py homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solarlog/* @@ -934,13 +1006,26 @@ omit = homeassistant/components/soma/__init__.py homeassistant/components/soma/cover.py homeassistant/components/soma/sensor.py - homeassistant/components/somfy/* + homeassistant/components/somfy/__init__.py + homeassistant/components/somfy/api.py + homeassistant/components/somfy/climate.py + homeassistant/components/somfy/cover.py + homeassistant/components/somfy/sensor.py + homeassistant/components/somfy/switch.py homeassistant/components/somfy_mylink/__init__.py homeassistant/components/somfy_mylink/cover.py - homeassistant/components/sonos/* + homeassistant/components/sonos/__init__.py + homeassistant/components/sonos/alarms.py + homeassistant/components/sonos/entity.py + homeassistant/components/sonos/favorites.py + homeassistant/components/sonos/helpers.py + homeassistant/components/sonos/household_coordinator.py + homeassistant/components/sonos/media_browser.py + homeassistant/components/sonos/media_player.py + homeassistant/components/sonos/speaker.py + homeassistant/components/sonos/switch.py homeassistant/components/sony_projector/switch.py homeassistant/components/spc/* - homeassistant/components/speedtestdotnet/* homeassistant/components/spider/* homeassistant/components/splunk/* homeassistant/components/spotify/__init__.py @@ -953,18 +1038,27 @@ omit = homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* - homeassistant/components/stookalert/* + homeassistant/components/stookalert/__init__.py + homeassistant/components/stookalert/binary_sensor.py homeassistant/components/stream/* homeassistant/components/streamlabswater/* homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py homeassistant/components/surepetcare/__init__.py + homeassistant/components/surepetcare/entity.py + homeassistant/components/surepetcare/binary_sensor.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py - homeassistant/components/switcher_kis/switch.py + homeassistant/components/switchbot/binary_sensor.py + homeassistant/components/switchbot/__init__.py + homeassistant/components/switchbot/const.py + homeassistant/components/switchbot/entity.py + homeassistant/components/switchbot/cover.py + homeassistant/components/switchbot/sensor.py + homeassistant/components/switchbot/coordinator.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py @@ -980,16 +1074,17 @@ omit = homeassistant/components/synology_srm/device_tracker.py homeassistant/components/syslog/notify.py homeassistant/components/system_bridge/__init__.py - homeassistant/components/system_bridge/const.py homeassistant/components/system_bridge/binary_sensor.py + homeassistant/components/system_bridge/const.py + homeassistant/components/system_bridge/coordinator.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* - homeassistant/components/tado/device_tracker.py - homeassistant/components/tahoma/* homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py + homeassistant/components/tautulli/const.py + homeassistant/components/tautulli/coordinator.py homeassistant/components/tautulli/sensor.py homeassistant/components/ted5000/sensor.py homeassistant/components/telegram/notify.py @@ -1005,14 +1100,6 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/tesla/__init__.py - homeassistant/components/tesla/binary_sensor.py - homeassistant/components/tesla/climate.py - homeassistant/components/tesla/const.py - homeassistant/components/tesla/device_tracker.py - homeassistant/components/tesla/lock.py - homeassistant/components/tesla/sensor.py - homeassistant/components/tesla/switch.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* @@ -1030,6 +1117,14 @@ omit = homeassistant/components/todoist/calendar.py homeassistant/components/todoist/const.py homeassistant/components/tof/sensor.py + homeassistant/components/tolo/__init__.py + homeassistant/components/tolo/binary_sensor.py + homeassistant/components/tolo/button.py + homeassistant/components/tolo/climate.py + homeassistant/components/tolo/fan.py + homeassistant/components/tolo/light.py + homeassistant/components/tolo/select.py + homeassistant/components/tolo/sensor.py homeassistant/components/tomato/device_tracker.py homeassistant/components/toon/__init__.py homeassistant/components/toon/binary_sensor.py @@ -1043,21 +1138,28 @@ omit = homeassistant/components/toon/switch.py homeassistant/components/torque/sensor.py homeassistant/components/totalconnect/__init__.py - homeassistant/components/totalconnect/alarm_control_panel.py homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/totalconnect/const.py homeassistant/components/touchline/climate.py - homeassistant/components/tplink/common.py - homeassistant/components/tplink/switch.py homeassistant/components/tplink_lte/* homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py - homeassistant/components/trackr/device_tracker.py - homeassistant/components/tradfri/* - homeassistant/components/tradfri/light.py - homeassistant/components/tradfri/cover.py + homeassistant/components/tractive/__init__.py + homeassistant/components/tractive/binary_sensor.py + homeassistant/components/tractive/device_tracker.py + homeassistant/components/tractive/entity.py + homeassistant/components/tractive/sensor.py + homeassistant/components/tractive/switch.py + homeassistant/components/tradfri/__init__.py homeassistant/components/tradfri/base_class.py + homeassistant/components/tradfri/config_flow.py + homeassistant/components/tradfri/cover.py + homeassistant/components/tradfri/fan.py + homeassistant/components/tradfri/light.py + homeassistant/components/tradfri/sensor.py + homeassistant/components/tradfri/switch.py homeassistant/components/trafikverket_train/sensor.py + homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/sensor.py homeassistant/components/transmission/sensor.py homeassistant/components/transmission/switch.py @@ -1065,15 +1167,24 @@ omit = homeassistant/components/transmission/errors.py homeassistant/components/travisci/sensor.py homeassistant/components/tuya/__init__.py + homeassistant/components/tuya/base.py + homeassistant/components/tuya/binary_sensor.py + homeassistant/components/tuya/button.py + homeassistant/components/tuya/camera.py homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py homeassistant/components/tuya/cover.py homeassistant/components/tuya/fan.py + homeassistant/components/tuya/humidifier.py homeassistant/components/tuya/light.py + homeassistant/components/tuya/number.py homeassistant/components/tuya/scene.py + homeassistant/components/tuya/select.py + homeassistant/components/tuya/sensor.py + homeassistant/components/tuya/siren.py homeassistant/components/tuya/switch.py - homeassistant/components/twentemilieu/const.py - homeassistant/components/twentemilieu/sensor.py + homeassistant/components/tuya/util.py + homeassistant/components/tuya/vacuum.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py homeassistant/components/twitter/notify.py @@ -1089,7 +1200,6 @@ omit = homeassistant/components/upcloud/switch.py homeassistant/components/upnp/* homeassistant/components/upc_connect/* - homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uscis/sensor.py homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py @@ -1102,7 +1212,10 @@ omit = homeassistant/components/velbus/sensor.py homeassistant/components/velbus/switch.py homeassistant/components/velux/* + homeassistant/components/venstar/__init__.py + homeassistant/components/venstar/binary_sensor.py homeassistant/components/venstar/climate.py + homeassistant/components/venstar/sensor.py homeassistant/components/verisure/__init__.py homeassistant/components/verisure/alarm_control_panel.py homeassistant/components/verisure/binary_sensor.py @@ -1119,12 +1232,18 @@ omit = homeassistant/components/vesync/light.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py - homeassistant/components/vicare/* + homeassistant/components/vicare/binary_sensor.py + homeassistant/components/vicare/climate.py + homeassistant/components/vicare/const.py + homeassistant/components/vicare/__init__.py + homeassistant/components/vicare/sensor.py + homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py homeassistant/components/vilfo/sensor.py homeassistant/components/vilfo/const.py homeassistant/components/vivotek/camera.py homeassistant/components/vlc/media_player.py + homeassistant/components/vlc_telnet/__init__.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py @@ -1136,13 +1255,14 @@ omit = homeassistant/components/waterfurnace/* homeassistant/components/watson_iot/* homeassistant/components/watson_tts/tts.py + homeassistant/components/watttime/__init__.py + homeassistant/components/watttime/sensor.py homeassistant/components/waze_travel_time/__init__.py homeassistant/components/waze_travel_time/helpers.py homeassistant/components/waze_travel_time/sensor.py homeassistant/components/webostv/* homeassistant/components/whois/sensor.py homeassistant/components/wiffi/* - homeassistant/components/wink/* homeassistant/components/wirelesstag/* homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/sensor.py @@ -1173,23 +1293,35 @@ omit = homeassistant/components/xiaomi_miio/__init__.py homeassistant/components/xiaomi_miio/air_quality.py homeassistant/components/xiaomi_miio/alarm_control_panel.py + homeassistant/components/xiaomi_miio/binary_sensor.py homeassistant/components/xiaomi_miio/device.py homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py homeassistant/components/xiaomi_miio/gateway.py + homeassistant/components/xiaomi_miio/humidifier.py homeassistant/components/xiaomi_miio/light.py + homeassistant/components/xiaomi_miio/number.py homeassistant/components/xiaomi_miio/remote.py + homeassistant/components/xiaomi_miio/select.py homeassistant/components/xiaomi_miio/sensor.py homeassistant/components/xiaomi_miio/switch.py - homeassistant/components/xiaomi_miio/vacuum.py homeassistant/components/xiaomi_tv/media_player.py homeassistant/components/xmpp/notify.py homeassistant/components/xs1/* + homeassistant/components/yale_smart_alarm/__init__.py homeassistant/components/yale_smart_alarm/alarm_control_panel.py + homeassistant/components/yale_smart_alarm/const.py + homeassistant/components/yale_smart_alarm/coordinator.py + homeassistant/components/yamaha_musiccast/__init__.py homeassistant/components/yamaha_musiccast/media_player.py + homeassistant/components/yamaha_musiccast/number.py + homeassistant/components/yamaha_musiccast/select.py homeassistant/components/yandex_transport/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py + homeassistant/components/youless/__init__.py + homeassistant/components/youless/const.py + homeassistant/components/youless/sensor.py homeassistant/components/zabbix/* homeassistant/components/zamg/sensor.py homeassistant/components/zamg/weather.py @@ -1216,7 +1348,6 @@ omit = homeassistant/components/supla/* homeassistant/components/zwave/util.py homeassistant/components/zwave_js/discovery.py - homeassistant/components/zwave_js/light.py homeassistant/components/zwave_js/sensor.py [report] @@ -1232,5 +1363,6 @@ exclude_lines = raise AssertionError raise NotImplementedError - # TYPE_CHECKING block is never executed during pytest run + # TYPE_CHECKING and @overload blocks are never executed during pytest run if TYPE_CHECKING: + @overload diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efcc038074811..2f94441940e4a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,7 +24,12 @@ "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, - "terminal.integrated.shell.linux": "/usr/bin/zsh", + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", "yaml.customTags": [ "!input scalar", "!secret scalar", diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 116afec36eecb..ac4c8453327a3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -15,7 +15,7 @@ body: attributes: label: The problem description: >- - Describe the issue you are experiencing here to communicate to the + Describe the issue you are experiencing here, to communicate to the maintainers. Tell us what you were trying to do and what happened. Provide a clear and concise description of what the problem is. @@ -28,10 +28,12 @@ body: validations: required: true attributes: - label: What is version of Home Assistant Core has the issue? + label: What version of Home Assistant Core has the issue? placeholder: core- description: > - Can be found in the Configuration panel -> Info. + Can be found in: [Configuration panel -> Info](https://my.home-assistant.io/redirect/info/). + + [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) - type: input attributes: label: What was the last working version of Home Assistant Core? @@ -44,7 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - If you don't know, you can find it in: Configuration panel -> Info. + Can be found in: [Configuration panel -> Info](https://my.home-assistant.io/redirect/info/). + + [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) options: - Home Assistant OS - Home Assistant Container @@ -55,15 +59,15 @@ body: attributes: label: Integration causing the issue description: > - The name of the integration, for example, Automation or Philips Hue. + The name of the integration. For example: Automation, Philips Hue - type: input id: integration_link attributes: label: Link to integration documentation on our website placeholder: "https://www.home-assistant.io/integrations/..." description: | - Providing a link [to the documentation][docs] help us categorizing the - issue, while providing a useful reference at the same time. + Providing a link [to the documentation][docs] helps us categorize the + issue, while also providing a useful reference for others. [docs]: https://www.home-assistant.io/integrations @@ -75,8 +79,8 @@ body: attributes: label: Example YAML snippet description: | - If this issue has an example piece of YAML that can help reproducing this problem, please provide. - This can be an piece of YAML from, e.g., an automation, script, scene or configuration. + If applicable, please provide an example piece of YAML that can help reproduce this problem. + This can be from an automation, script, scene or configuration. render: yaml - type: textarea attributes: @@ -88,5 +92,3 @@ body: label: Additional information description: > If you have any additional information for us, use the field below. - Please note, you can attach screenshots or screen recordings here, by - dragging and dropping files in the field below. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7c169580cb291..974022834fbf2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -71,6 +71,7 @@ If the code communicates with devices, web services, or third-party tools: Updated and included derived files by running: `python3 -m script.hassfest`. - [ ] New or updated dependencies have been added to `requirements_all.txt`. Updated by running `python3 -m script.gen_requirements_all`. +- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] Untested files have been added to `.coveragerc`. The integration reached or maintains the following [Integration Quality Scale][quality-scale]: diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 32ea439b830ba..884e7585dcd70 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -23,12 +23,12 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -47,6 +47,17 @@ jobs: with: ignore-dev: true + - name: Generate meta info + shell: bash + run: | + echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > OFFICIAL_IMAGE + + - name: Signing meta info file + uses: home-assistant/actions/helpers/codenotary@master + with: + source: file://${{ github.workspace }}/OFFICIAL_IMAGE + token: ${{ secrets.CAS_TOKEN }} + build_python: name: Build PyPi package needs: init @@ -54,10 +65,10 @@ jobs: if: needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -84,11 +95,11 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -101,29 +112,34 @@ jobs: python3 script/version_bump.py nightly version="$(python setup.py -V)" + - name: Write meta info file + shell: bash + run: | + echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE + - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v1.12.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v1.12.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.04.2 + uses: home-assistant/builder@2021.12.0 with: args: | $BUILD_ARGS \ --${{ matrix.arch }} \ --target /data \ - --with-codenotary "${{ secrets.VCN_USER }}" "${{ secrets.VCN_PASSWORD }}" "${{ secrets.VCN_ORG }}" \ - --validate-from "${{ secrets.VCN_ORG }}" \ --generic ${{ needs.init.outputs.version }} + env: + CAS_API_KEY: ${{ secrets.CAS_TOKEN }} build_machine: name: Build ${{ matrix.machine }} machine core image @@ -134,6 +150,7 @@ jobs: machine: - generic-x86-64 - intel-nuc + - khadas-vim3 - odroid-c2 - odroid-c4 - odroid-n2 @@ -151,30 +168,41 @@ jobs: - tinker steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 + + - name: Set build additional args + run: | + # Create general tags + if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then + echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV + elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then + echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV + else + echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV + fi - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v1.12.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v1.12.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.04.2 + uses: home-assistant/builder@2021.12.0 with: args: | $BUILD_ARGS \ --target /data/machine \ - --with-codenotary "${{ secrets.VCN_USER }}" "${{ secrets.VCN_PASSWORD }}" "${{ secrets.VCN_ORG }}" \ - --validate-from "${{ secrets.VCN_ORG }}" \ --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" + env: + CAS_API_KEY: ${{ secrets.CAS_TOKEN }} publish_ha: name: Publish version files @@ -182,7 +210,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -214,26 +242,27 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v1.12.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v1.12.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Install CAS tools + uses: home-assistant/actions/helpers/cas@master + - name: Build Meta Image shell: bash run: | - bash <(curl https://getvcn.codenotary.com -L) - export DOCKER_CLI_EXPERIMENTAL=enabled function create_manifest() { @@ -273,8 +302,7 @@ jobs: function validate_image() { local image=${1} - state="$(vcn authenticate --org home-assistant.io --output json docker://${image} | jq '.verification.status // 2')" - if [[ "${state}" != "0" ]]; then + if ! cas authenticate --signerID notary@home-assistant.io "docker://${image}"; then echo "Invalid signature!" exit 1 fi @@ -307,5 +335,9 @@ jobs: create_manifest "${docker_reg}" "latest" "${{ needs.init.outputs.version }}" create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}" create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}" + + # Create series version tag (e.g. 2021.6) + v="${{ needs.init.outputs.version }}" + create_manifest "${docker_reg}" "${v%.*}" "${{ needs.init.outputs.version }}" fi done diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 50b346b7843f6..0df18ce3bf002 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,12 +10,121 @@ on: pull_request: ~ env: - CACHE_VERSION: 1 + CACHE_VERSION: 5 DEFAULT_PYTHON: 3.8 PRE_COMMIT_CACHE: ~/.cache/pre-commit SQLALCHEMY_WARN_20: 1 + PYTHONASYNCIODEBUG: 1 + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: + changes: + name: Determine what has changed + outputs: + # In case of issues with the partial run, use the following line instead: + # test_full_suite: 'true' + test_full_suite: ${{ steps.info.outputs.test_full_suite }} + core: ${{ steps.core.outputs.changes }} + integrations: ${{ steps.integrations.outputs.changes }} + integrations_glob: ${{ steps.info.outputs.integrations_glob }} + tests: ${{ steps.info.outputs.tests }} + tests_glob: ${{ steps.info.outputs.tests_glob }} + test_groups: ${{ steps.info.outputs.test_groups }} + test_group_count: ${{ steps.info.outputs.test_group_count }} + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2.4.0 + - name: Filter for core changes + uses: dorny/paths-filter@v2.10.2 + id: core + with: + filters: .core_files.yaml + - name: Create a list of integrations to filter for changes + run: | + integrations=$(ls -Ad ./homeassistant/components/[!_]* | xargs -n 1 basename) + touch .integration_paths.yaml + for integration in $integrations; do + echo "${integration}: [homeassistant/components/${integration}/*, tests/components/${integration}/*]" \ + >> .integration_paths.yaml; + done + echo "Result:" + cat .integration_paths.yaml + - name: Filter for integration changes + uses: dorny/paths-filter@v2.10.2 + id: integrations + with: + filters: .integration_paths.yaml + - name: Collect additional information + id: info + run: | + # Defaults + integrations_glob="" + test_full_suite="true" + test_groups="[1, 2, 3, 4, 5, 6]" + test_group_count=6 + tests="[]" + tests_glob="" + + if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; + then + # Create a file glob for the integrations + integrations_glob=$(echo '${{ steps.integrations.outputs.changes }}' | jq -cSr '. | join(",")') + [[ "${integrations_glob}" == *","* ]] && integrations_glob="{${integrations_glob}}" + + # Create list of testable integrations + possible_integrations=$(echo '${{ steps.integrations.outputs.changes }}' | jq -cSr '.[]') + tests=$( + for integration in ${possible_integrations}; + do + if [[ -d "tests/components/${integration}" ]]; then + echo -n "\"${integration}\","; + fi; + done + ) + + [[ ! -z "${tests}" ]] && tests="${tests::-1}" + tests="[${tests}]" + test_groups="${tests}" + # Test group count should be 1, we don't split partial tests + test_group_count=1 + + # Create a file glob for the integrations tests + tests_glob=$(echo "${tests}" | jq -cSr '. | join(",")') + [[ "${tests_glob}" == *","* ]] && tests_glob="{${tests_glob}}" + + test_full_suite="false" + fi + + # We need to run the full suite on certain branches. + # Or, in case core files are touched, for the full suite as well. + if [[ "${{ github.ref }}" == "refs/heads/dev" ]] \ + || [[ "${{ github.ref }}" == "refs/heads/master" ]] \ + || [[ "${{ github.ref }}" == "refs/heads/rc" ]] \ + || [[ "${{ steps.core.outputs.any }}" == "true" ]]; + then + test_groups="[1, 2, 3, 4, 5, 6]" + test_group_count=6 + test_full_suite="true" + fi + + # Output & sent to GitHub Actions + echo "test_full_suite: ${test_full_suite}" + echo "::set-output name=test_full_suite::${test_full_suite}" + echo "integrations_glob: ${integrations_glob}" + echo "::set-output name=integrations_glob::${integrations_glob}" + echo "test_group_count: ${test_group_count}" + echo "::set-output name=test_group_count::${test_group_count}" + echo "test_groups: ${test_groups}" + echo "::set-output name=test_groups::${test_groups}" + echo "tests: ${tests}" + echo "::set-output name=tests::${tests}" + echo "tests_glob: ${tests_glob}" + echo "::set-output name=tests_glob::${tests_glob}" + # Separate job to pre-populate the base dependency cache # This prevent upcoming jobs to do the same individually prepare-base: @@ -26,10 +135,10 @@ jobs: pre-commit-key: ${{ steps.generate-pre-commit-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Generate partial Python venv restore key @@ -41,22 +150,27 @@ jobs: hashFiles('homeassistant/package_constraints.txt') }}" - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ steps.generate-python-key.outputs.key }} - restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_test.txt') }}- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}- + # Temporary disabling the restore of environments when bumping + # a dependency. It seems that we are experiencing issues with + # restoring environments in GitHub Actions, although unclear why. + # First attempt: https://github.com/home-assistant/core/pull/62383 + # + # restore-keys: | + # ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_test.txt') }}- + # ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}- + # ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}- - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" setuptools + pip install -U "pip<20.3" setuptools wheel pip install -r requirements.txt -r requirements_test.txt - name: Generate partial pre-commit restore key id: generate-pre-commit-key @@ -65,7 +179,7 @@ jobs: hashFiles('.pre-commit-config.yaml') }}" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -78,61 +192,23 @@ jobs: . venv/bin/activate pre-commit install-hooks - lint-bandit: - name: Check bandit - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.5 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Run bandit - run: | - . venv/bin/activate - pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure - lint-black: name: Check black runs-on: ubuntu-latest - needs: prepare-base + needs: + - changes + - prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -144,7 +220,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -153,131 +229,36 @@ jobs: run: | echo "Failed to restore pre-commit environment from cache" exit 1 - - name: Run black + - name: Run black (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - - lint-codespell: - name: Check codespell - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.5 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Register codespell problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/codespell.json" - - name: Run codespell - run: | - . venv/bin/activate - pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files - - lint-dockerfile: - name: Check Dockerfile - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Register hadolint problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/hadolint.json" - - name: Check Dockerfile - uses: docker://hadolint/hadolint:v1.18.2 - with: - args: hadolint Dockerfile - - name: Check Dockerfile.dev - uses: docker://hadolint/hadolint:v1.18.2 - with: - args: hadolint Dockerfile.dev - - lint-executable-shebangs: - name: Check executables - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.5 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Register check executables problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" - - name: Run executables check + - name: Run black (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash run: | . venv/bin/activate - pre-commit run --hook-stage manual check-executables-have-shebangs --all-files + shopt -s globstar + pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/**/* --show-diff-on-failure lint-flake8: name: Check flake8 runs-on: ubuntu-latest - needs: prepare-base + needs: + - changes + - prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -289,7 +270,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -301,10 +282,18 @@ jobs: - name: Register flake8 problem matcher run: | echo "::add-matcher::.github/workflows/matchers/flake8.json" - - name: Run flake8 + - name: Run flake8 (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate pre-commit run --hook-stage manual flake8 --all-files + - name: Run flake8 (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + shopt -s globstar + pre-commit run --hook-stage manual flake8 --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/**/* lint-isort: name: Check isort @@ -312,15 +301,15 @@ jobs: needs: prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -332,7 +321,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -346,21 +335,23 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure - lint-json: - name: Check JSON + lint-other: + name: Check other linters runs-on: ubuntu-latest - needs: prepare-base + needs: + - changes + - prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -372,7 +363,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -381,6 +372,28 @@ jobs: run: | echo "Failed to restore pre-commit environment from cache" exit 1 + + - name: Run pyupgrade (fully) + if: needs.changes.outputs.test_full_suite == 'true' + run: | + . venv/bin/activate + pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure + - name: Run pyupgrade (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + shopt -s globstar + pre-commit run --hook-stage manual pyupgrade --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/**/* --show-diff-on-failure + + - name: Register yamllint problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/yamllint.json" + - name: Run yamllint + run: | + . venv/bin/activate + pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure + - name: Register check-json problem matcher run: | echo "::add-matcher::.github/workflows/matchers/check-json.json" @@ -389,99 +402,46 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual check-json --all-files - lint-pyupgrade: - name: Check pyupgrade - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.5 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' + - name: Register check executables problem matcher run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Run pyupgrade + echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" + - name: Run executables check run: | . venv/bin/activate - pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure + pre-commit run --hook-stage manual check-executables-have-shebangs --all-files - # Disabled until we have the existing issues fixed - # lint-shellcheck: - # name: Check ShellCheck - # runs-on: ubuntu-latest - # needs: prepare-base - # steps: - # - name: Check out code from GitHub - # uses: actions/checkout@v2 - # - name: Run ShellCheck - # uses: ludeeus/action-shellcheck@0.3.0 + - name: Register codespell problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/codespell.json" + - name: Run codespell + run: | + . venv/bin/activate + pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files - lint-yaml: - name: Check YAML - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' + - name: Register hadolint problem matcher run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.5 + echo "::add-matcher::.github/workflows/matchers/hadolint.json" + - name: Check Dockerfile + uses: docker://hadolint/hadolint:v1.18.2 with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Register yamllint problem matcher + args: hadolint Dockerfile + - name: Check Dockerfile.dev + uses: docker://hadolint/hadolint:v1.18.2 + with: + args: hadolint Dockerfile.dev + + - name: Run bandit (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | - echo "::add-matcher::.github/workflows/matchers/yamllint.json" - - name: Run yamllint + . venv/bin/activate + pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure + - name: Run bandit (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash run: | . venv/bin/activate - pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure + shopt -s globstar + pre-commit run --hook-stage manual bandit --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/**/* --show-diff-on-failure hassfest: name: Check hassfest @@ -493,10 +453,10 @@ jobs: container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -517,15 +477,15 @@ jobs: needs: prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -551,7 +511,7 @@ jobs: container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Generate partial Python venv restore key id: generate-python-key run: >- @@ -561,16 +521,21 @@ jobs: hashFiles('homeassistant/package_constraints.txt') }}" - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: >- ${{ runner.os }}-${{ matrix.python-version }}-${{ steps.generate-python-key.outputs.key }} - restore-keys: | - ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('requirements_all.txt') }}- - ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}- - ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}- + # Temporary disabling the restore of environments when bumping + # a dependency. It seems that we are experiencing issues with + # restoring environments in GitHub Actions, although unclear why. + # First attempt: https://github.com/home-assistant/core/pull/62383 + # + # restore-keys: | + # ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('requirements_all.txt') }}- + # ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}- + # ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}- - name: Create full Python ${{ matrix.python-version }} virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -588,17 +553,19 @@ jobs: pylint: name: Check pylint runs-on: ubuntu-latest - needs: prepare-tests + needs: + - changes + - prepare-tests strategy: matrix: python-version: [3.8] container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -611,25 +578,34 @@ jobs: - name: Register pylint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/pylint.json" - - name: Run pylint + - name: Run pylint (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate pylint homeassistant + - name: Run pylint (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + pylint homeassistant/components/${{ needs.changes.outputs.integrations_glob }} mypy: name: Check mypy runs-on: ubuntu-latest - needs: prepare-tests + needs: + - changes + - prepare-tests strategy: matrix: python-version: [3.8] container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -642,28 +618,44 @@ jobs: - name: Register mypy problem matcher run: | echo "::add-matcher::.github/workflows/matchers/mypy.json" - - name: Run mypy + - name: Run mypy (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate mypy homeassistant + - name: Run mypy (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + mypy homeassistant/components/${{ needs.changes.outputs.integrations_glob }} pytest: runs-on: ubuntu-latest - needs: prepare-tests + if: needs.changes.outputs.test_full_suite == 'true' || needs.changes.outputs.tests_glob + needs: + - changes + - gen-requirements-all + - hassfest + - lint-black + - lint-other + - lint-isort + - mypy + - prepare-tests strategy: fail-fast: false matrix: - group: [1, 2, 3, 4] + group: ${{ fromJson(needs.changes.outputs.test_groups) }} python-version: [3.8, 3.9] name: >- - Run tests Python ${{ matrix.python-version }} (group ${{ matrix.group }}) + Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.5 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -682,62 +674,83 @@ jobs: # Ideally this should be part of our dependencies # However this plugin is fairly new and doesn't run correctly # on a non-GitHub environment. - pip install pytest-github-actions-annotate-failures - - name: Run pytest + pip install pytest-github-actions-annotate-failures==0.1.3 + - name: Register pytest slow test problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" + - name: Run pytest (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate - pytest \ + python3 -X dev -m pytest \ -qq \ --timeout=9 \ --durations=10 \ -n auto \ --dist=loadfile \ - --test-group-count 4 \ + --test-group-count ${{ needs.changes.outputs.test_group_count }} \ --test-group=${{ matrix.group }} \ --cov homeassistant \ - --cov-report= \ + --cov-report=xml \ -o console_output_style=count \ -p no:sugar \ tests + - name: Run pytest (partially) + if: needs.changes.outputs.test_full_suite == 'false' && matrix.python-version != '3.8' + run: | + . venv/bin/activate + python3 -X dev -m pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov homeassistant.components.${{ matrix.group }} \ + --cov-report=xml \ + --cov-report=term-missing \ + -o console_output_style=count \ + --durations=0 \ + --durations-min=1 \ + -p no:sugar \ + tests/components/${{ matrix.group }} + - name: Run pytest (partially); no coverage + if: needs.changes.outputs.test_full_suite == 'false' && matrix.python-version == '3.8' + run: | + . venv/bin/activate + python3 -X dev -m pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + -o console_output_style=count \ + --durations=0 \ + --durations-min=1 \ + -p no:sugar \ + tests/components/${{ matrix.group }} - name: Upload coverage artifact - uses: actions/upload-artifact@v2.2.3 + uses: actions/upload-artifact@v2.3.1 with: - name: coverage-${{ matrix.python-version }}-group${{ matrix.group }} - path: .coverage + name: coverage-${{ matrix.python-version }}-${{ matrix.group }} + path: coverage.xml - name: Check dirty run: | ./script/check_dirty coverage: - name: Process test coverage + name: Upload test coverage to Codecov runs-on: ubuntu-latest - needs: ["prepare-tests", "pytest"] - strategy: - matrix: - python-version: [3.8] - container: homeassistant/ci-azure:${{ matrix.python-version }} + needs: + - changes + - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v2 - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache@v2.1.5 - with: - path: venv - key: ${{ runner.os }}-${{ matrix.python-version }}-${{ - needs.prepare-tests.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 + uses: actions/checkout@v2.4.0 - name: Download all coverage artifacts uses: actions/download-artifact@v2 - - name: Combine coverage results - run: | - . venv/bin/activate - coverage combine coverage*/.coverage* - coverage report --fail-under=94 - coverage xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.5.0 + - name: Upload coverage to Codecov (full coverage) + if: needs.changes.outputs.test_full_suite == 'true' + uses: codecov/codecov-action@v2.1.0 + with: + flags: full-suite + - name: Upload coverage to Codecov (partial coverage) + if: needs.changes.outputs.test_full_suite == 'false' + uses: codecov/codecov-action@v2.1.0 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 3059dc5e2ef11..6be819f9b822e 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,12 +9,12 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2.0.3 + - uses: dessant/lock-threads@v3 with: github-token: ${{ github.token }} - issue-lock-inactive-days: "30" - issue-exclude-created-before: "2020-10-01T00:00:00Z" + issue-inactive-days: "30" + exclude-issue-created-before: "2020-10-01T00:00:00Z" issue-lock-reason: "" - pr-lock-inactive-days: "1" - pr-exclude-created-before: "2020-11-01T00:00:00Z" + pr-inactive-days: "1" + exclude-pr-created-before: "2020-11-01T00:00:00Z" pr-lock-reason: "" diff --git a/.github/workflows/matchers/pytest-slow.json b/.github/workflows/matchers/pytest-slow.json new file mode 100644 index 0000000000000..31f565a594a50 --- /dev/null +++ b/.github/workflows/matchers/pytest-slow.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "python", + "pattern": [ + { + "regexp": "^=+ slowest durations =+$" + }, + { + "regexp": "^((.*s)\\s(call|setup|teardown)\\s+(.*)::(.*))$", + "message": 1, + "file": 2, + "loop": true + } + ] + } + ] +} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3ff0f47cedc14..4770780341da4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # - No PRs marked as no-stale # - No issues marked as no-stale or help-wanted - name: 90 days stale issues & PRs policy - uses: actions/stale@v3.0.18 + uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No PRs marked as no-stale or new-integrations # - No issues (-1) - name: 30 days stale PRs policy - uses: actions/stale@v3.0.18 + uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 @@ -78,7 +78,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v3.0.18 + uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml new file mode 100644 index 0000000000000..6c9c6700e9fdc --- /dev/null +++ b/.github/workflows/translations.yaml @@ -0,0 +1,64 @@ +name: Translations + +# yamllint disable-line rule:truthy +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - dev + paths: + - "**strings.json" + +env: + DEFAULT_PYTHON: 3.8 + +jobs: + upload: + name: Upload + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v2.4.0 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.3.1 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Upload Translations + run: | + export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}" + python3 -m script.translations upload + + download: + name: Download + needs: upload + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v2.4.0 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.3.1 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Download Translations + run: | + export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}" + python3 -m script.translations download + + - name: Initialize git + uses: home-assistant/actions/helpers/git-init@master + with: + name: GitHub Action + email: github-action@users.noreply.github.com + + - name: Update translation + run: | + git add homeassistant + git commit -am "[ci skip] Translation update" + git push diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 16818a37cb2ea..bcb2595bf00b2 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -21,7 +21,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Get information id: info @@ -41,16 +41,17 @@ jobs: echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false" echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" + echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v2.3.1 with: name: env_file path: ./.env_file - name: Upload requirements_diff - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v2.3.1 with: name: requirements_diff path: ./requirements_diff.txt @@ -60,14 +61,14 @@ jobs: needs: init runs-on: ubuntu-latest strategy: + fail-fast: false matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - - "3.8-alpine3.12" - - "3.9-alpine3.13" + - "3.9-alpine3.14" steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Download env_file uses: actions/download-artifact@v2 @@ -80,15 +81,15 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@master + uses: home-assistant/wheels@2021.07.0 with: tag: ${{ matrix.tag }} arch: ${{ matrix.arch }} - wheels-host: ${{ secrets.WHEELS_HOST }} + wheels-host: wheels.hass.io wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev" + apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;cargo" pip: "Cython;numpy" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" @@ -100,14 +101,14 @@ jobs: needs: init runs-on: ubuntu-latest strategy: + fail-fast: false matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - - "3.8-alpine3.12" - - "3.9-alpine3.13" + - "3.9-alpine3.14" steps: - name: Checkout the repository - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 - name: Download env_file uses: actions/download-artifact@v2 @@ -144,21 +145,20 @@ jobs: sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} - sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} sed -i "s|# bme680|bme680|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} done - name: Build wheels - uses: home-assistant/wheels@master + uses: home-assistant/wheels@2021.07.0 with: tag: ${{ matrix.tag }} arch: ${{ matrix.arch }} - wheels-host: ${{ secrets.WHEELS_HOST }} + wheels-host: wheels.hass.io wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev" + apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;cargo" pip: "Cython;numpy;scikit-build" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" diff --git a/.gitignore b/.gitignore index 20c1991c45d3a..d6f7198fcd464 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ -config/* +/config config2/* tests/testing_config/deps -tests/testing_config/home-assistant.log +tests/testing_config/home-assistant.log* # hass-release data/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15e723feb2645..fbd954a8103ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.14.0 + rev: v2.29.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 21.5b0 + rev: 21.12b0 hooks: - id: black args: @@ -17,22 +17,22 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort + - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,iif,ines,ist,lightsensor,mut,nd,pres,referer,rime,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba,haa - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/ - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.1 + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: - - pycodestyle==2.7.0 - - pyflakes==2.3.1 + - pycodestyle==2.8.0 + - pyflakes==2.4.0 - flake8-docstrings==1.6.0 - - pydocstyle==6.0.0 - - flake8-comprehensions==3.4.0 - - flake8-noqa==1.1.0 + - pydocstyle==6.1.1 + - flake8-comprehensions==3.7.0 + - flake8-noqa==1.2.0 - mccabe==0.6.1 files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/bandit @@ -45,7 +45,7 @@ repos: - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.8.0 + rev: 5.10.0 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks @@ -61,7 +61,7 @@ repos: - --branch=master - --branch=rc - repo: https://github.com/adrienverge/yamllint.git - rev: v1.26.1 + rev: v1.26.3 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier @@ -70,7 +70,7 @@ repos: - id: prettier stages: [manual] - repo: https://github.com/cdce8p/python-typing-update - rev: v0.3.3 + rev: v0.3.5 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. diff --git a/.strict-typing b/.strict-typing index f6ed1ba63af04..66fc72efc777f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -3,51 +3,160 @@ # to enable strict mypy checks. homeassistant.components +homeassistant.components.acer_projector.* +homeassistant.components.accuweather.* +homeassistant.components.actiontec.* +homeassistant.components.aftership.* +homeassistant.components.air_quality.* homeassistant.components.airly.* +homeassistant.components.airvisual.* +homeassistant.components.aladdin_connect.* +homeassistant.components.alarm_control_panel.* +homeassistant.components.amazon_polly.* +homeassistant.components.ambee.* +homeassistant.components.ambient_station.* +homeassistant.components.amcrest.* +homeassistant.components.ampio.* +homeassistant.components.aseko_pool_live.* homeassistant.components.automation.* homeassistant.components.binary_sensor.* +homeassistant.components.bluetooth_tracker.* +homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* +homeassistant.components.braviatv.* homeassistant.components.brother.* +homeassistant.components.button.* homeassistant.components.calendar.* homeassistant.components.camera.* +homeassistant.components.canary.* homeassistant.components.cover.* +homeassistant.components.crownstone.* homeassistant.components.device_automation.* +homeassistant.components.device_tracker.* +homeassistant.components.devolo_home_control.* +homeassistant.components.devolo_home_network.* +homeassistant.components.dlna_dmr.* +homeassistant.components.dnsip.* +homeassistant.components.dsmr.* +homeassistant.components.dunehd.* +homeassistant.components.efergy.* homeassistant.components.elgato.* +homeassistant.components.esphome.* +homeassistant.components.energy.* +homeassistant.components.evil_genius_labs.* +homeassistant.components.fastdotcom.* +homeassistant.components.fitbit.* +homeassistant.components.flunearyou.* +homeassistant.components.flux_led.* +homeassistant.components.forecast_solar.* +homeassistant.components.fritzbox.* +homeassistant.components.fronius.* homeassistant.components.frontend.* +homeassistant.components.fritz.* homeassistant.components.geo_location.* +homeassistant.components.gios.* +homeassistant.components.goalzero.* +homeassistant.components.greeneye_monitor.* homeassistant.components.group.* +homeassistant.components.guardian.* homeassistant.components.history.* +homeassistant.components.homeassistant.triggers.event homeassistant.components.http.* homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* homeassistant.components.image_processing.* +homeassistant.components.input_button.* +homeassistant.components.input_select.* homeassistant.components.integration.* +homeassistant.components.iqvia.* +homeassistant.components.jellyfin.* +homeassistant.components.jewish_calendar.* homeassistant.components.knx.* +homeassistant.components.kraken.* +homeassistant.components.lcn.* homeassistant.components.light.* +homeassistant.components.local_ip.* homeassistant.components.lock.* +homeassistant.components.lookin.* +homeassistant.components.luftdaten.* homeassistant.components.mailbox.* homeassistant.components.media_player.* +homeassistant.components.modbus.* +homeassistant.components.modem_callerid.* +homeassistant.components.media_source.* +homeassistant.components.mysensors.* homeassistant.components.nam.* +homeassistant.components.nanoleaf.* +homeassistant.components.neato.* +homeassistant.components.nest.* +homeassistant.components.netatmo.* +homeassistant.components.network.* +homeassistant.components.nfandroidtv.* +homeassistant.components.no_ip.* homeassistant.components.notify.* +homeassistant.components.notion.* homeassistant.components.number.* +homeassistant.components.onewire.* +homeassistant.components.open_meteo.* +homeassistant.components.openuv.* homeassistant.components.persistent_notification.* +homeassistant.components.pi_hole.* homeassistant.components.proximity.* +homeassistant.components.pvoutput.* +homeassistant.components.rainmachine.* +homeassistant.components.rdw.* +homeassistant.components.recollect_waste.* homeassistant.components.recorder.purge homeassistant.components.recorder.repack +homeassistant.components.recorder.statistics homeassistant.components.remote.* +homeassistant.components.renault.* +homeassistant.components.ridwell.* +homeassistant.components.rituals_perfume_genie.* +homeassistant.components.rpi_power.* +homeassistant.components.samsungtv.* homeassistant.components.scene.* +homeassistant.components.select.* homeassistant.components.sensor.* +homeassistant.components.shelly.* +homeassistant.components.simplisafe.* homeassistant.components.slack.* homeassistant.components.sonos.media_player +homeassistant.components.ssdp.* +homeassistant.components.stookalert.* +homeassistant.components.statistics.* +homeassistant.components.stream.* homeassistant.components.sun.* +homeassistant.components.surepetcare.* homeassistant.components.switch.* +homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* +homeassistant.components.tag.* +homeassistant.components.tailscale.* +homeassistant.components.tautulli.* +homeassistant.components.tcp.* +homeassistant.components.tile.* +homeassistant.components.tplink.* +homeassistant.components.tolo.* +homeassistant.components.tractive.* +homeassistant.components.tradfri.* homeassistant.components.tts.* +homeassistant.components.twentemilieu.* +homeassistant.components.upcloud.* +homeassistant.components.uptime.* +homeassistant.components.uptimerobot.* homeassistant.components.vacuum.* +homeassistant.components.vallox.* +homeassistant.components.velbus.* +homeassistant.components.vlc_telnet.* +homeassistant.components.wallbox.* homeassistant.components.water_heater.* +homeassistant.components.watttime.* homeassistant.components.weather.* homeassistant.components.websocket_api.* +homeassistant.components.wemo.* +homeassistant.components.zodiac.* homeassistant.components.zeroconf.* homeassistant.components.zone.* homeassistant.components.zwave_js.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0226b3f4361a5..77d31339a6976 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,13 +2,10 @@ "version": "2.0.0", "tasks": [ { - "label": "Preview", + "label": "Run Home Assistant Core", "type": "shell", "command": "hass -c ./config", - "group": { - "kind": "test", - "isDefault": true - }, + "group": "test", "presentation": { "reveal": "always", "panel": "new" @@ -19,7 +16,9 @@ "label": "Pytest", "type": "shell", "command": "pytest --timeout=10 tests", - "dependsOn": ["Install all Test Requirements"], + "dependsOn": [ + "Install all Test Requirements" + ], "group": { "kind": "test", "isDefault": true @@ -48,7 +47,24 @@ "label": "Pylint", "type": "shell", "command": "pylint homeassistant", - "dependsOn": ["Install all Requirements"], + "dependsOn": [ + "Install all Requirements" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Code Coverage", + "detail": "Generate code coverage report for a given integration.", + "type": "shell", + "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "group": { "kind": "test", "isDefault": true @@ -101,5 +117,12 @@ }, "problemMatcher": [] } + ], + "inputs": [ + { + "id": "integrationName", + "type": "promptString", + "description": "For which integration should the task run?" + } ] } diff --git a/CODEOWNERS b/CODEOWNERS index c2824fb33b63b..217ffb0b22b36 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -20,558 +20,1066 @@ homeassistant/scripts/check_config.py @kellerza # Integrations homeassistant/components/abode/* @shred86 +tests/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu +tests/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray +tests/components/acmeda/* @atmurray +homeassistant/components/adax/* @danielhiversen +tests/components/adax/* @danielhiversen homeassistant/components/adguard/* @frenck +tests/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 +tests/components/advantage_air/* @Bre77 homeassistant/components/aemet/* @noltari +tests/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware +tests/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu +tests/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks +tests/components/airnow/* @asymworks +homeassistant/components/airthings/* @danielhiversen +tests/components/airthings/* @danielhiversen +homeassistant/components/airtouch4/* @LonePurpleWolf +tests/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya +tests/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 +tests/components/alarmdecoder/* @ajschmidt8 homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy +tests/components/alexa/* @home-assistant/cloud @ochlocracy homeassistant/components/almond/* @gcampax @balloob +tests/components/almond/* @gcampax @balloob homeassistant/components/alpha_vantage/* @fabaff +homeassistant/components/ambee/* @frenck +tests/components/ambee/* @frenck +homeassistant/components/amberelectric/* @madpilot +tests/components/amberelectric/* @madpilot homeassistant/components/ambiclimate/* @danielhiversen +tests/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya +tests/components/ambient_station/* @bachya +homeassistant/components/amcrest/* @flacjacket homeassistant/components/analytics/* @home-assistant/core @ludeeus -homeassistant/components/androidtv/* @JeffLIrion +tests/components/analytics/* @home-assistant/core @ludeeus +homeassistant/components/androidtv/* @JeffLIrion @ollo69 +tests/components/androidtv/* @JeffLIrion @ollo69 homeassistant/components/apache_kafka/* @bachya +tests/components/apache_kafka/* @bachya homeassistant/components/api/* @home-assistant/core +tests/components/api/* @home-assistant/core homeassistant/components/apple_tv/* @postlund +tests/components/apple_tv/* @postlund homeassistant/components/apprise/* @caronc +tests/components/apprise/* @caronc homeassistant/components/aprs/* @PhilRW +tests/components/aprs/* @PhilRW homeassistant/components/arcam_fmj/* @elupus +tests/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff homeassistant/components/arris_tg2492lg/* @vanbalken +homeassistant/components/aseko_pool_live/* @milanmeu +tests/components/aseko_pool_live/* @milanmeu homeassistant/components/asuswrt/* @kennedyshead @ollo69 +tests/components/asuswrt/* @kennedyshead @ollo69 homeassistant/components/atag/* @MatsNL +tests/components/atag/* @MatsNL homeassistant/components/aten_pe/* @mtdcr homeassistant/components/atome/* @baqs homeassistant/components/august/* @bdraco +tests/components/august/* @bdraco homeassistant/components/aurora/* @djtimca +tests/components/aurora/* @djtimca homeassistant/components/aurora_abb_powerone/* @davet2001 +tests/components/aurora_abb_powerone/* @davet2001 homeassistant/components/auth/* @home-assistant/core +tests/components/auth/* @home-assistant/core homeassistant/components/automation/* @home-assistant/core +tests/components/automation/* @home-assistant/core homeassistant/components/avea/* @pattyland homeassistant/components/awair/* @ahayworth @danielsjf +tests/components/awair/* @ahayworth @danielsjf homeassistant/components/axis/* @Kane610 +tests/components/axis/* @Kane610 homeassistant/components/azure_devops/* @timmo001 +tests/components/azure_devops/* @timmo001 homeassistant/components/azure_event_hub/* @eavanvalkenburg +tests/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_service_bus/* @hfurubotten +homeassistant/components/balboa/* @garbled1 +tests/components/balboa/* @garbled1 homeassistant/components/beewi_smartclim/* @alemuro homeassistant/components/bitcoin/* @fabaff homeassistant/components/bizkaibus/* @UgaitzEtxebarria -homeassistant/components/blebox/* @gadgetmobile +homeassistant/components/blebox/* @bbx-a @bbx-jp +tests/components/blebox/* @bbx-a @bbx-jp homeassistant/components/blink/* @fronzbot +tests/components/blink/* @fronzbot homeassistant/components/blueprint/* @home-assistant/core +tests/components/blueprint/* @home-assistant/core +homeassistant/components/bluesound/* @thrawnarn homeassistant/components/bmp280/* @belidzs homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe -homeassistant/components/bond/* @prystupa -homeassistant/components/braviatv/* @bieniu -homeassistant/components/broadlink/* @danielhiversen @felipediel +tests/components/bmw_connected_drive/* @gerard33 @rikroe +homeassistant/components/bond/* @bdraco @prystupa @joshs85 +tests/components/bond/* @bdraco @prystupa @joshs85 +homeassistant/components/bosch_shc/* @tschamm +tests/components/bosch_shc/* @tschamm +homeassistant/components/braviatv/* @bieniu @Drafteed +tests/components/braviatv/* @bieniu @Drafteed +homeassistant/components/broadlink/* @danielhiversen @felipediel @L-I-Am +tests/components/broadlink/* @danielhiversen @felipediel @L-I-Am homeassistant/components/brother/* @bieniu +tests/components/brother/* @bieniu homeassistant/components/brunt/* @eavanvalkenburg +tests/components/brunt/* @eavanvalkenburg homeassistant/components/bsblan/* @liudger +tests/components/bsblan/* @liudger homeassistant/components/bt_smarthub/* @jxwolstenholme homeassistant/components/buienradar/* @mjj4791 @ties @Robbie1221 +tests/components/buienradar/* @mjj4791 @ties @Robbie1221 +homeassistant/components/button/* @home-assistant/core +tests/components/button/* @home-assistant/core homeassistant/components/cast/* @emontnemery +tests/components/cast/* @emontnemery homeassistant/components/cert_expiry/* @Cereal2nd @jjlawren +tests/components/cert_expiry/* @Cereal2nd @jjlawren homeassistant/components/circuit/* @braam homeassistant/components/cisco_ios/* @fbradyirl homeassistant/components/cisco_mobility_express/* @fbradyirl homeassistant/components/cisco_webex_teams/* @fbradyirl homeassistant/components/climacell/* @raman325 +tests/components/climacell/* @raman325 homeassistant/components/cloud/* @home-assistant/cloud +tests/components/cloud/* @home-assistant/cloud homeassistant/components/cloudflare/* @ludeeus @ctalkington +tests/components/cloudflare/* @ludeeus @ctalkington +homeassistant/components/coinbase/* @tombrien +tests/components/coinbase/* @tombrien homeassistant/components/color_extractor/* @GenericStudent +tests/components/color_extractor/* @GenericStudent homeassistant/components/comfoconnect/* @michaelarnauts +tests/components/comfoconnect/* @michaelarnauts homeassistant/components/compensation/* @Petro31 +tests/components/compensation/* @Petro31 homeassistant/components/config/* @home-assistant/core +tests/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core +tests/components/configurator/* @home-assistant/core homeassistant/components/control4/* @lawtancool +tests/components/control4/* @lawtancool homeassistant/components/conversation/* @home-assistant/core +tests/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund -homeassistant/components/coronavirus/* @home_assistant/core +tests/components/coolmaster/* @OnFreund +homeassistant/components/coronavirus/* @home-assistant/core +tests/components/coronavirus/* @home-assistant/core homeassistant/components/counter/* @fabaff +tests/components/counter/* @fabaff homeassistant/components/cover/* @home-assistant/core +tests/components/cover/* @home-assistant/core homeassistant/components/cpuspeed/* @fabaff +homeassistant/components/crownstone/* @Crownstone @RicArch97 +tests/components/crownstone/* @Crownstone @RicArch97 homeassistant/components/cups/* @fabaff homeassistant/components/daikin/* @fredrike +tests/components/daikin/* @fredrike homeassistant/components/darksky/* @fabaff +tests/components/darksky/* @fabaff homeassistant/components/debugpy/* @frenck +tests/components/debugpy/* @frenck homeassistant/components/deconz/* @Kane610 +tests/components/deconz/* @Kane610 homeassistant/components/delijn/* @bollewolle @Emilv2 homeassistant/components/demo/* @home-assistant/core -homeassistant/components/denonavr/* @scarface-4711 @starkillerOG +tests/components/demo/* @home-assistant/core +homeassistant/components/denonavr/* @ol-iver @starkillerOG +tests/components/denonavr/* @ol-iver @starkillerOG homeassistant/components/derivative/* @afaucogney +tests/components/derivative/* @afaucogney homeassistant/components/device_automation/* @home-assistant/core +tests/components/device_automation/* @home-assistant/core homeassistant/components/devolo_home_control/* @2Fake @Shutgun +tests/components/devolo_home_control/* @2Fake @Shutgun +homeassistant/components/devolo_home_network/* @2Fake @Shutgun +tests/components/devolo_home_network/* @2Fake @Shutgun homeassistant/components/dexcom/* @gagebenne +tests/components/dexcom/* @gagebenne homeassistant/components/dhcp/* @bdraco +tests/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey homeassistant/components/digital_ocean/* @fabaff -homeassistant/components/directv/* @ctalkington homeassistant/components/discogs/* @thibmaek +homeassistant/components/dlna_dmr/* @StevenLooman @chishm +tests/components/dlna_dmr/* @StevenLooman @chishm homeassistant/components/doorbird/* @oblogic7 @bdraco -homeassistant/components/dsmr/* @Robbie1221 +tests/components/doorbird/* @oblogic7 @bdraco +homeassistant/components/dsmr/* @Robbie1221 @frenck +tests/components/dsmr/* @Robbie1221 @frenck homeassistant/components/dsmr_reader/* @depl0y homeassistant/components/dunehd/* @bieniu +tests/components/dunehd/* @bieniu homeassistant/components/dwd_weather_warnings/* @runningman84 @stephan192 @Hummel95 homeassistant/components/dweet/* @fabaff homeassistant/components/dynalite/* @ziv1234 +tests/components/dynalite/* @ziv1234 homeassistant/components/eafm/* @Jc2k +tests/components/eafm/* @Jc2k homeassistant/components/ecobee/* @marthoc +tests/components/ecobee/* @marthoc homeassistant/components/econet/* @vangorra @w1ll1am23 +tests/components/econet/* @vangorra @w1ll1am23 homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/edl21/* @mtdcr +homeassistant/components/efergy/* @tkdrob +tests/components/efergy/* @tkdrob homeassistant/components/egardia/* @jeroenterheerdt -homeassistant/components/eight_sleep/* @mezz64 +homeassistant/components/eight_sleep/* @mezz64 @raman325 homeassistant/components/elgato/* @frenck +tests/components/elgato/* @frenck homeassistant/components/elkm1/* @gwww @bdraco +tests/components/elkm1/* @gwww @bdraco +homeassistant/components/elmax/* @albertogeniola +tests/components/elmax/* @albertogeniola homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 homeassistant/components/emoncms/* @borpin homeassistant/components/emonitor/* @bdraco +tests/components/emonitor/* @bdraco homeassistant/components/emulated_kasa/* @kbickar +tests/components/emulated_kasa/* @kbickar +homeassistant/components/energy/* @home-assistant/core +tests/components/energy/* @home-assistant/core homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer +tests/components/enocean/* @bdurrer homeassistant/components/enphase_envoy/* @gtdiehl +tests/components/enphase_envoy/* @gtdiehl homeassistant/components/entur_public_transport/* @hfurubotten -homeassistant/components/environment_canada/* @michaeldavie +homeassistant/components/environment_canada/* @gwww @michaeldavie +tests/components/environment_canada/* @gwww @michaeldavie homeassistant/components/ephember/* @ttroy50 homeassistant/components/epson/* @pszafer +tests/components/epson/* @pszafer homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti -homeassistant/components/esphome/* @OttoWinter -homeassistant/components/essent/* @TheLastProject +homeassistant/components/esphome/* @OttoWinter @jesserockz +tests/components/esphome/* @OttoWinter @jesserockz +homeassistant/components/evil_genius_labs/* @balloob +tests/components/evil_genius_labs/* @balloob homeassistant/components/evohome/* @zxdavb homeassistant/components/ezviz/* @RenierM26 @baqs +tests/components/ezviz/* @RenierM26 @baqs homeassistant/components/faa_delays/* @ntilley905 +tests/components/faa_delays/* @ntilley905 homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff +tests/components/file/* @fabaff homeassistant/components/filter/* @dgomes +tests/components/filter/* @dgomes homeassistant/components/fireservicerota/* @cyberjunky +tests/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP +tests/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff +homeassistant/components/fjaraskupan/* @elupus +tests/components/fjaraskupan/* @elupus homeassistant/components/flick_electric/* @ZephireNZ +tests/components/flick_electric/* @ZephireNZ +homeassistant/components/flipr/* @cnico +tests/components/flipr/* @cnico homeassistant/components/flo/* @dmulcahey +tests/components/flo/* @dmulcahey homeassistant/components/flock/* @fabaff homeassistant/components/flume/* @ChrisMandich @bdraco +tests/components/flume/* @ChrisMandich @bdraco homeassistant/components/flunearyou/* @bachya +tests/components/flunearyou/* @bachya +homeassistant/components/flux_led/* @icemanch +tests/components/flux_led/* @icemanch +homeassistant/components/forecast_solar/* @klaasnicolaas @frenck +tests/components/forecast_solar/* @klaasnicolaas @frenck homeassistant/components/forked_daapd/* @uvjustin +tests/components/forked_daapd/* @uvjustin homeassistant/components/fortios/* @kimfrellsen homeassistant/components/foscam/* @skgsergio +tests/components/foscam/* @skgsergio homeassistant/components/freebox/* @hacf-fr @Quentame +tests/components/freebox/* @hacf-fr @Quentame +homeassistant/components/freedompro/* @stefano055415 +tests/components/freedompro/* @stefano055415 homeassistant/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74 -homeassistant/components/fritzbox/* @mib1185 -homeassistant/components/fronius/* @nielstron +tests/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74 +homeassistant/components/fritzbox/* @mib1185 @flabbamann +tests/components/fritzbox/* @mib1185 @flabbamann +homeassistant/components/fronius/* @nielstron @farmio +tests/components/fronius/* @nielstron @farmio homeassistant/components/frontend/* @home-assistant/frontend -homeassistant/components/garmin_connect/* @cyberjunky +tests/components/frontend/* @home-assistant/frontend +homeassistant/components/garages_amsterdam/* @klaasnicolaas +tests/components/garages_amsterdam/* @klaasnicolaas homeassistant/components/gdacs/* @exxamalte +tests/components/gdacs/* @exxamalte +homeassistant/components/generic_hygrostat/* @Shulyaka +tests/components/generic_hygrostat/* @Shulyaka homeassistant/components/geniushub/* @zxdavb +homeassistant/components/geo_json_events/* @exxamalte +tests/components/geo_json_events/* @exxamalte homeassistant/components/geo_rss_events/* @exxamalte +tests/components/geo_rss_events/* @exxamalte homeassistant/components/geonetnz_quakes/* @exxamalte +tests/components/geonetnz_quakes/* @exxamalte homeassistant/components/geonetnz_volcano/* @exxamalte +tests/components/geonetnz_volcano/* @exxamalte homeassistant/components/gios/* @bieniu +tests/components/gios/* @bieniu +homeassistant/components/github/* @timmo001 @ludeeus homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff @engrbm87 +tests/components/glances/* @fabaff @engrbm87 homeassistant/components/goalzero/* @tkdrob +tests/components/goalzero/* @tkdrob homeassistant/components/gogogate2/* @vangorra @bdraco +tests/components/gogogate2/* @vangorra @bdraco homeassistant/components/google_assistant/* @home-assistant/cloud +tests/components/google_assistant/* @home-assistant/cloud homeassistant/components/google_cloud/* @lufton homeassistant/components/gpsd/* @fabaff homeassistant/components/gree/* @cmroche +tests/components/gree/* @cmroche homeassistant/components/greeneye_monitor/* @jkeljo +tests/components/greeneye_monitor/* @jkeljo homeassistant/components/group/* @home-assistant/core -homeassistant/components/growatt_server/* @indykoning @muppet3000 +tests/components/group/* @home-assistant/core +homeassistant/components/growatt_server/* @indykoning @muppet3000 @JasperPlant +tests/components/growatt_server/* @indykoning @muppet3000 @JasperPlant homeassistant/components/guardian/* @bachya +tests/components/guardian/* @bachya homeassistant/components/habitica/* @ASMfreaK @leikoilja -homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey +tests/components/habitica/* @ASMfreaK @leikoilja +homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan +tests/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan homeassistant/components/hassio/* @home-assistant/supervisor +tests/components/hassio/* @home-assistant/supervisor homeassistant/components/heatmiser/* @andylockran homeassistant/components/heos/* @andrewsayre +tests/components/heos/* @andrewsayre homeassistant/components/here_travel_time/* @eifinger +tests/components/here_travel_time/* @eifinger homeassistant/components/hikvision/* @mezz64 homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead +tests/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core +tests/components/history/* @home-assistant/core homeassistant/components/hive/* @Rendili @KJonline +tests/components/hive/* @Rendili @KJonline homeassistant/components/hlk_sw16/* @jameshilliard +tests/components/hlk_sw16/* @jameshilliard homeassistant/components/home_connect/* @DavidMStraub +tests/components/home_connect/* @DavidMStraub homeassistant/components/home_plus_control/* @chemaaa +tests/components/home_plus_control/* @chemaaa homeassistant/components/homeassistant/* @home-assistant/core +tests/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @bdraco -homeassistant/components/homekit_controller/* @Jc2k +tests/components/homekit/* @bdraco +homeassistant/components/homekit_controller/* @Jc2k @bdraco +tests/components/homekit_controller/* @Jc2k @bdraco homeassistant/components/homematic/* @pvizeli @danielperna84 +tests/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/honeywell/* @rdfurman +tests/components/honeywell/* @rdfurman homeassistant/components/http/* @home-assistant/core +tests/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle -homeassistant/components/huawei_router/* @abmantis -homeassistant/components/hue/* @balloob @frenck -homeassistant/components/huisbaasje/* @denniss17 +tests/components/huawei_lte/* @scop @fphammerle +homeassistant/components/hue/* @balloob @marcelveldt +tests/components/hue/* @balloob @marcelveldt +homeassistant/components/huisbaasje/* @dennisschroer +tests/components/huisbaasje/* @dennisschroer homeassistant/components/humidifier/* @home-assistant/core @Shulyaka +tests/components/humidifier/* @home-assistant/core @Shulyaka homeassistant/components/hunterdouglas_powerview/* @bdraco +tests/components/hunterdouglas_powerview/* @bdraco homeassistant/components/hvv_departures/* @vigonotion +tests/components/hvv_departures/* @vigonotion homeassistant/components/hydrawise/* @ptcryan homeassistant/components/hyperion/* @dermotduffy +tests/components/hyperion/* @dermotduffy homeassistant/components/ialarm/* @RyuzakiKK +tests/components/ialarm/* @RyuzakiKK homeassistant/components/iammeter/* @lewei50 homeassistant/components/iaqualink/* @flz +tests/components/iaqualink/* @flz homeassistant/components/icloud/* @Quentame @nzapponi +tests/components/icloud/* @Quentame @nzapponi homeassistant/components/ign_sismologia/* @exxamalte +tests/components/ign_sismologia/* @exxamalte homeassistant/components/image/* @home-assistant/core +tests/components/image/* @home-assistant/core homeassistant/components/incomfort/* @zxdavb homeassistant/components/influxdb/* @fabaff @mdegat01 +tests/components/influxdb/* @fabaff @mdegat01 homeassistant/components/input_boolean/* @home-assistant/core +tests/components/input_boolean/* @home-assistant/core +homeassistant/components/input_button/* @home-assistant/core +tests/components/input_button/* @home-assistant/core homeassistant/components/input_datetime/* @home-assistant/core +tests/components/input_datetime/* @home-assistant/core homeassistant/components/input_number/* @home-assistant/core +tests/components/input_number/* @home-assistant/core homeassistant/components/input_select/* @home-assistant/core +tests/components/input_select/* @home-assistant/core homeassistant/components/input_text/* @home-assistant/core +tests/components/input_text/* @home-assistant/core homeassistant/components/insteon/* @teharris1 +tests/components/insteon/* @teharris1 homeassistant/components/integration/* @dgomes +tests/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core +tests/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 +tests/components/ios/* @robbiet480 +homeassistant/components/iotawatt/* @gtdiehl @jyavenard +tests/components/iotawatt/* @gtdiehl @jyavenard homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis +tests/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington +tests/components/ipp/* @ctalkington homeassistant/components/iqvia/* @bachya +tests/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/islamic_prayer_times/* @engrbm87 +tests/components/islamic_prayer_times/* @engrbm87 homeassistant/components/isy994/* @bdraco @shbatm +tests/components/isy994/* @bdraco @shbatm homeassistant/components/izone/* @Swamp-Ig +tests/components/izone/* @Swamp-Ig +homeassistant/components/jellyfin/* @j-stienstra +tests/components/jellyfin/* @j-stienstra homeassistant/components/jewish_calendar/* @tsvi +tests/components/jewish_calendar/* @tsvi homeassistant/components/juicenet/* @jesserockz +tests/components/juicenet/* @jesserockz homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel +tests/components/keenetic_ndms2/* @foxel homeassistant/components/kef/* @basnijholt -homeassistant/components/keyboard_remote/* @bendavid +homeassistant/components/keyboard_remote/* @bendavid @lanrat homeassistant/components/kmtronic/* @dgomes +tests/components/kmtronic/* @dgomes homeassistant/components/knx/* @Julius2342 @farmio @marvin-w +tests/components/knx/* @Julius2342 @farmio @marvin-w homeassistant/components/kodi/* @OnFreund @cgtobi -homeassistant/components/konnected/* @heythisisnate @kit-klein +tests/components/kodi/* @OnFreund @cgtobi +homeassistant/components/konnected/* @heythisisnate +tests/components/konnected/* @heythisisnate homeassistant/components/kostal_plenticore/* @stegm +tests/components/kostal_plenticore/* @stegm +homeassistant/components/kraken/* @eifinger +tests/components/kraken/* @eifinger homeassistant/components/kulersky/* @emlove +tests/components/kulersky/* @emlove homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus +tests/components/lcn/* @alengwenus homeassistant/components/lg_netcast/* @Drafteed homeassistant/components/life360/* @pnbruckner homeassistant/components/linux_battery/* @fabaff homeassistant/components/litejet/* @joncar +tests/components/litejet/* @joncar homeassistant/components/litterrobot/* @natekspencer +tests/components/litterrobot/* @natekspencer homeassistant/components/local_ip/* @issacg +tests/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core +tests/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd -homeassistant/components/loopenergy/* @pavoni +tests/components/logi_circle/* @evanjd +homeassistant/components/lookin/* @ANMalko @bdraco +tests/components/lookin/* @ANMalko @bdraco homeassistant/components/lovelace/* @home-assistant/frontend +tests/components/lovelace/* @home-assistant/frontend homeassistant/components/luci/* @mzdrale homeassistant/components/luftdaten/* @fabaff +tests/components/luftdaten/* @fabaff homeassistant/components/lupusec/* @majuss homeassistant/components/lutron/* @JonGilmore homeassistant/components/lutron_caseta/* @swails @bdraco +tests/components/lutron_caseta/* @swails @bdraco homeassistant/components/lyric/* @timmo001 +tests/components/lyric/* @timmo001 homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf homeassistant/components/mazda/* @bdr99 +tests/components/mazda/* @bdr99 homeassistant/components/mcp23017/* @jardiamj homeassistant/components/media_source/* @hunterjm +tests/components/media_source/* @hunterjm homeassistant/components/mediaroom/* @dgomes homeassistant/components/melcloud/* @vilppuvuorinen +tests/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead +tests/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen @thimic +tests/components/met/* @danielhiversen @thimic homeassistant/components/met_eireann/* @DylanGore +tests/components/met_eireann/* @DylanGore homeassistant/components/meteo_france/* @hacf-fr @oncleben31 @Quentame +tests/components/meteo_france/* @hacf-fr @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch +homeassistant/components/meteoclimatic/* @adrianmo +tests/components/meteoclimatic/* @adrianmo homeassistant/components/metoffice/* @MrHarcombe +tests/components/metoffice/* @MrHarcombe homeassistant/components/miflora/* @danielhiversen @basnijholt homeassistant/components/mikrotik/* @engrbm87 +tests/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen +tests/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff +tests/components/min_max/* @fabaff homeassistant/components/minecraft_server/* @elmurato +tests/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan +tests/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 +tests/components/mobile_app/* @robbiet480 homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik +tests/components/modbus/* @adamchengtkc @janiversen @vzahradnik +homeassistant/components/modem_callerid/* @tkdrob +tests/components/modem_callerid/* @tkdrob +homeassistant/components/modern_forms/* @wonderslug +tests/components/modern_forms/* @wonderslug homeassistant/components/monoprice/* @etsinko @OnFreund +tests/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff +tests/components/moon/* @fabaff homeassistant/components/motion_blinds/* @starkillerOG +tests/components/motion_blinds/* @starkillerOG homeassistant/components/motioneye/* @dermotduffy +tests/components/motioneye/* @dermotduffy homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @emontnemery +tests/components/mqtt/* @emontnemery homeassistant/components/msteams/* @peroyvind homeassistant/components/mullvad/* @meichthys +tests/components/mullvad/* @meichthys homeassistant/components/mutesync/* @currentoor +tests/components/mutesync/* @currentoor homeassistant/components/my/* @home-assistant/core -homeassistant/components/myq/* @bdraco +tests/components/my/* @home-assistant/core +homeassistant/components/myq/* @bdraco @ehendrix23 +tests/components/myq/* @bdraco @ehendrix23 homeassistant/components/mysensors/* @MartinHjelmare @functionpointer +tests/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff homeassistant/components/nam/* @bieniu +tests/components/nam/* @bieniu +homeassistant/components/nanoleaf/* @milanmeu +tests/components/nanoleaf/* @milanmeu homeassistant/components/neato/* @dshokouhi @Santobert +tests/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM -homeassistant/components/nello/* @pschmitt homeassistant/components/ness_alarm/* @nickw444 +tests/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @allenporter +tests/components/nest/* @allenporter homeassistant/components/netatmo/* @cgtobi +tests/components/netatmo/* @cgtobi homeassistant/components/netdata/* @fabaff +homeassistant/components/netgear/* @hacf-fr @Quentame @starkillerOG +tests/components/netgear/* @hacf-fr @Quentame @starkillerOG homeassistant/components/nexia/* @bdraco +tests/components/nexia/* @bdraco homeassistant/components/nextbus/* @vividboarder +tests/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys +homeassistant/components/nfandroidtv/* @tkdrob +tests/components/nfandroidtv/* @tkdrob homeassistant/components/nightscout/* @marciogranzotto +tests/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten +homeassistant/components/nina/* @DeerMaximum +tests/components/nina/* @DeerMaximum homeassistant/components/nissan_leaf/* @filcole homeassistant/components/nmbs/* @thibmaek homeassistant/components/no_ip/* @fabaff +tests/components/no_ip/* @fabaff homeassistant/components/noaa_tides/* @jdelaney72 homeassistant/components/notify/* @home-assistant/core +tests/components/notify/* @home-assistant/core homeassistant/components/notify_events/* @matrozov @papajojo +tests/components/notify_events/* @matrozov @papajojo homeassistant/components/notion/* @bachya +tests/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 +tests/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte -homeassistant/components/nuheat/* @bdraco +tests/components/nsw_rural_fire_service_feed/* @exxamalte homeassistant/components/nuki/* @pschmitt @pvizeli @pree +tests/components/nuki/* @pschmitt @pvizeli @pree homeassistant/components/numato/* @clssn +tests/components/numato/* @clssn homeassistant/components/number/* @home-assistant/core @Shulyaka -homeassistant/components/nut/* @bdraco +tests/components/number/* @home-assistant/core @Shulyaka +homeassistant/components/nut/* @bdraco @ollo69 +tests/components/nut/* @bdraco @ollo69 homeassistant/components/nws/* @MatthewFlamm +tests/components/nws/* @MatthewFlamm homeassistant/components/nzbget/* @chriscla +tests/components/nzbget/* @chriscla homeassistant/components/obihai/* @dshokouhi +homeassistant/components/octoprint/* @rfleming71 +tests/components/octoprint/* @rfleming71 homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/ombi/* @larssont homeassistant/components/omnilogic/* @oliver84 @djtimca @gentoosu +tests/components/omnilogic/* @oliver84 @djtimca @gentoosu homeassistant/components/onboarding/* @home-assistant/core +tests/components/onboarding/* @home-assistant/core homeassistant/components/ondilo_ico/* @JeromeHXP +tests/components/ondilo_ico/* @JeromeHXP homeassistant/components/onewire/* @garbled1 @epenet +tests/components/onewire/* @garbled1 @epenet homeassistant/components/onvif/* @hunterjm +tests/components/onvif/* @hunterjm +homeassistant/components/open_meteo/* @frenck +tests/components/open_meteo/* @frenck homeassistant/components/openerz/* @misialq +tests/components/openerz/* @misialq homeassistant/components/opengarage/* @danielhiversen +tests/components/opengarage/* @danielhiversen homeassistant/components/openhome/* @bazwilliams homeassistant/components/opentherm_gw/* @mvn23 +tests/components/opentherm_gw/* @mvn23 homeassistant/components/openuv/* @bachya +tests/components/openuv/* @bachya homeassistant/components/openweathermap/* @fabaff @freekode @nzapponi +tests/components/openweathermap/* @fabaff @freekode @nzapponi homeassistant/components/opnsense/* @mtreinish +tests/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu +homeassistant/components/overkiz/* @imicknl @vlebourl @tetienne +tests/components/overkiz/* @imicknl @vlebourl @tetienne homeassistant/components/ovo_energy/* @timmo001 +tests/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare +tests/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare +homeassistant/components/p1_monitor/* @klaasnicolaas +tests/components/p1_monitor/* @klaasnicolaas homeassistant/components/panel_custom/* @home-assistant/frontend +tests/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend +tests/components/panel_iframe/* @home-assistant/frontend homeassistant/components/pcal9535a/* @Shulyaka homeassistant/components/persistent_notification/* @home-assistant/core +tests/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus +tests/components/philips_js/* @elupus homeassistant/components/pi4ioe5v9xxxx/* @antonverburg homeassistant/components/pi_hole/* @fabaff @johnluetke @shenxn +tests/components/pi_hole/* @fabaff @johnluetke @shenxn homeassistant/components/picnic/* @corneyl +tests/components/picnic/* @corneyl homeassistant/components/pilight/* @trekky12 +tests/components/pilight/* @trekky12 homeassistant/components/plaato/* @JohNan +tests/components/plaato/* @JohNan homeassistant/components/plex/* @jjlawren +tests/components/plex/* @jjlawren homeassistant/components/plugwise/* @CoMPaTech @bouwew @brefra +tests/components/plugwise/* @CoMPaTech @bouwew @brefra homeassistant/components/plum_lightpad/* @ColinHarrington @prystupa +tests/components/plum_lightpad/* @ColinHarrington @prystupa homeassistant/components/point/* @fredrike +tests/components/point/* @fredrike homeassistant/components/poolsense/* @haemishkyd +tests/components/poolsense/* @haemishkyd homeassistant/components/powerwall/* @bdraco @jrester +tests/components/powerwall/* @bdraco @jrester homeassistant/components/profiler/* @bdraco +tests/components/profiler/* @bdraco homeassistant/components/progettihwsw/* @ardaseremet +tests/components/progettihwsw/* @ardaseremet homeassistant/components/prometheus/* @knyar +tests/components/prometheus/* @knyar +homeassistant/components/prosegur/* @dgomes +tests/components/prosegur/* @dgomes homeassistant/components/proxmoxve/* @k4ds3 @jhollowe @Corbeno homeassistant/components/ps4/* @ktnrg45 +tests/components/ps4/* @ktnrg45 homeassistant/components/push/* @dgomes -homeassistant/components/pvoutput/* @fabaff +tests/components/push/* @dgomes +homeassistant/components/pvoutput/* @fabaff @frenck homeassistant/components/pvpc_hourly_pricing/* @azogue +tests/components/pvpc_hourly_pricing/* @azogue homeassistant/components/qbittorrent/* @geoffreylagaisse homeassistant/components/qld_bushfire/* @exxamalte +tests/components/qld_bushfire/* @exxamalte homeassistant/components/qnap/* @colinodell homeassistant/components/quantum_gateway/* @cisasteelersfan homeassistant/components/qvr_pro/* @oblogic7 homeassistant/components/qwikswitch/* @kellerza +tests/components/qwikswitch/* @kellerza homeassistant/components/rachio/* @bdraco +tests/components/rachio/* @bdraco homeassistant/components/radiotherm/* @vinnyfuria homeassistant/components/rainbird/* @konikvranik homeassistant/components/raincloud/* @vanstinator homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert +tests/components/rainforest_eagle/* @gtdiehl @jcalbert homeassistant/components/rainmachine/* @bachya +tests/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff +tests/components/random/* @fabaff +homeassistant/components/rdw/* @frenck +tests/components/rdw/* @frenck homeassistant/components/recollect_waste/* @bachya +tests/components/recollect_waste/* @bachya +homeassistant/components/recorder/* @home-assistant/core +tests/components/recorder/* @home-assistant/core homeassistant/components/rejseplanen/* @DarkFox -homeassistant/components/repetier/* @MTrab +homeassistant/components/renault/* @epenet +tests/components/renault/* @epenet +homeassistant/components/repetier/* @MTrab @ShadowBr0ther homeassistant/components/rflink/* @javicalle +tests/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 +tests/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 +homeassistant/components/ridwell/* @bachya +tests/components/ridwell/* @bachya homeassistant/components/ring/* @balloob +tests/components/ring/* @balloob homeassistant/components/risco/* @OnFreund +tests/components/risco/* @OnFreund homeassistant/components/rituals_perfume_genie/* @milanmeu +tests/components/rituals_perfume_genie/* @milanmeu homeassistant/components/rmvtransport/* @cgtobi +tests/components/rmvtransport/* @cgtobi homeassistant/components/roku/* @ctalkington +tests/components/roku/* @ctalkington homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn +tests/components/roomba/* @pschmitt @cyr-ius @shenxn homeassistant/components/roon/* @pavoni +tests/components/roon/* @pavoni homeassistant/components/rpi_gpio_pwm/* @soldag homeassistant/components/rpi_power/* @shenxn @swetoast +tests/components/rpi_power/* @shenxn @swetoast homeassistant/components/ruckus_unleashed/* @gabe565 +tests/components/ruckus_unleashed/* @gabe565 homeassistant/components/safe_mode/* @home-assistant/core +tests/components/safe_mode/* @home-assistant/core homeassistant/components/saj/* @fredericvl -homeassistant/components/samsungtv/* @escoand +homeassistant/components/samsungtv/* @escoand @chemelli74 +tests/components/samsungtv/* @escoand @chemelli74 homeassistant/components/scene/* @home-assistant/core +tests/components/scene/* @home-assistant/core homeassistant/components/schluter/* @prairieapps homeassistant/components/scrape/* @fabaff -homeassistant/components/screenlogic/* @dieselrabbit +homeassistant/components/screenlogic/* @dieselrabbit @bdraco +tests/components/screenlogic/* @dieselrabbit @bdraco homeassistant/components/script/* @home-assistant/core +tests/components/script/* @home-assistant/core homeassistant/components/search/* @home-assistant/core +tests/components/search/* @home-assistant/core +homeassistant/components/select/* @home-assistant/core +tests/components/select/* @home-assistant/core homeassistant/components/sense/* @kbickar -homeassistant/components/sensibo/* @andrey-git +tests/components/sense/* @kbickar +homeassistant/components/sensibo/* @andrey-git @gjohansson-ST +tests/components/sensibo/* @andrey-git @gjohansson-ST homeassistant/components/sentry/* @dcramer @frenck +tests/components/sentry/* @dcramer @frenck homeassistant/components/serial/* @fabaff homeassistant/components/seven_segments/* @fabaff -homeassistant/components/seventeentrack/* @bachya homeassistant/components/sharkiq/* @ajmarks +tests/components/sharkiq/* @ajmarks homeassistant/components/shell_command/* @home-assistant/core +tests/components/shell_command/* @home-assistant/core homeassistant/components/shelly/* @balloob @bieniu @thecode @chemelli74 +tests/components/shelly/* @balloob @bieniu @thecode @chemelli74 homeassistant/components/shiftr/* @fabaff homeassistant/components/shodan/* @fabaff +homeassistant/components/sia/* @eavanvalkenburg +tests/components/sia/* @eavanvalkenburg homeassistant/components/sighthound/* @robmarkcole +tests/components/sighthound/* @robmarkcole homeassistant/components/signal_messenger/* @bbernhard +tests/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya +tests/components/simplisafe/* @bachya homeassistant/components/sinch/* @bendikrb +homeassistant/components/siren/* @home-assistant/core @raman325 +tests/components/siren/* @home-assistant/core @raman325 homeassistant/components/sisyphus/* @jkeljo homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slack/* @bachya +tests/components/slack/* @bachya homeassistant/components/slide/* @ualex73 homeassistant/components/sma/* @kellerza @rklomp +tests/components/sma/* @kellerza @rklomp homeassistant/components/smappee/* @bsmappee +tests/components/smappee/* @bsmappee homeassistant/components/smart_meter_texas/* @grahamwetzler +tests/components/smart_meter_texas/* @grahamwetzler homeassistant/components/smarthab/* @outadoc +tests/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre +tests/components/smartthings/* @andrewsayre homeassistant/components/smarttub/* @mdz +tests/components/smarttub/* @mdz homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/sms/* @ocalvo homeassistant/components/smtp/* @fabaff +tests/components/smtp/* @fabaff homeassistant/components/solaredge/* @frenck +tests/components/solaredge/* @frenck homeassistant/components/solaredge_local/* @drobtravels @scheric homeassistant/components/solarlog/* @Ernst79 +tests/components/solarlog/* @Ernst79 homeassistant/components/solax/* @squishykid homeassistant/components/soma/* @ratsept +tests/components/soma/* @ratsept homeassistant/components/somfy/* @tetienne +tests/components/somfy/* @tetienne homeassistant/components/sonarr/* @ctalkington +tests/components/sonarr/* @ctalkington homeassistant/components/songpal/* @rytilahti @shenxn +tests/components/songpal/* @rytilahti @shenxn homeassistant/components/sonos/* @cgtobi @jjlawren +tests/components/sonos/* @cgtobi @jjlawren homeassistant/components/spaceapi/* @fabaff +tests/components/spaceapi/* @fabaff homeassistant/components/speedtestdotnet/* @rohankapoorcom @engrbm87 +tests/components/speedtestdotnet/* @rohankapoorcom @engrbm87 homeassistant/components/spider/* @peternijssen +tests/components/spider/* @peternijssen homeassistant/components/splunk/* @Bre77 homeassistant/components/spotify/* @frenck +tests/components/spotify/* @frenck homeassistant/components/sql/* @dgomes +tests/components/sql/* @dgomes homeassistant/components/squeezebox/* @rajlaud +tests/components/squeezebox/* @rajlaud homeassistant/components/srp_energy/* @briglx +tests/components/srp_energy/* @briglx homeassistant/components/starline/* @anonym-tsk -homeassistant/components/statistics/* @fabaff +tests/components/starline/* @anonym-tsk +homeassistant/components/statistics/* @fabaff @ThomDietrich +tests/components/statistics/* @fabaff @ThomDietrich homeassistant/components/stiebel_eltron/* @fucm -homeassistant/components/stookalert/* @fwestenberg +homeassistant/components/stookalert/* @fwestenberg @frenck +tests/components/stookalert/* @fwestenberg @frenck homeassistant/components/stream/* @hunterjm @uvjustin @allenporter +tests/components/stream/* @hunterjm @uvjustin @allenporter homeassistant/components/stt/* @pvizeli +tests/components/stt/* @pvizeli homeassistant/components/subaru/* @G-Two +tests/components/subaru/* @G-Two homeassistant/components/suez_water/* @ooii homeassistant/components/sun/* @Swamp-Ig +tests/components/sun/* @Swamp-Ig homeassistant/components/supla/* @mwegrzynek -homeassistant/components/surepetcare/* @benleb +homeassistant/components/surepetcare/* @benleb @danielhiversen +tests/components/surepetcare/* @benleb @danielhiversen homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff -homeassistant/components/switchbot/* @danielhiversen -homeassistant/components/switcher_kis/* @tomerfi +homeassistant/components/switchbot/* @danielhiversen @RenierM26 +tests/components/switchbot/* @danielhiversen @RenierM26 +homeassistant/components/switcher_kis/* @tomerfi @thecode +tests/components/switcher_kis/* @tomerfi @thecode homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthing/* @zhulik +tests/components/syncthing/* @zhulik homeassistant/components/syncthru/* @nielstron +tests/components/syncthru/* @nielstron homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185 +tests/components/synology_dsm/* @hacf-fr @Quentame @mib1185 homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/system_bridge/* @timmo001 -homeassistant/components/tado/* @michaelarnauts @bdraco @noltari +tests/components/system_bridge/* @timmo001 +homeassistant/components/tado/* @michaelarnauts @noltari +tests/components/tado/* @michaelarnauts @noltari homeassistant/components/tag/* @balloob @dmulcahey -homeassistant/components/tahoma/* @philklei +tests/components/tag/* @balloob @dmulcahey +homeassistant/components/tailscale/* @frenck +tests/components/tailscale/* @frenck homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tapsaff/* @bazwilliams homeassistant/components/tasmota/* @emontnemery +tests/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike -homeassistant/components/template/* @PhracturedBlue @tetienne -homeassistant/components/tesla/* @zabuldon @alandtse +tests/components/tellduslive/* @fredrike +homeassistant/components/template/* @PhracturedBlue @tetienne @home-assistant/core +tests/components/template/* @PhracturedBlue @tetienne @home-assistant/core +homeassistant/components/tesla_wall_connector/* @einarhauks +tests/components/tesla_wall_connector/* @einarhauks homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/* @fabaff +tests/components/threshold/* @fabaff homeassistant/components/tibber/* @danielhiversen +tests/components/tibber/* @danielhiversen homeassistant/components/tile/* @bachya +tests/components/tile/* @bachya homeassistant/components/time_date/* @fabaff +tests/components/time_date/* @fabaff homeassistant/components/tmb/* @alemuro homeassistant/components/todoist/* @boralyl -homeassistant/components/toon/* @frenck +homeassistant/components/tolo/* @MatthiasLohr +tests/components/tolo/* @MatthiasLohr homeassistant/components/totalconnect/* @austinmroczek +tests/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey +tests/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus +tests/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core +tests/components/trace/* @home-assistant/core +homeassistant/components/tractive/* @Danielhiversen @zhulik @bieniu +tests/components/tractive/* @Danielhiversen @zhulik @bieniu homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force +tests/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins +tests/components/transmission/* @engrbm87 @JPHutchins homeassistant/components/tts/* @pvizeli -homeassistant/components/tuya/* @ollo69 +tests/components/tts/* @pvizeli +homeassistant/components/tuya/* @Tuya @zlinoliver @METISU @frenck +tests/components/tuya/* @Tuya @zlinoliver @METISU @frenck homeassistant/components/twentemilieu/* @frenck +tests/components/twentemilieu/* @frenck homeassistant/components/twinkly/* @dr1rrb +tests/components/twinkly/* @dr1rrb homeassistant/components/ubus/* @noltari homeassistant/components/unifi/* @Kane610 +tests/components/unifi/* @Kane610 homeassistant/components/unifiled/* @florisvdk homeassistant/components/upb/* @gwww +tests/components/upb/* @gwww homeassistant/components/upc_connect/* @pvizeli @fabaff homeassistant/components/upcloud/* @scop +tests/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core -homeassistant/components/upnp/* @StevenLooman +tests/components/updater/* @home-assistant/core +homeassistant/components/upnp/* @StevenLooman @ehendrix23 +tests/components/upnp/* @StevenLooman @ehendrix23 homeassistant/components/uptimerobot/* @ludeeus +tests/components/uptimerobot/* @ludeeus +homeassistant/components/usb/* @bdraco +tests/components/usb/* @bdraco homeassistant/components/usgs_earthquakes_feed/* @exxamalte +tests/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes +tests/components/utility_meter/* @dgomes +homeassistant/components/vallox/* @andre-richter homeassistant/components/velbus/* @Cereal2nd @brefra +tests/components/velbus/* @Cereal2nd @brefra homeassistant/components/velux/* @Julius2342 +homeassistant/components/venstar/* @garbled1 +tests/components/venstar/* @garbled1 homeassistant/components/vera/* @pavoni +tests/components/vera/* @pavoni homeassistant/components/verisure/* @frenck +tests/components/verisure/* @frenck homeassistant/components/versasense/* @flamm3blemuff1n homeassistant/components/version/* @fabaff @ludeeus +tests/components/version/* @fabaff @ludeeus homeassistant/components/vesync/* @markperdue @webdjoe @thegardenmonkey +tests/components/vesync/* @markperdue @webdjoe @thegardenmonkey homeassistant/components/vicare/* @oischinger +tests/components/vicare/* @oischinger homeassistant/components/vilfo/* @ManneW +tests/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 -homeassistant/components/vlc_telnet/* @rodripf @dmcc +tests/components/vizio/* @raman325 +homeassistant/components/vlc_telnet/* @rodripf @dmcc @MartinHjelmare +tests/components/vlc_telnet/* @rodripf @dmcc @MartinHjelmare homeassistant/components/volkszaehler/* @fabaff homeassistant/components/volumio/* @OnFreund +tests/components/volumio/* @OnFreund +homeassistant/components/volvooncall/* @molobrakos @decompil3d homeassistant/components/wake_on_lan/* @ntilley905 +tests/components/wake_on_lan/* @ntilley905 +homeassistant/components/wallbox/* @hesselonline +tests/components/wallbox/* @hesselonline homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai +homeassistant/components/watttime/* @bachya +tests/components/watttime/* @bachya homeassistant/components/weather/* @fabaff -homeassistant/components/webostv/* @bendavid +tests/components/weather/* @fabaff +homeassistant/components/webostv/* @bendavid @thecode +tests/components/webostv/* @bendavid @thecode homeassistant/components/websocket_api/* @home-assistant/core +tests/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @esev +tests/components/wemo/* @esev +homeassistant/components/whirlpool/* @abmantis +tests/components/whirlpool/* @abmantis homeassistant/components/wiffi/* @mampfes +tests/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj +tests/components/wilight/* @leofig-rj +homeassistant/components/wirelesstag/* @sergeymaysak homeassistant/components/withings/* @vangorra +tests/components/withings/* @vangorra homeassistant/components/wled/* @frenck +tests/components/wled/* @frenck homeassistant/components/wolflink/* @adamkrol93 +tests/components/wolflink/* @adamkrol93 homeassistant/components/workday/* @fabaff +tests/components/workday/* @fabaff homeassistant/components/worldclock/* @fabaff +tests/components/worldclock/* @fabaff homeassistant/components/xbox/* @hunterjm +tests/components/xbox/* @hunterjm homeassistant/components/xbox_live/* @MartinHjelmare homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi -homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG +tests/components/xiaomi_aqara/* @danielhiversen @syssi +homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG @bieniu +tests/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG @bieniu homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf -homeassistant/components/yamaha_musiccast/* @jalmeroth +homeassistant/components/yale_smart_alarm/* @gjohansson-ST +tests/components/yale_smart_alarm/* @gjohansson-ST +homeassistant/components/yamaha_musiccast/* @vigonotion @micha91 +tests/components/yamaha_musiccast/* @vigonotion @micha91 homeassistant/components/yandex_transport/* @rishatik92 @devbis -homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn +tests/components/yandex_transport/* @rishatik92 @devbis +homeassistant/components/yeelight/* @zewelor @shenxn @starkillerOG +tests/components/yeelight/* @zewelor @shenxn @starkillerOG homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yi/* @bachya +homeassistant/components/youless/* @gjong +tests/components/youless/* @gjong homeassistant/components/zeroconf/* @bdraco +tests/components/zeroconf/* @bdraco homeassistant/components/zerproc/* @emlove +tests/components/zerproc/* @emlove homeassistant/components/zha/* @dmulcahey @adminiuga +tests/components/zha/* @dmulcahey @adminiuga homeassistant/components/zodiac/* @JulienTant +tests/components/zodiac/* @JulienTant homeassistant/components/zone/* @home-assistant/core +tests/components/zone/* @home-assistant/core homeassistant/components/zoneminder/* @rohankapoorcom homeassistant/components/zwave/* @home-assistant/z-wave +tests/components/zwave/* @home-assistant/z-wave homeassistant/components/zwave_js/* @home-assistant/z-wave +tests/components/zwave_js/* @home-assistant/z-wave # Individual files homeassistant/components/demo/weather @fabaff diff --git a/Dockerfile b/Dockerfile index 6bcb080a06e67..a4d5ce3045dc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,15 +7,39 @@ ENV \ WORKDIR /usr/src -## Setup Home Assistant +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \ + pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + -r homeassistant/requirements.txt +COPY requirements_all.txt homeassistant/ +RUN \ + pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core COPY . homeassistant/ RUN \ pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - -r homeassistant/requirements_all.txt \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ -e ./homeassistant \ && python3 -m compileall homeassistant/homeassistant +# Fix Bug with Alpine 3.14 and sqlite 3.35 +# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 +ARG BUILD_ARCH +RUN \ + if [ "${BUILD_ARCH}" = "amd64" ]; then \ + export APK_ARCH=x86_64; \ + elif [ "${BUILD_ARCH}" = "i386" ]; then \ + export APK_ARCH=x86; \ + else \ + export APK_ARCH=${BUILD_ARCH}; \ + fi \ + && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ + && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ + && rm -f sqlite-libs-3.34.1-r0.apk + # Home Assistant S6-Overlay COPY rootfs / diff --git a/Dockerfile.dev b/Dockerfile.dev index 68188f16f012a..727358dae9e33 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.8 +FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.9 SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -6,6 +6,8 @@ RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + # Additional library needed by some tests and accordingly by VScode Tests Discovery + bluez \ libudev-dev \ libavformat-dev \ libavcodec-dev \ @@ -28,11 +30,12 @@ RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ WORKDIR /workspaces # Install Python dependencies from requirements -COPY requirements.txt requirements_test.txt requirements_test_pre_commit.txt ./ +COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN pip3 install -r requirements.txt \ - && pip3 install -r requirements_test.txt \ - && rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ +RUN pip3 install -r requirements.txt +COPY requirements_test.txt requirements_test_pre_commit.txt ./ +RUN pip3 install -r requirements_test.txt +RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ # Set the default shell to bash instead of sh ENV SHELL /bin/bash diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml deleted file mode 100644 index cda5943ecd03a..0000000000000 --- a/azure-pipelines-ci.yml +++ /dev/null @@ -1,232 +0,0 @@ -# https://dev.azure.com/home-assistant - -trigger: - batch: true - branches: - include: - - rc - - dev - - master -pr: - - rc - - dev - - master - -resources: - containers: - - container: 38 - image: homeassistant/ci-azure:3.8 - repositories: - - repository: azure - type: github - name: "home-assistant/ci-azure" - endpoint: "home-assistant" -variables: - - name: PythonMain - value: "38" - - name: versionHadolint - value: "v1.17.6" - -stages: - - stage: "Overview" - jobs: - - job: "Lint" - pool: - vmImage: "ubuntu-latest" - container: $[ variables['PythonMain'] ] - steps: - - template: templates/azp-step-cache.yaml@azure - parameters: - keyfile: "requirements_test.txt | homeassistant/package_constraints.txt" - build: | - python -m venv venv - - . venv/bin/activate - pip install -r requirements_test.txt - pre-commit install-hooks - - script: | - . venv/bin/activate - pre-commit run --hook-stage manual check-executables-have-shebangs --all-files - displayName: "Run executables check" - - script: | - . venv/bin/activate - pre-commit run codespell --all-files - displayName: "Run codespell" - - script: | - . venv/bin/activate - pre-commit run flake8 --all-files - displayName: "Run flake8" - - script: | - . venv/bin/activate - pre-commit run bandit --all-files - displayName: "Run bandit" - - script: | - . venv/bin/activate - pre-commit run isort --all-files --show-diff-on-failure - displayName: "Run isort" - - script: | - . venv/bin/activate - pre-commit run check-json --all-files - displayName: "Run check-json" - - script: | - . venv/bin/activate - pre-commit run yamllint --all-files - displayName: "Run yamllint" - - script: | - . venv/bin/activate - pre-commit run pyupgrade --all-files --show-diff-on-failure - displayName: "Run pyupgrade" - # Prettier seems to hang on Azure, unknown why yet. - # Temporarily disable the check to no block PRs - # - script: | - # . venv/bin/activate - # pre-commit run prettier --all-files --show-diff-on-failure - # displayName: 'Run prettier' - - job: "Validate" - pool: - vmImage: "ubuntu-latest" - container: $[ variables['PythonMain'] ] - steps: - - template: templates/azp-step-cache.yaml@azure - parameters: - keyfile: "homeassistant/package_constraints.txt" - build: | - python -m venv venv - - . venv/bin/activate - pip install -e . - - script: | - . venv/bin/activate - python -m script.hassfest --action validate - displayName: "Validate manifests" - - script: | - . venv/bin/activate - ./script/gen_requirements_all.py validate - displayName: "requirements_all validate" - - job: "CheckFormat" - pool: - vmImage: "ubuntu-latest" - container: $[ variables['PythonMain'] ] - steps: - - template: templates/azp-step-cache.yaml@azure - parameters: - keyfile: "requirements_test.txt | homeassistant/package_constraints.txt" - build: | - python -m venv venv - - . venv/bin/activate - pip install -r requirements_test.txt - pre-commit install-hooks - - script: | - . venv/bin/activate - pre-commit run black --all-files --show-diff-on-failure - displayName: "Check Black formatting" - - job: "Docker" - pool: - vmImage: "ubuntu-latest" - steps: - - script: sudo docker pull hadolint/hadolint:$(versionHadolint) - displayName: "Install Hadolint" - - script: | - set -e - for dockerfile in Dockerfile Dockerfile.dev - do - echo "Linting: $dockerfile" - docker run --rm -i \ - -v "$(pwd)/.hadolint.yaml:/.hadolint.yaml:ro" \ - hadolint/hadolint:$(versionHadolint) < "$dockerfile" - done - displayName: "Run Hadolint" - - - stage: "Tests" - dependsOn: - - "Overview" - jobs: - - job: "PyTest" - pool: - vmImage: "ubuntu-latest" - strategy: - maxParallel: 3 - matrix: - Python38: - python.container: "38" - container: $[ variables['python.container'] ] - steps: - - template: templates/azp-step-cache.yaml@azure - parameters: - keyfile: "requirements_test_all.txt | requirements_test.txt | homeassistant/package_constraints.txt" - build: | - set -e - python -m venv venv - - . venv/bin/activate - pip install -U pip setuptools pytest-azurepipelines pytest-xdist -c homeassistant/package_constraints.txt - pip install -r requirements_test_all.txt - - script: | - . venv/bin/activate - pip install -e . - displayName: "Install Home Assistant" - - script: | - set -e - - . venv/bin/activate - pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar tests - script/check_dirty - displayName: "Run pytest for python $(python.container)" - condition: and(succeeded(), ne(variables['python.container'], variables['PythonMain'])) - - script: | - set -e - - . venv/bin/activate - pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests - codecov --token $(codecovToken) - script/check_dirty - displayName: "Run pytest for python $(python.container) / coverage" - condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) - - - stage: "FullCheck" - dependsOn: - - "Overview" - jobs: - - job: "Pylint" - pool: - vmImage: "ubuntu-latest" - container: $[ variables['PythonMain'] ] - steps: - - template: templates/azp-step-cache.yaml@azure - parameters: - keyfile: "requirements_all.txt | requirements_test.txt | homeassistant/package_constraints.txt" - build: | - set -e - python -m venv venv - - . venv/bin/activate - pip install -U pip setuptools wheel - pip install -r requirements_all.txt - pip install -r requirements_test.txt - - script: | - . venv/bin/activate - pip install -e . - displayName: "Install Home Assistant" - - script: | - . venv/bin/activate - pylint homeassistant - displayName: "Run pylint" - - job: "Mypy" - pool: - vmImage: "ubuntu-latest" - container: $[ variables['PythonMain'] ] - steps: - - template: templates/azp-step-cache.yaml@azure - parameters: - keyfile: "requirements_test.txt | setup.py | homeassistant/package_constraints.txt" - build: | - python -m venv venv - - . venv/bin/activate - pip install -e . -r requirements_test.txt - pre-commit install-hooks - - script: | - . venv/bin/activate - pre-commit run mypy --all-files - displayName: "Run mypy" diff --git a/azure-pipelines-translation.yml b/azure-pipelines-translation.yml deleted file mode 100644 index 481b98bc4840a..0000000000000 --- a/azure-pipelines-translation.yml +++ /dev/null @@ -1,65 +0,0 @@ -# https://dev.azure.com/home-assistant - -trigger: - batch: true - branches: - include: - - dev -pr: none -schedules: - - cron: "0 0 * * *" - displayName: "translation update" - branches: - include: - - dev - always: true -variables: -- group: translation -resources: - repositories: - - repository: azure - type: github - name: 'home-assistant/ci-azure' - endpoint: 'home-assistant' - - -jobs: - -- job: 'Upload' - pool: - vmImage: 'ubuntu-latest' - steps: - - task: UsePythonVersion@0 - displayName: 'Use Python 3.8' - inputs: - versionSpec: '3.8' - - script: | - export LOKALISE_TOKEN="$(lokaliseToken)" - export AZURE_BRANCH="$(Build.SourceBranchName)" - - python3 -m script.translations upload - displayName: 'Upload Translation' - -- job: 'Download' - dependsOn: - - 'Upload' - condition: or(eq(variables['Build.Reason'], 'Schedule'), eq(variables['Build.Reason'], 'Manual')) - pool: - vmImage: 'ubuntu-latest' - steps: - - task: UsePythonVersion@0 - displayName: 'Use Python 3.7' - inputs: - versionSpec: '3.7' - - template: templates/azp-step-git-init.yaml@azure - - script: | - export LOKALISE_TOKEN="$(lokaliseToken)" - - python3 -m script.translations download - displayName: 'Download Translation' - - script: | - git checkout dev - git add homeassistant - git commit -am "[ci skip] Translation update" - git push - displayName: 'Update translation' diff --git a/build.json b/build.json deleted file mode 100644 index de5f895af2ad4..0000000000000 --- a/build.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "image": "homeassistant/{arch}-homeassistant", - "shadow_repository": "ghcr.io/home-assistant", - "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.04.3", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.04.3", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.04.3", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.04.3", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.04.3" - }, - "labels": { - "io.hass.type": "core", - "org.opencontainers.image.title": "Home Assistant", - "org.opencontainers.image.description": "Open-source home automation platform running on Python 3", - "org.opencontainers.image.source": "https://github.com/home-assistant/core", - "org.opencontainers.image.authors": "The Home Assistant Authors", - "org.opencontainers.image.url": "https://www.home-assistant.io/", - "org.opencontainers.image.documentation": "https://www.home-assistant.io/docs/", - "org.opencontainers.image.licenses": "Apache License 2.0" - }, - "version_tag": true -} diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000000000..1d0e18c79ea88 --- /dev/null +++ b/build.yaml @@ -0,0 +1,20 @@ +image: homeassistant/{arch}-homeassistant +shadow_repository: ghcr.io/home-assistant +build_from: + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2021.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2021.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2021.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2021.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2021.09.0 +codenotary: + signer: notary@home-assistant.io + base_image: notary@home-assistant.io +labels: + io.hass.type: core + org.opencontainers.image.title: Home Assistant + org.opencontainers.image.description: Open-source home automation platform running on Python 3 + org.opencontainers.image.source: https://github.com/home-assistant/core + org.opencontainers.image.authors: The Home Assistant Authors + org.opencontainers.image.url: https://www.home-assistant.io/ + org.opencontainers.image.documentation: https://www.home-assistant.io/docs/ + org.opencontainers.image.licenses: Apache License 2.0 diff --git a/codecov.yml b/codecov.yml index 7a9eea730d8fe..2522ccd90e927 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,4 +6,28 @@ coverage: default: target: 90 threshold: 0.09 + config-flows: + target: auto + threshold: 0 + paths: + - homeassistant/components/*/config_flow.py + patch: + default: + target: auto + config-flows: + target: 100 + threshold: 0 + paths: + - homeassistant/components/*/config_flow.py comment: false + +# To make partial tests possible, +# we need to carry forward. +flag_management: + default_rules: + carryforward: false + individual_flags: + - name: full-suite + paths: + - ".*" + carryforward: true diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 52ae8eacdd3e0..071f4d81cdf47 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -118,14 +118,6 @@ homeassistant.util.pressure :undoc-members: :show-inheritance: -homeassistant.util.ruamel\_yaml -------------------------------- - -.. automodule:: homeassistant.util.ruamel_yaml - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.ssl ---------------------- diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index b01284d997446..1a129686a8f57 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -2,13 +2,16 @@ from __future__ import annotations import argparse +import faulthandler import os import platform import subprocess import sys import threading -from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ +from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ + +FAULT_LOG_FILENAME = "home-assistant.log.fault" def validate_python() -> None: @@ -24,7 +27,7 @@ def validate_python() -> None: def ensure_config_path(config_dir: str) -> None: """Validate the configuration directory.""" # pylint: disable=import-outside-toplevel - import homeassistant.config as config_util + from . import config as config_util lib_dir = os.path.join(config_dir, "deps") @@ -58,7 +61,7 @@ def ensure_config_path(config_dir: str) -> None: def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" # pylint: disable=import-outside-toplevel - import homeassistant.config as config_util + from . import config as config_util parser = argparse.ArgumentParser( description="Home Assistant: Observe, Control, Automate." @@ -132,22 +135,20 @@ def get_arguments() -> argparse.Namespace: def daemonize() -> None: """Move current process to daemon process.""" # Create first fork - pid = os.fork() - if pid > 0: + if os.fork() > 0: sys.exit(0) # Decouple fork os.setsid() # Create second fork - pid = os.fork() - if pid > 0: + if os.fork() > 0: sys.exit(0) # redirect standard file descriptors to devnull # pylint: disable=consider-using-with - infd = open(os.devnull) - outfd = open(os.devnull, "a+") + infd = open(os.devnull, encoding="utf8") + outfd = open(os.devnull, "a+", encoding="utf8") sys.stdout.flush() sys.stderr.flush() os.dup2(infd.fileno(), sys.stdin.fileno()) @@ -159,7 +160,7 @@ def check_pid(pid_file: str) -> None: """Check that Home Assistant is not already running.""" # Check pid file try: - with open(pid_file) as file: + with open(pid_file, encoding="utf8") as file: pid = int(file.readline()) except OSError: # PID File does not exist @@ -182,7 +183,7 @@ def write_pid(pid_file: str) -> None: """Create a PID File.""" pid = os.getpid() try: - with open(pid_file, "w") as file: + with open(pid_file, "w", encoding="utf8") as file: file.write(str(pid)) except OSError: print(f"Fatal Error: Unable to write pid file {pid_file}") @@ -281,7 +282,7 @@ def main() -> int: if args.script is not None: # pylint: disable=import-outside-toplevel - from homeassistant import scripts + from . import scripts return scripts.run(args.script) @@ -297,7 +298,7 @@ def main() -> int: write_pid(args.pid_file) # pylint: disable=import-outside-toplevel - from homeassistant import runner + from . import runner runtime_conf = runner.RuntimeConfig( config_dir=config_dir, @@ -311,7 +312,15 @@ def main() -> int: open_ui=args.open_ui, ) - exit_code = runner.run(runtime_conf) + fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) + with open(fault_file_name, mode="a", encoding="utf8") as fault_file: + faulthandler.enable(fault_file) + exit_code = runner.run(runtime_conf) + faulthandler.disable() + + if os.path.getsize(fault_file_name) == 0: + os.remove(fault_file_name) + if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() diff --git a/homeassistant/async_timeout_backcompat.py b/homeassistant/async_timeout_backcompat.py new file mode 100644 index 0000000000000..70b38c1870803 --- /dev/null +++ b/homeassistant/async_timeout_backcompat.py @@ -0,0 +1,42 @@ +"""Provide backwards compat for async_timeout.""" +from __future__ import annotations + +import asyncio +from typing import Any + +import async_timeout + +from .helpers.frame import report + + +def timeout( + delay: float | None, loop: asyncio.AbstractEventLoop | None = None +) -> async_timeout.Timeout: + """Backwards compatible timeout context manager that warns with loop usage.""" + if loop is None: + loop = asyncio.get_running_loop() + else: + report( + "called async_timeout.timeout with loop keyword argument. The loop keyword argument is deprecated and calls will fail after Home Assistant 2022.2", + error_if_core=False, + ) + if delay is not None: + deadline: float | None = loop.time() + delay + else: + deadline = None + return async_timeout.Timeout(deadline, loop) + + +def current_task(loop: asyncio.AbstractEventLoop) -> asyncio.Task[Any] | None: + """Backwards compatible current_task.""" + report( + "called async_timeout.current_task. The current_task call is deprecated and calls will fail after Home Assistant 2022.2; use asyncio.current_task instead", + error_if_core=False, + ) + return asyncio.current_task() + + +def enable() -> None: + """Enable backwards compat transitions.""" + async_timeout.timeout = timeout + async_timeout.current_task = current_task # type: ignore[attr-defined] diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 14981d0df09d3..21afef8b1be90 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -9,13 +9,12 @@ import jwt from homeassistant import data_entry_flow -from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util import dt as dt_util from . import auth_store, models -from .const import GROUP_ID_ADMIN +from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config @@ -79,7 +78,7 @@ async def auth_manager_from_config( class AuthManagerFlowManager(data_entry_flow.FlowManager): """Manage authentication flows.""" - def __init__(self, hass: HomeAssistant, auth_manager: AuthManager): + def __init__(self, hass: HomeAssistant, auth_manager: AuthManager) -> None: """Init auth manager flows.""" super().__init__(hass) self.auth_manager = auth_manager @@ -156,6 +155,7 @@ def __init__( self._providers = providers self._mfa_modules = mfa_modules self.login_flow = AuthManagerFlowManager(hass, self) + self._revoke_callbacks: dict[str, list[CALLBACK_TYPE]] = {} @property def auth_providers(self) -> list[AuthProvider]: @@ -214,11 +214,19 @@ async def async_get_user_by_credentials( return None async def async_create_system_user( - self, name: str, group_ids: list[str] | None = None + self, + name: str, + *, + group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> models.User: """Create a system user.""" user = await self._store.async_create_user( - name=name, system_generated=True, is_active=True, group_ids=group_ids or [] + name=name, + system_generated=True, + is_active=True, + group_ids=group_ids or [], + local_only=local_only, ) self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) @@ -226,13 +234,18 @@ async def async_create_system_user( return user async def async_create_user( - self, name: str, group_ids: list[str] | None = None + self, + name: str, + *, + group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> models.User: """Create a user.""" kwargs: dict[str, Any] = { "name": name, "is_active": True, "group_ids": group_ids or [], + "local_only": local_only, } if await self._user_should_be_owner(): @@ -276,6 +289,12 @@ async def async_link_user( self, user: models.User, credentials: models.Credentials ) -> None: """Link credentials to an existing user.""" + linked_user = await self.async_get_user_by_credentials(credentials) + if linked_user == user: + return + if linked_user is not None: + raise ValueError("Credential is already linked to a user") + await self._store.async_link_user(user, credentials) async def async_remove_user(self, user: models.User) -> None: @@ -286,7 +305,7 @@ async def async_remove_user(self, user: models.User) -> None: ] if tasks: - await asyncio.wait(tasks) + await asyncio.gather(*tasks) await self._store.async_remove_user(user) @@ -298,13 +317,18 @@ async def async_update_user( name: str | None = None, is_active: bool | None = None, group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> None: """Update a user.""" kwargs: dict[str, Any] = {} - if name is not None: - kwargs["name"] = name - if group_ids is not None: - kwargs["group_ids"] = group_ids + + for attr_name, value in ( + ("name", name), + ("group_ids", group_ids), + ("local_only", local_only), + ): + if value is not None: + kwargs[attr_name] = value await self._store.async_update_user(user, **kwargs) if is_active is not None: @@ -342,8 +366,7 @@ async def async_enable_user_mfa( "System generated users cannot enable multi-factor auth module." ) - module = self.get_auth_mfa_module(mfa_module_id) - if module is None: + if (module := self.get_auth_mfa_module(mfa_module_id)) is None: raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_setup_user(user.id, data) @@ -357,8 +380,7 @@ async def async_disable_user_mfa( "System generated users cannot disable multi-factor auth module." ) - module = self.get_auth_mfa_module(mfa_module_id) - if module is None: + if (module := self.get_auth_mfa_module(mfa_module_id)) is None: raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_depose_user(user.id) @@ -449,6 +471,28 @@ async def async_remove_refresh_token( """Delete a refresh token.""" await self._store.async_remove_refresh_token(refresh_token) + callbacks = self._revoke_callbacks.pop(refresh_token.id, []) + for revoke_callback in callbacks: + revoke_callback() + + @callback + def async_register_revoke_token_callback( + self, refresh_token_id: str, revoke_callback: CALLBACK_TYPE + ) -> CALLBACK_TYPE: + """Register a callback to be called when the refresh token id is revoked.""" + if refresh_token_id not in self._revoke_callbacks: + self._revoke_callbacks[refresh_token_id] = [] + + callbacks = self._revoke_callbacks[refresh_token_id] + callbacks.append(revoke_callback) + + @callback + def unregister() -> None: + if revoke_callback in callbacks: + callbacks.remove(revoke_callback) + + return unregister + @callback def async_create_access_token( self, refresh_token: models.RefreshToken, remote_ip: str | None = None @@ -467,7 +511,7 @@ def async_create_access_token( }, refresh_token.jwt_key, algorithm="HS256", - ).decode() + ) @callback def _async_resolve_provider( @@ -499,8 +543,7 @@ def async_validate_refresh_token( Will raise InvalidAuthError on errors. """ - provider = self._async_resolve_provider(refresh_token) - if provider: + if provider := self._async_resolve_provider(refresh_token): provider.async_validate_refresh_token(refresh_token, remote_ip) async def async_validate_access_token( @@ -508,7 +551,9 @@ async def async_validate_access_token( ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode(token, verify=False) + unverif_claims = jwt.decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 0b360668ad450..8b8531b4aff68 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -8,15 +8,21 @@ from logging import getLogger from typing import Any -from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models -from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER +from .const import ( + ACCESS_TOKEN_EXPIRATION, + GROUP_ID_ADMIN, + GROUP_ID_READ_ONLY, + GROUP_ID_USER, +) from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType +# mypy: disallow-any-generics + STORAGE_VERSION = 1 STORAGE_KEY = "auth" GROUP_NAME_ADMIN = "Administrators" @@ -40,7 +46,7 @@ def __init__(self, hass: HomeAssistant) -> None: self._groups: dict[str, models.Group] | None = None self._perm_lookup: PermissionLookup | None = None self._store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._lock = asyncio.Lock() @@ -84,6 +90,7 @@ async def async_create_user( system_generated: bool | None = None, credentials: models.Credentials | None = None, group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> models.User: """Create a new user.""" if self._users is None: @@ -94,8 +101,7 @@ async def async_create_user( groups = [] for group_id in group_ids or []: - group = self._groups.get(group_id) - if group is None: + if (group := self._groups.get(group_id)) is None: raise ValueError(f"Invalid group specified {group_id}") groups.append(group) @@ -107,14 +113,14 @@ async def async_create_user( "perm_lookup": self._perm_lookup, } - if is_owner is not None: - kwargs["is_owner"] = is_owner - - if is_active is not None: - kwargs["is_active"] = is_active - - if system_generated is not None: - kwargs["system_generated"] = system_generated + for attr_name, value in ( + ("is_owner", is_owner), + ("is_active", is_active), + ("local_only", local_only), + ("system_generated", system_generated), + ): + if value is not None: + kwargs[attr_name] = value new_user = models.User(**kwargs) @@ -151,6 +157,7 @@ async def async_update_user( name: str | None = None, is_active: bool | None = None, group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> None: """Update a user.""" assert self._groups is not None @@ -158,15 +165,18 @@ async def async_update_user( if group_ids is not None: groups = [] for grid in group_ids: - group = self._groups.get(grid) - if group is None: + if (group := self._groups.get(grid)) is None: raise ValueError("Invalid group specified.") groups.append(group) user.groups = groups user.invalidate_permission_cache() - for attr_name, value in (("name", name), ("is_active", is_active)): + for attr_name, value in ( + ("name", name), + ("is_active", is_active), + ("local_only", local_only), + ): if value is not None: setattr(user, attr_name, value) @@ -417,6 +427,8 @@ async def _async_load_task(self) -> None: is_active=user_dict["is_active"], system_generated=user_dict["system_generated"], perm_lookup=perm_lookup, + # New in 2021.11 + local_only=user_dict.get("local_only", False), ) for cred_dict in data["credentials"]: @@ -444,16 +456,14 @@ async def _async_load_task(self) -> None: ) continue - token_type = rt_dict.get("token_type") - if token_type is None: + if (token_type := rt_dict.get("token_type")) is None: if rt_dict["client_id"] is None: token_type = models.TOKEN_TYPE_SYSTEM else: token_type = models.TOKEN_TYPE_NORMAL # old refresh_token don't have last_used_at (pre-0.78) - last_used_at_str = rt_dict.get("last_used_at") - if last_used_at_str: + if last_used_at_str := rt_dict.get("last_used_at"): last_used_at = dt_util.parse_datetime(last_used_at_str) else: last_used_at = None @@ -491,7 +501,7 @@ def _async_schedule_save(self) -> None: self._store.async_delay_save(self._data_to_save, 1) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: """Return the data to store.""" assert self._users is not None assert self._groups is not None @@ -504,6 +514,7 @@ def _data_to_save(self) -> dict: "is_active": user.is_active, "name": user.name, "system_generated": user.system_generated, + "local_only": user.local_only, } for user in self._users.values() ] diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 1d40339417bd3..a50b762b12115 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -38,12 +38,12 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({"pin": str}) + return vol.Schema({vol.Required("pin"): str}) @property def setup_schema(self) -> vol.Schema: """Validate async_setup_user input data.""" - return vol.Schema({"pin": str}) + return vol.Schema({vol.Required("pin"): str}) async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 31210e2d39a49..0378d3aeaa840 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -25,7 +25,7 @@ SetupFlow, ) -REQUIREMENTS = ["pyotp==2.3.0"] +REQUIREMENTS = ["pyotp==2.6.0"] CONF_MESSAGE = "message" @@ -56,10 +56,10 @@ def _generate_secret() -> str: def _generate_random() -> int: - """Generate a 8 digit number.""" + """Generate a 32 digit number.""" import pyotp # pylint: disable=import-outside-toplevel - return int(pyotp.random_base32(length=8, chars=list("1234567890"))) + return int(pyotp.random_base32(length=32, chars=list("1234567890"))) def _generate_otp(secret: str, count: int) -> str: @@ -100,7 +100,7 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: super().__init__(hass, config) self._user_settings: _UsersDict | None = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._include = config.get(CONF_INCLUDE, []) self._exclude = config.get(CONF_EXCLUDE, []) @@ -110,7 +110,7 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({INPUT_FIELD_CODE: str}) + return vol.Schema({vol.Required(INPUT_FIELD_CODE): str}) async def _async_load(self) -> None: """Load stored data.""" @@ -118,9 +118,7 @@ async def _async_load(self) -> None: if self._user_settings is not None: return - data = await self._user_store.async_load() - - if data is None: + if (data := await self._user_store.async_load()) is None: data = {STORAGE_USERS: {}} self._user_settings = { @@ -207,8 +205,7 @@ async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: return False # user_input has been validate in caller @@ -225,8 +222,7 @@ async def async_initialize_login_mfa_step(self, user_id: str) -> None: await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: raise ValueError("Cannot find user_id") def generate_secret_and_one_time_password() -> str: @@ -249,8 +245,7 @@ async def async_notify_user(self, user_id: str, code: str) -> None: await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: _LOGGER.error("Cannot find user %s", user_id) return diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 20030ae166bea..c979ba05b5aef 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -18,7 +18,7 @@ SetupFlow, ) -REQUIREMENTS = ["pyotp==2.3.0", "PyQRCode==1.2.1"] +REQUIREMENTS = ["pyotp==2.6.0", "PyQRCode==1.2.1"] CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) @@ -77,14 +77,14 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: super().__init__(hass, config) self._users: dict[str, str] | None = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._init_lock = asyncio.Lock() @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({INPUT_FIELD_CODE: str}) + return vol.Schema({vol.Required(INPUT_FIELD_CODE): str}) async def _async_load(self) -> None: """Load stored data.""" @@ -92,9 +92,7 @@ async def _async_load(self) -> None: if self._users is not None: return - data = await self._user_store.async_load() - - if data is None: + if (data := await self._user_store.async_load()) is None: data = {STORAGE_USERS: {}} self._users = data.get(STORAGE_USERS, {}) @@ -163,8 +161,7 @@ def _validate_2fa(self, user_id: str, code: str) -> bool: """Validate two factor authentication code.""" import pyotp # pylint: disable=import-outside-toplevel - ota_secret = self._users.get(user_id) # type: ignore - if ota_secret is None: + if (ota_secret := self._users.get(user_id)) is None: # type: ignore # even we cannot find user, we still do verify # to make timing the same as if user was found. pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1) @@ -184,7 +181,7 @@ def __init__( # to fix typing complaint self._auth_module: TotpAuthModule = auth_module self._user = user - self._ota_secret: str | None = None + self._ota_secret: str = "" self._url = None # type Optional[str] self._image = None # type Optional[str] diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 758bbdb78e212..e604bf9d21cd2 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -39,6 +39,7 @@ class User: is_owner: bool = attr.ib(default=False) is_active: bool = attr.ib(default=False) system_generated: bool = attr.ib(default=False) + local_only: bool = attr.ib(default=False) groups: list[Group] = attr.ib(factory=list, eq=False, order=False) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 28ff3f638d409..101c331b84203 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,8 +1,8 @@ """Permissions for Home Assistant.""" from __future__ import annotations -import logging -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import voluptuous as vol @@ -15,8 +15,6 @@ POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) -_LOGGER = logging.getLogger(__name__) - class AbstractPermissions: """Default permissions class.""" @@ -33,9 +31,7 @@ def access_all_entities(self, key: str) -> bool: def check_entity(self, entity_id: str, key: str) -> bool: """Check if we can access entity.""" - entity_func = self._cached_entity_func - - if entity_func is None: + if (entity_func := self._cached_entity_func) is None: entity_func = self._cached_entity_func = self._entity_func() return entity_func(entity_id, key) diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index f19590a6349de..3f2a0c14f19e2 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from typing import Callable +from collections.abc import Callable import voluptuous as vol diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index e95e0080b50a8..28823a9fd1b66 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -72,8 +72,7 @@ def apply_policy_func(object_id: str, key: str) -> bool: def apply_policy_funcs(object_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(object_id, key) - if result is not None: + if (result := func(object_id, key)) is not None: return result return False diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index d2dfa0e1c6d8b..dc5f8f2580c6d 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -22,6 +22,8 @@ from ..const import MFA_SESSION_EXPIRATION from ..models import Credentials, RefreshToken, User, UserMeta +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) DATA_REQS = "auth_prov_reqs_processed" @@ -96,7 +98,7 @@ def async_create_credentials(self, data: dict[str, str]) -> Credentials: # Implement by extending class - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. @@ -167,9 +169,7 @@ async def load_auth_provider_module( if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module - processed = hass.data.get(DATA_REQS) - - if processed is None: + if (processed := hass.data.get(DATA_REQS)) is None: processed = hass.data[DATA_REQS] = set() elif provider in processed: return module diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 65d553d4eb29d..81a6b6d78e5b1 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -1,8 +1,7 @@ """Auth provider that validates credentials via an external command.""" from __future__ import annotations -import asyncio.subprocess -import collections +import asyncio from collections.abc import Mapping import logging import os @@ -17,6 +16,8 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + CONF_ARGS = "args" CONF_META = "meta" @@ -56,7 +57,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._user_meta: dict[str, dict[str, Any]] = {} - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return CommandLineLoginFlow(self) @@ -64,7 +65,7 @@ async def async_validate_login(self, username: str, password: str) -> None: """Validate a username and password.""" env = {"username": username, "password": password} try: - process = await asyncio.subprocess.create_subprocess_exec( # pylint: disable=no-member + process = await asyncio.create_subprocess_exec( self.config[CONF_COMMAND], *self.config[CONF_ARGS], env=env, @@ -146,10 +147,13 @@ async def async_step_init( user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = collections.OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index dfbf077a89def..8d682670e014b 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -3,7 +3,6 @@ import asyncio import base64 -from collections import OrderedDict from collections.abc import Mapping import logging from typing import Any, cast @@ -19,6 +18,8 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + STORAGE_VERSION = 1 STORAGE_KEY = "auth_provider.homeassistant" @@ -62,7 +63,7 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize the user data store.""" self.hass = hass self._store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._data: dict[str, Any] | None = None # Legacy mode will allow usernames to start/end with whitespace @@ -80,9 +81,7 @@ def normalize_username(self, username: str) -> str: async def async_load(self) -> None: """Load stored data.""" - data = await self._store.async_load() - - if data is None: + if (data := await self._store.async_load()) is None: data = {"users": []} seen: set[str] = set() @@ -91,9 +90,7 @@ async def async_load(self) -> None: username = user["username"] # check if we have duplicates - folded = username.casefold() - - if folded in seen: + if (folded := username.casefold()) in seen: self.is_legacy = True logging.getLogger(__name__).warning( @@ -235,7 +232,7 @@ async def async_initialize(self) -> None: await data.async_load() self.data = data - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return HassLoginFlow(self) @@ -337,10 +334,13 @@ async def async_step_init( user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index 5a3a890ff6669..dc003c3e6f3c4 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -1,10 +1,9 @@ """Example auth provider.""" from __future__ import annotations -from collections import OrderedDict from collections.abc import Mapping import hmac -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -15,6 +14,8 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + USER_SCHEMA = vol.Schema( { vol.Required("username"): str, @@ -37,7 +38,7 @@ class InvalidAuthError(HomeAssistantError): class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return ExampleLoginFlow(self) @@ -101,7 +102,7 @@ async def async_step_init( self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the step of the form.""" - errors = {} + errors = None if user_input is not None: try: @@ -109,16 +110,19 @@ async def async_step_init( user_input["username"], user_input["password"] ) except InvalidAuthError: - errors["base"] = "invalid_auth" + errors = {"base": "invalid_auth"} if not errors: user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index b385aa0ed5994..2cb113b8b8c1c 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -7,7 +7,7 @@ from collections.abc import Mapping import hmac -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -19,6 +19,8 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" @@ -44,7 +46,7 @@ def api_password(self) -> str: """Return api_password.""" return str(self.config[CONF_API_PASSWORD]) - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return LegacyLoginFlow(self) @@ -100,5 +102,7 @@ async def async_step_init( return await self.async_finish({}) return self.async_show_form( - step_id="init", data_schema=vol.Schema({"password": str}), errors=errors + step_id="init", + data_schema=vol.Schema({vol.Required("password"): str}), + errors=errors, ) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 2f120e56652ac..fa08dde139fd8 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -27,6 +27,8 @@ from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta +# mypy: disallow-any-generics + IPAddress = Union[IPv4Address, IPv6Address] IPNetwork = Union[IPv4Network, IPv6Network] @@ -81,12 +83,23 @@ def trusted_users(self) -> dict[IPNetwork, Any]: """Return trusted users per network.""" return cast(Dict[IPNetwork, Any], self.config[CONF_TRUSTED_USERS]) + @property + def trusted_proxies(self) -> list[IPNetwork]: + """Return trusted proxies in the system.""" + if not self.hass.http: + return [] + + return [ + ip_network(trusted_proxy) + for trusted_proxy in self.hass.http.trusted_proxies + ] + @property def support_mfa(self) -> bool: """Trusted Networks auth provider does not support MFA.""" return False - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" assert context is not None ip_addr = cast(IPAddress, context.get("ip_address")) @@ -95,31 +108,29 @@ async def async_login_flow(self, context: dict | None) -> LoginFlow: user for user in users if not user.system_generated and user.is_active ] for ip_net, user_or_group_list in self.trusted_users.items(): - if ip_addr in ip_net: - user_list = [ - user_id - for user_id in user_or_group_list - if isinstance(user_id, str) - ] - group_list = [ - group[CONF_GROUP] - for group in user_or_group_list - if isinstance(group, dict) - ] - flattened_group_list = [ - group for sublist in group_list for group in sublist - ] - available_users = [ - user - for user in available_users - if ( - user.id in user_list - or any( - group.id in flattened_group_list for group in user.groups - ) - ) - ] - break + if ip_addr not in ip_net: + continue + + user_list = [ + user_id for user_id in user_or_group_list if isinstance(user_id, str) + ] + group_list = [ + group[CONF_GROUP] + for group in user_or_group_list + if isinstance(group, dict) + ] + flattened_group_list = [ + group for sublist in group_list for group in sublist + ] + available_users = [ + user + for user in available_users + if ( + user.id in user_list + or any(group.id in flattened_group_list for group in user.groups) + ) + ] + break return TrustedNetworksLoginFlow( self, @@ -136,13 +147,22 @@ async def async_get_or_create_credentials( users = await self.store.async_get_users() for user in users: - if not user.system_generated and user.is_active and user.id == user_id: - for credential in await self.async_credentials(): - if credential.data["user_id"] == user_id: - return credential - cred = self.async_create_credentials({"user_id": user_id}) - await self.store.async_link_user(user, cred) - return cred + if user.id != user_id: + continue + + if user.system_generated: + continue + + if not user.is_active: + continue + + for credential in await self.async_credentials(): + if credential.data["user_id"] == user_id: + return credential + + cred = self.async_create_credentials({"user_id": user_id}) + await self.store.async_link_user(user, cred) + return cred # We only allow login as exist user raise InvalidUserError @@ -171,6 +191,15 @@ def async_validate_access(self, ip_addr: IPAddress) -> None: ): raise InvalidAuthError("Not in trusted_networks") + if any(ip_addr in trusted_proxy for trusted_proxy in self.trusted_proxies): + raise InvalidAuthError("Can't allow access from a proxy server") + + if "cloud" in self.hass.config.components: + from hass_nabucasa import remote # pylint: disable=import-outside-toplevel + + if remote.is_cloud_request.get(): + raise InvalidAuthError("Can't allow access from Home Assistant Cloud") + @callback def async_validate_refresh_token( self, refresh_token: RefreshToken, remote_ip: str | None = None @@ -221,5 +250,7 @@ async def async_step_init( return self.async_show_form( step_id="init", - data_schema=vol.Schema({"user": vol.In(self._available_users)}), + data_schema=vol.Schema( + {vol.Required("user"): vol.In(self._available_users)} + ), ) diff --git a/homeassistant/backports/__init__.py b/homeassistant/backports/__init__.py new file mode 100644 index 0000000000000..e3dc736417a0f --- /dev/null +++ b/homeassistant/backports/__init__.py @@ -0,0 +1 @@ +"""Backports from newer Python versions.""" diff --git a/homeassistant/backports/enum.py b/homeassistant/backports/enum.py new file mode 100644 index 0000000000000..3fa9a582f790d --- /dev/null +++ b/homeassistant/backports/enum.py @@ -0,0 +1,33 @@ +"""Enum backports from standard lib.""" +from __future__ import annotations + +from enum import Enum +from typing import Any, TypeVar + +T = TypeVar("T", bound="StrEnum") + + +class StrEnum(str, Enum): + """Partial backport of Python 3.11's StrEnum for our basic use cases.""" + + def __new__(cls: type[T], value: str, *args: Any, **kwargs: Any) -> T: + """Create a new StrEnum instance.""" + if not isinstance(value, str): + raise TypeError(f"{value!r} is not a string") + return super().__new__(cls, value, *args, **kwargs) # type: ignore[call-overload,no-any-return] + + def __str__(self) -> str: + """Return self.value.""" + return str(self.value) + + @staticmethod + def _generate_next_value_( # pylint: disable=arguments-differ # https://github.com/PyCQA/pylint/issues/5371 + name: str, start: int, count: int, last_values: list[Any] + ) -> Any: + """ + Make `auto()` explicitly unsupported. + + We may revisit this when it's very clear that Python 3.11's + `StrEnum.auto()` behavior will no longer change. + """ + raise TypeError("auto() is not supported by this implementation") diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index ec56b7467064c..0a1f4445c3ecf 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -1,7 +1,7 @@ """Block I/O being done in asyncio.""" from http.client import HTTPConnection -from homeassistant.util.async_ import protect_loop +from .util.async_ import protect_loop def enable() -> None: diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 45c0465146128..94dfb2bd1c8c5 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -15,24 +15,24 @@ import voluptuous as vol import yarl -from homeassistant import config as conf_util, config_entries, core, loader -from homeassistant.components import http -from homeassistant.const import REQUIRED_NEXT_PYTHON_DATE, REQUIRED_NEXT_PYTHON_VER -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry, device_registry, entity_registry -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import ( +from . import config as conf_util, config_entries, core, loader +from .components import http +from .const import REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER +from .exceptions import HomeAssistantError +from .helpers import area_registry, device_registry, entity_registry +from .helpers.dispatcher import async_dispatcher_send +from .helpers.typing import ConfigType +from .setup import ( DATA_SETUP, DATA_SETUP_STARTED, DATA_SETUP_TIME, async_set_domains_to_be_loaded, async_setup_component, ) -from homeassistant.util.async_ import gather_with_concurrency -import homeassistant.util.dt as dt_util -from homeassistant.util.logging import async_activate_log_queue_handler -from homeassistant.util.package import async_get_user_site, is_virtual_env +from .util import dt as dt_util +from .util.async_ import gather_with_concurrency +from .util.logging import async_activate_log_queue_handler +from .util.package import async_get_user_site, is_virtual_env if TYPE_CHECKING: from .runner import RuntimeConfig @@ -109,9 +109,8 @@ async def async_setup_hass( config_dict = None basic_setup_success = False - safe_mode = runtime_config.safe_mode - if not safe_mode: + if not (safe_mode := runtime_config.safe_mode): await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: @@ -241,14 +240,16 @@ async def async_from_config_dict( stop = monotonic() _LOGGER.info("Home Assistant initialized in %.2fs", stop - start) - if REQUIRED_NEXT_PYTHON_DATE and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER: + if ( + REQUIRED_NEXT_PYTHON_HA_RELEASE + and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER + ): msg = ( "Support for the running Python version " f"{'.'.join(str(x) for x in sys.version_info[:3])} is deprecated and will " - f"be removed in the first release after {REQUIRED_NEXT_PYTHON_DATE}. " + f"be removed in Home Assistant {REQUIRED_NEXT_PYTHON_HA_RELEASE}. " "Please upgrade Python to " - f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER)} or " - "higher." + f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])}." ) _LOGGER.warning(msg) hass.components.persistent_notification.async_create( @@ -332,14 +333,20 @@ def async_enable_logging( not err_path_exists and os.access(err_dir, os.W_OK) ): + err_handler: logging.handlers.RotatingFileHandler | logging.handlers.TimedRotatingFileHandler if log_rotate_days: - err_handler: logging.FileHandler = ( - logging.handlers.TimedRotatingFileHandler( - err_log_path, when="midnight", backupCount=log_rotate_days - ) + err_handler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when="midnight", backupCount=log_rotate_days ) else: - err_handler = logging.FileHandler(err_log_path, mode="w", delay=True) + err_handler = logging.handlers.RotatingFileHandler( + err_log_path, backupCount=1 + ) + + try: + err_handler.doRollover() + except OSError as err: + _LOGGER.error("Error rolling over log file: %s", err) err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) @@ -362,8 +369,7 @@ async def async_mount_local_lib_path(config_dir: str) -> str: This function is a coroutine. """ deps_dir = os.path.join(config_dir, "deps") - lib_dir = await async_get_user_site(deps_dir) - if lib_dir not in sys.path: + if (lib_dir := await async_get_user_site(deps_dir)) not in sys.path: sys.path.insert(0, lib_dir) return deps_dir @@ -488,17 +494,13 @@ async def _async_set_up_integrations( _LOGGER.info("Domains to be set up: %s", domains_to_setup) - logging_domains = domains_to_setup & LOGGING_INTEGRATIONS - # Load logging as soon as possible - if logging_domains: + if logging_domains := domains_to_setup & LOGGING_INTEGRATIONS: _LOGGER.info("Setting up logging: %s", logging_domains) await async_setup_multi_components(hass, logging_domains, config) # Start up debuggers. Start these first in case they want to wait. - debuggers = domains_to_setup & DEBUGGER_INTEGRATIONS - - if debuggers: + if debuggers := domains_to_setup & DEBUGGER_INTEGRATIONS: _LOGGER.debug("Setting up debuggers: %s", debuggers) await async_setup_multi_components(hass, debuggers, config) @@ -518,9 +520,7 @@ async def _async_set_up_integrations( stage_1_domains.add(domain) - dep_itg = integration_cache.get(domain) - - if dep_itg is None: + if (dep_itg := integration_cache.get(domain)) is None: continue deps_promotion.update(dep_itg.all_dependencies) @@ -558,6 +558,14 @@ async def _async_set_up_integrations( except asyncio.TimeoutError: _LOGGER.warning("Setup timed out for stage 2 - moving forward") + # Wrap up startup + _LOGGER.debug("Waiting for startup to wrap up") + try: + async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): + await hass.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for bootstrap - moving forward") + watch_task.cancel() async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATONS, {}) @@ -570,11 +578,3 @@ async def _async_set_up_integrations( ) }, ) - - # Wrap up startup - _LOGGER.debug("Waiting for startup to wrap up") - try: - async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): - await hass.async_block_till_done() - except asyncio.TimeoutError: - _LOGGER.warning("Setup timed out for bootstrap - moving forward") diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 156dbae280497..411a25373c2a7 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,5 +1,4 @@ """Support for the Abode Security System.""" -from copy import deepcopy from functools import partial from abodepy import Abode @@ -8,9 +7,8 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_DATE, ATTR_DEVICE_ID, ATTR_ENTITY_ID, @@ -18,11 +16,13 @@ CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN, LOGGER @@ -44,22 +44,7 @@ ATTR_EVENT_BY = "event_by" ATTR_VALUE = "value" -CONFIG_SCHEMA = vol.Schema( - vol.All( - # Deprecated in Home Assistant 2021.6 - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_POLLING, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) CHANGE_SETTING_SCHEMA = vol.Schema( {vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string} @@ -70,14 +55,14 @@ AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) PLATFORMS = [ - "alarm_control_panel", - "binary_sensor", - "lock", - "switch", - "cover", - "camera", - "light", - "sensor", + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SWITCH, + Platform.COVER, + Platform.CAMERA, + Platform.LIGHT, + Platform.SENSOR, ] @@ -92,23 +77,7 @@ def __init__(self, abode, polling): self.logout_listener = None -async def async_setup(hass, config): - """Set up Abode integration.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=deepcopy(conf) - ) - ) - - return True - - -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Abode integration from a config entry.""" username = config_entry.data.get(CONF_USERNAME) password = config_entry.data.get(CONF_PASSWORD) @@ -143,7 +112,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" hass.services.async_remove(DOMAIN, SERVICE_SETTINGS) hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE) @@ -162,7 +131,7 @@ async def async_unload_entry(hass, config_entry): return unload_ok -def setup_hass_services(hass): +def setup_hass_services(hass: HomeAssistant) -> None: """Home Assistant services.""" def change_setting(call): @@ -216,7 +185,7 @@ def trigger_automation(call): ) -async def setup_hass_events(hass): +async def setup_hass_events(hass: HomeAssistant) -> None: """Home Assistant start and stop callbacks.""" def logout(event): @@ -235,7 +204,7 @@ def logout(event): ) -def setup_abode_events(hass): +def setup_abode_events(hass: HomeAssistant) -> None: """Event callbacks.""" def event_callback(event, event_json): @@ -280,20 +249,12 @@ def event_callback(event, event_json): class AbodeEntity(Entity): """Representation of an Abode entity.""" + _attr_attribution = ATTRIBUTION + def __init__(self, data): """Initialize Abode entity.""" self._data = data - self._available = True - - @property - def available(self): - """Return the available state.""" - return self._available - - @property - def should_poll(self): - """Return the polling state.""" - return self._data.polling + self._attr_should_poll = data.polling async def async_added_to_hass(self): """Subscribe to Abode connection status updates.""" @@ -313,7 +274,7 @@ async def async_will_remove_from_hass(self): def _update_connection_status(self): """Update the entity available property.""" - self._available = self._data.abode.events.connected + self._attr_available = self._data.abode.events.connected self.schedule_update_ha_state() @@ -324,6 +285,8 @@ def __init__(self, data, device): """Initialize Abode device.""" super().__init__(data) self._device = device + self._attr_name = device.name + self._attr_unique_id = device.device_uuid async def async_added_to_hass(self): """Subscribe to device events.""" @@ -345,16 +308,10 @@ def update(self): """Update device state.""" self._device.refresh() - @property - def name(self): - """Return the name of the device.""" - return self._device.name - @property def extra_state_attributes(self): """Return the state attributes.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, "device_id": self._device.device_id, "battery_low": self._device.battery_low, "no_response": self._device.no_response, @@ -362,19 +319,14 @@ def extra_state_attributes(self): } @property - def unique_id(self): - """Return a unique ID to use for this device.""" - return self._device.device_uuid - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "manufacturer": "Abode", - "name": self._device.name, - "device_type": self._device.type, - } + return DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + manufacturer="Abode", + model=self._device.type, + name=self._device.name, + ) def _update_callback(self, device): """Update the device state.""" @@ -388,22 +340,12 @@ def __init__(self, data, automation): """Initialize for Abode automation.""" super().__init__(data) self._automation = automation + self._attr_name = automation.name + self._attr_unique_id = automation.automation_id + self._attr_extra_state_attributes = { + "type": "CUE automation", + } def update(self): """Update automation state.""" self._automation.refresh() - - @property - def name(self): - """Return the name of the automation.""" - return self._automation.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION, "type": "CUE automation"} - - @property - def unique_id(self): - """Return a unique ID to use for this automation.""" - return self._automation.automation_id diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 6d0c030e3e1e0..791c6ab393def 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -4,20 +4,26 @@ SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice -from .const import ATTRIBUTION, DOMAIN +from .const import DOMAIN ICON = "mdi:security" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode alarm control panel device.""" data = hass.data[DOMAIN] async_add_entities( @@ -28,10 +34,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): """An alarm_control_panel implementation for Abode.""" - @property - def icon(self): - """Return the icon.""" - return ICON + _attr_icon = ICON + _attr_code_arm_required = False + _attr_supported_features = SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY @property def state(self): @@ -46,16 +51,6 @@ def state(self): state = None return state - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return False - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() @@ -72,7 +67,6 @@ def alarm_arm_away(self, code=None): def extra_state_attributes(self): """Return the state attributes.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, "device_id": self._device.device_id, "battery_backup": self._device.battery, "cellular_backup": self._device.is_cellular, diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 7175fbc550a95..0f59f02842ff8 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -2,15 +2,22 @@ import abodepy.helpers.constants as CONST from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_WINDOW, + BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice from .const import DOMAIN -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode binary sensor devices.""" data = hass.data[DOMAIN] @@ -42,5 +49,5 @@ def is_on(self): def device_class(self): """Return the class of the binary sensor.""" if self._device.get_value("is_window") == "1": - return DEVICE_CLASS_WINDOW + return BinarySensorDeviceClass.WINDOW return self._device.generic_type diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 99d4fd433a7af..05feaf214c53c 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,4 +1,6 @@ """Support for Abode Security System cameras.""" +from __future__ import annotations + from datetime import timedelta import abodepy.helpers.constants as CONST @@ -6,7 +8,10 @@ import requests from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle from . import AbodeDevice @@ -15,7 +20,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode camera devices.""" data = hass.data[DOMAIN] @@ -73,7 +82,9 @@ def get_image(self): else: self._response = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get a camera image.""" self.refresh_image() diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index bf51ffee81cd4..0c22766e37308 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Abode Security System component.""" +from http import HTTPStatus + from abodepy import Abode from abodepy.exceptions import AbodeAuthenticationException, AbodeException from abodepy.helpers.errors import MFA_CODE_REQUIRED @@ -6,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, HTTP_BAD_REQUEST +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import DEFAULT_CACHEDB, DOMAIN, LOGGER @@ -51,7 +53,7 @@ async def _async_abode_login(self, step_id): LOGGER.error("Unable to connect to Abode: %s", ex) - if ex.errcode == HTTP_BAD_REQUEST: + if ex.errcode == HTTPStatus.BAD_REQUEST: errors = {"base": "invalid_auth"} else: @@ -158,13 +160,3 @@ async def async_step_reauth_confirm(self, user_input=None): self._password = user_input[CONF_PASSWORD] return await self._async_abode_login(step_id="reauth_confirm") - - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - if self._async_current_entries(): - LOGGER.warning("Already configured; Only a single configuration possible") - return self.async_abort(reason="single_instance_allowed") - - self._polling = import_config.get(CONF_POLLING, False) - - return await self.async_step_user(import_config) diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index d88c2fdd4048f..6eb65296c2d26 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -2,12 +2,19 @@ import abodepy.helpers.constants as CONST from homeassistant.components.cover import CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice from .const import DOMAIN -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode cover devices.""" data = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index b756c79d9decb..7f90137d0f77c 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -12,6 +12,9 @@ SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import ( color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, @@ -21,7 +24,11 @@ from .const import DOMAIN -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode light devices.""" data = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index 2a52663c0e7b5..f766ef88c2c74 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -2,12 +2,19 @@ import abodepy.helpers.constants as CONST from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice from .const import DOMAIN -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode lock devices.""" data = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index e3ececb62d96a..f415036aff8bb 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,35 +1,58 @@ """Support for Abode Security System sensors.""" +from __future__ import annotations + import abodepy.helpers.constants as CONST -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice from .const import DOMAIN -# Sensor types: Name, icon -SENSOR_TYPES = { - CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE], - CONST.HUMI_STATUS_KEY: ["Humidity", DEVICE_CLASS_HUMIDITY], - CONST.LUX_STATUS_KEY: ["Lux", DEVICE_CLASS_ILLUMINANCE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CONST.TEMP_STATUS_KEY, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key=CONST.HUMI_STATUS_KEY, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=CONST.LUX_STATUS_KEY, + name="Lux", + device_class=SensorDeviceClass.ILLUMINANCE, + ), +) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode sensor devices.""" data = hass.data[DOMAIN] entities = [] for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR): - for sensor_type in SENSOR_TYPES: - if sensor_type not in device.get_value(CONST.STATUSES_KEY): - continue - entities.append(AbodeSensor(data, device, sensor_type)) + conditions = device.get_value(CONST.STATUSES_KEY) + entities.extend( + [ + AbodeSensor(data, device, description) + for description in SENSOR_TYPES + if description.key in conditions + ] + ) async_add_entities(entities) @@ -37,44 +60,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AbodeSensor(AbodeDevice, SensorEntity): """A sensor implementation for Abode devices.""" - def __init__(self, data, device, sensor_type): + def __init__(self, data, device, description: SensorEntityDescription): """Initialize a sensor for an Abode device.""" super().__init__(data, device) - self._sensor_type = sensor_type - self._name = f"{self._device.name} {SENSOR_TYPES[self._sensor_type][0]}" - self._device_class = SENSOR_TYPES[self._sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def unique_id(self): - """Return a unique ID to use for this device.""" - return f"{self._device.device_uuid}-{self._sensor_type}" + self.entity_description = description + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.device_uuid}-{description.key}" + if description.key == CONST.TEMP_STATUS_KEY: + self._attr_native_unit_of_measurement = device.temp_unit + elif description.key == CONST.HUMI_STATUS_KEY: + self._attr_native_unit_of_measurement = device.humidity_unit + elif description.key == CONST.LUX_STATUS_KEY: + self._attr_native_unit_of_measurement = device.lux_unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._sensor_type == CONST.TEMP_STATUS_KEY: + if self.entity_description.key == CONST.TEMP_STATUS_KEY: return self._device.temp - if self._sensor_type == CONST.HUMI_STATUS_KEY: + if self.entity_description.key == CONST.HUMI_STATUS_KEY: return self._device.humidity - if self._sensor_type == CONST.LUX_STATUS_KEY: + if self.entity_description.key == CONST.LUX_STATUS_KEY: return self._device.lux - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - if self._sensor_type == CONST.TEMP_STATUS_KEY: - return self._device.temp_unit - if self._sensor_type == CONST.HUMI_STATUS_KEY: - return self._device.humidity_unit - if self._sensor_type == CONST.LUX_STATUS_KEY: - return self._device.lux_unit diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml index 9b5362c0929a2..843cc123c69b6 100644 --- a/homeassistant/components/abode/services.yaml +++ b/homeassistant/components/abode/services.yaml @@ -6,7 +6,6 @@ capture_image: name: Entity description: Entity id of the camera to request an image. required: true - example: camera.downstairs_motion_camera selector: entity: integration: abode @@ -39,7 +38,6 @@ trigger_automation: name: Entity description: Entity id of the automation to trigger. required: true - example: switch.my_automation selector: entity: integration: abode diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 0985ce5ce2a30..aacfb46d287d1 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -1,8 +1,13 @@ """Support for Abode Security System switches.""" +from __future__ import annotations + import abodepy.helpers.constants as CONST from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeAutomation, AbodeDevice from .const import DOMAIN @@ -12,11 +17,15 @@ ICON = "mdi:robot" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Abode switch devices.""" data = hass.data[DOMAIN] - entities = [] + entities: list[SwitchEntity] = [] for device_type in DEVICE_TYPES: for device in data.abode.get_devices(generic_type=device_type): @@ -48,6 +57,8 @@ def is_on(self): class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): """A switch implementation for Abode automations.""" + _attr_icon = ICON + async def async_added_to_hass(self): """Set up trigger automation service.""" await super().async_added_to_hass() @@ -73,8 +84,3 @@ def trigger(self): def is_on(self): """Return True if the automation is enabled.""" return self._automation.is_enabled - - @property - def icon(self): - """Return the robot icon to match Home Assistant automations.""" - return ICON diff --git a/homeassistant/components/abode/translations/bg.json b/homeassistant/components/abode/translations/bg.json index 285bf18d330b8..955ed18c82c9e 100644 --- a/homeassistant/components/abode/translations/bg.json +++ b/homeassistant/components/abode/translations/bg.json @@ -1,9 +1,19 @@ { "config": { "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Abode." }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "Email" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/abode/translations/de.json b/homeassistant/components/abode/translations/de.json index 307f5f45065d4..695ecba621c48 100644 --- a/homeassistant/components/abode/translations/de.json +++ b/homeassistant/components/abode/translations/de.json @@ -26,7 +26,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Gib deine Abode-Anmeldeinformationen ein" } diff --git a/homeassistant/components/abode/translations/es-419.json b/homeassistant/components/abode/translations/es-419.json index 9de6d9d185a2a..6d380e5bb436b 100644 --- a/homeassistant/components/abode/translations/es-419.json +++ b/homeassistant/components/abode/translations/es-419.json @@ -1,9 +1,12 @@ { "config": { "abort": { + "reauth_successful": "La reautenticaci\u00f3n fue exitosa", "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", "invalid_mfa_code": "C\u00f3digo MFA no v\u00e1lido" }, "step": { @@ -15,7 +18,8 @@ }, "reauth_confirm": { "data": { - "password": "Contrase\u00f1a" + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" }, "title": "Complete su informaci\u00f3n de inicio de sesi\u00f3n de Abode" }, diff --git a/homeassistant/components/abode/translations/fr.json b/homeassistant/components/abode/translations/fr.json index 2ab158cca57f1..fb5a079d40527 100644 --- a/homeassistant/components/abode/translations/fr.json +++ b/homeassistant/components/abode/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", - "single_instance_allowed": "D\u00e9ja configur\u00e9. Une seule configuration possible." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/abode/translations/he.json b/homeassistant/components/abode/translations/he.json index 6f4191da70d53..17717573b68d5 100644 --- a/homeassistant/components/abode/translations/he.json +++ b/homeassistant/components/abode/translations/he.json @@ -1,10 +1,26 @@ { "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, "step": { - "user": { + "reauth_confirm": { "data": { - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05d3\u05d5\u05d0\"\u05dc" } + }, + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05d3\u05d5\u05d0\"\u05dc" + }, + "title": "\u05d9\u05e9 \u05dc\u05de\u05dc\u05d0 \u05d0\u05ea \u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e0\u05d9\u05e1\u05d4 \u05e9\u05dc\u05da \u05dc\u05d0\u05d3\u05d5\u05d1\u05d9" } } } diff --git a/homeassistant/components/abode/translations/hu.json b/homeassistant/components/abode/translations/hu.json index 260416b07bba4..8f835cbbe2df4 100644 --- a/homeassistant/components/abode/translations/hu.json +++ b/homeassistant/components/abode/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "error": { @@ -20,7 +20,8 @@ "data": { "password": "Jelsz\u00f3", "username": "E-mail" - } + }, + "title": "T\u00f6ltse ki az Abode bejelentkez\u00e9si adatait" }, "user": { "data": { diff --git a/homeassistant/components/abode/translations/it.json b/homeassistant/components/abode/translations/it.json index 6cb571df8e5e9..76e8bec17be44 100644 --- a/homeassistant/components/abode/translations/it.json +++ b/homeassistant/components/abode/translations/it.json @@ -19,14 +19,14 @@ "reauth_confirm": { "data": { "password": "Password", - "username": "E-mail" + "username": "Email" }, "title": "Inserisci le tue informazioni di accesso Abode" }, "user": { "data": { "password": "Password", - "username": "E-mail" + "username": "Email" }, "title": "Inserisci le tue informazioni di accesso Abode" } diff --git a/homeassistant/components/abode/translations/ja.json b/homeassistant/components/abode/translations/ja.json new file mode 100644 index 0000000000000..cd498691f4b94 --- /dev/null +++ b/homeassistant/components/abode/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_mfa_code": "\u7121\u52b9\u306aMFA\u30b3\u30fc\u30c9" + }, + "step": { + "mfa": { + "data": { + "mfa_code": "MFA\u30b3\u30fc\u30c9(6\u6841)" + }, + "title": "Abode\u306eMFA\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "Abode\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "Abode\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/tr.json b/homeassistant/components/abode/translations/tr.json index d469e43f1f42a..6214803198ad5 100644 --- a/homeassistant/components/abode/translations/tr.json +++ b/homeassistant/components/abode/translations/tr.json @@ -27,7 +27,8 @@ "data": { "password": "Parola", "username": "E-posta" - } + }, + "title": "Abode giri\u015f bilgilerinizi doldurun" } } } diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index f6f124b2d4d07..bc8ae459fd7eb 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -1,33 +1,34 @@ """The AccuWeather component.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any, Dict from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from homeassistant.const import CONF_API_KEY +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - ATTR_FORECAST, - CONF_FORECAST, - COORDINATOR, - DOMAIN, - UNDO_UPDATE_LISTENER, -) +from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "weather"] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -async def async_setup_entry(hass, config_entry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AccuWeather as config entry.""" - api_key = config_entry.data[CONF_API_KEY] - location_key = config_entry.unique_id - forecast = config_entry.options.get(CONF_FORECAST, False) + api_key: str = entry.data[CONF_API_KEY] + assert entry.unique_id is not None + location_key = entry.unique_id + forecast: bool = entry.options.get(CONF_FORECAST, False) _LOGGER.debug("Using location_key: %s, get forecast: %s", location_key, forecast) @@ -38,41 +39,41 @@ async def async_setup_entry(hass, config_entry) -> bool: ) await coordinator.async_config_entry_first_refresh() - undo_listener = config_entry.add_update_listener(update_listener) + entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { - COORDINATOR: coordinator, - UNDO_UPDATE_LISTENER: undo_listener, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def update_listener(hass, config_entry): +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener.""" - await hass.config_entries.async_reload(config_entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) -class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator): +class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[Dict[str, Any]]): """Class to manage fetching AccuWeather data API.""" - def __init__(self, hass, session, api_key, location_key, forecast: bool): + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + location_key: str, + forecast: bool, + ) -> None: """Initialize.""" self.location_key = location_key self.forecast = forecast @@ -91,7 +92,7 @@ def __init__(self, hass, session, api_key, location_key, forecast: bool): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: async with timeout(10): @@ -108,5 +109,5 @@ async def _async_update_data(self): RequestsExceededError, ) as error: raise UpdateFailed(error) from error - _LOGGER.debug("Requests remaining: %s", self.accuweather.requests_remaining) + _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) return {**current, **{ATTR_FORECAST: forecast}} diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 999a54b11a735..b9244a3645c69 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -1,5 +1,8 @@ """Adds config flow for AccuWeather.""" +from __future__ import annotations + import asyncio +from typing import Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientError @@ -8,8 +11,10 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -21,7 +26,9 @@ class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" # Under the terms of use of the API, one user can use one free API key. Due to # the small number of requests allowed, we only allow one integration instance. @@ -77,7 +84,9 @@ async def async_step_user(self, user_input=None): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> AccuWeatherOptionsFlowHandler: """Options callback for AccuWeather.""" return AccuWeatherOptionsFlowHandler(config_entry) @@ -85,15 +94,19 @@ def async_get_options_flow(config_entry): class AccuWeatherOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for AccuWeather.""" - def __init__(self, config_entry): + def __init__(self, entry: ConfigEntry) -> None: """Initialize AccuWeather options flow.""" - self.config_entry = config_entry + self.config_entry = entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" return await self.async_step_user() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 60fdd48c8f4c0..408d470042283 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -1,4 +1,9 @@ """Constants for AccuWeather integration.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -16,10 +21,7 @@ ATTR_CONDITION_WINDY, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_PARTS_PER_CUBIC_METER, - DEVICE_CLASS_TEMPERATURE, LENGTH_FEET, LENGTH_INCHES, LENGTH_METERS, @@ -33,18 +35,19 @@ UV_INDEX, ) -ATTRIBUTION = "Data provided by AccuWeather" -ATTR_FORECAST = CONF_FORECAST = "forecast" -ATTR_LABEL = "label" -ATTR_UNIT_IMPERIAL = "Imperial" -ATTR_UNIT_METRIC = "Metric" -COORDINATOR = "coordinator" -DOMAIN = "accuweather" -MANUFACTURER = "AccuWeather, Inc." -NAME = "AccuWeather" -UNDO_UPDATE_LISTENER = "undo_update_listener" +from .model import AccuWeatherSensorDescription -CONDITION_CLASSES = { +API_IMPERIAL: Final = "Imperial" +API_METRIC: Final = "Metric" +ATTRIBUTION: Final = "Data provided by AccuWeather" +ATTR_FORECAST: Final = "forecast" +CONF_FORECAST: Final = "forecast" +DOMAIN: Final = "accuweather" +MANUFACTURER: Final = "AccuWeather, Inc." +MAX_FORECAST_DAYS: Final = 4 +NAME: Final = "AccuWeather" + +CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_CLEAR_NIGHT: [33, 34, 37], ATTR_CONDITION_CLOUDY: [7, 8, 38], ATTR_CONDITION_EXCEPTIONAL: [24, 30, 31], @@ -61,255 +64,263 @@ ATTR_CONDITION_WINDY: [32], } -FORECAST_DAYS = [0, 1, 2, 3, 4] - -FORECAST_SENSOR_TYPES = { - "CloudCoverDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-cloudy", - ATTR_LABEL: "Cloud Cover Day", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - }, - "CloudCoverNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-cloudy", - ATTR_LABEL: "Cloud Cover Night", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - }, - "Grass": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:grass", - ATTR_LABEL: "Grass Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "HoursOfSun": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-partly-cloudy", - ATTR_LABEL: "Hours Of Sun", - ATTR_UNIT_METRIC: TIME_HOURS, - ATTR_UNIT_IMPERIAL: TIME_HOURS, - }, - "Mold": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: "Mold Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "Ozone": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:vector-triangle", - ATTR_LABEL: "Ozone", - ATTR_UNIT_METRIC: None, - ATTR_UNIT_IMPERIAL: None, - }, - "Ragweed": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:sprout", - ATTR_LABEL: "Ragweed Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "RealFeelTemperatureMax": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Max", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "RealFeelTemperatureMin": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Min", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "RealFeelTemperatureShadeMax": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Shade Max", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "RealFeelTemperatureShadeMin": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Shade Min", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "ThunderstormProbabilityDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-lightning", - ATTR_LABEL: "Thunderstorm Probability Day", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - }, - "ThunderstormProbabilityNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-lightning", - ATTR_LABEL: "Thunderstorm Probability Night", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - }, - "Tree": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:tree-outline", - ATTR_LABEL: "Tree Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "UVIndex": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-sunny", - ATTR_LABEL: "UV Index", - ATTR_UNIT_METRIC: UV_INDEX, - ATTR_UNIT_IMPERIAL: UV_INDEX, - }, - "WindGustDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust Day", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - }, - "WindGustNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust Night", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - }, - "WindDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Day", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - }, - "WindNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Night", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - }, -} - -OPTIONAL_SENSORS = ( - "ApparentTemperature", - "CloudCover", - "CloudCoverDay", - "CloudCoverNight", - "DewPoint", - "Grass", - "Mold", - "Ozone", - "Ragweed", - "RealFeelTemperatureShade", - "RealFeelTemperatureShadeMax", - "RealFeelTemperatureShadeMin", - "Tree", - "WetBulbTemperature", - "WindChillTemperature", - "WindGust", - "WindGustDay", - "WindGustNight", +FORECAST_SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( + AccuWeatherSensorDescription( + key="CloudCoverDay", + icon="mdi:weather-cloudy", + name="Cloud Cover Day", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="CloudCoverNight", + icon="mdi:weather-cloudy", + name="Cloud Cover Night", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="Grass", + icon="mdi:grass", + name="Grass Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="HoursOfSun", + icon="mdi:weather-partly-cloudy", + name="Hours Of Sun", + unit_metric=TIME_HOURS, + unit_imperial=TIME_HOURS, + ), + AccuWeatherSensorDescription( + key="Mold", + icon="mdi:blur", + name="Mold Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="Ozone", + icon="mdi:vector-triangle", + name="Ozone", + unit_metric=None, + unit_imperial=None, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="Ragweed", + icon="mdi:sprout", + name="Ragweed Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureMax", + device_class=SensorDeviceClass.TEMPERATURE, + name="RealFeel Temperature Max", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureMin", + device_class=SensorDeviceClass.TEMPERATURE, + name="RealFeel Temperature Min", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMax", + device_class=SensorDeviceClass.TEMPERATURE, + name="RealFeel Temperature Shade Max", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMin", + device_class=SensorDeviceClass.TEMPERATURE, + name="RealFeel Temperature Shade Min", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="ThunderstormProbabilityDay", + icon="mdi:weather-lightning", + name="Thunderstorm Probability Day", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + ), + AccuWeatherSensorDescription( + key="ThunderstormProbabilityNight", + icon="mdi:weather-lightning", + name="Thunderstorm Probability Night", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + ), + AccuWeatherSensorDescription( + key="Tree", + icon="mdi:tree-outline", + name="Tree Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="UVIndex", + icon="mdi:weather-sunny", + name="UV Index", + unit_metric=UV_INDEX, + unit_imperial=UV_INDEX, + ), + AccuWeatherSensorDescription( + key="WindGustDay", + icon="mdi:weather-windy", + name="Wind Gust Day", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="WindGustNight", + icon="mdi:weather-windy", + name="Wind Gust Night", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="WindDay", + icon="mdi:weather-windy", + name="Wind Day", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + ), + AccuWeatherSensorDescription( + key="WindNight", + icon="mdi:weather-windy", + name="Wind Night", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + ), ) -SENSOR_TYPES = { - "ApparentTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Apparent Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "Ceiling": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-fog", - ATTR_LABEL: "Cloud Ceiling", - ATTR_UNIT_METRIC: LENGTH_METERS, - ATTR_UNIT_IMPERIAL: LENGTH_FEET, - }, - "CloudCover": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-cloudy", - ATTR_LABEL: "Cloud Cover", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - }, - "DewPoint": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Dew Point", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "RealFeelTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "RealFeelTemperatureShade": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Shade", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "Precipitation": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-rainy", - ATTR_LABEL: "Precipitation", - ATTR_UNIT_METRIC: LENGTH_MILLIMETERS, - ATTR_UNIT_IMPERIAL: LENGTH_INCHES, - }, - "PressureTendency": { - ATTR_DEVICE_CLASS: "accuweather__pressure_tendency", - ATTR_ICON: "mdi:gauge", - ATTR_LABEL: "Pressure Tendency", - ATTR_UNIT_METRIC: None, - ATTR_UNIT_IMPERIAL: None, - }, - "UVIndex": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-sunny", - ATTR_LABEL: "UV Index", - ATTR_UNIT_METRIC: UV_INDEX, - ATTR_UNIT_IMPERIAL: UV_INDEX, - }, - "WetBulbTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Wet Bulb Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "WindChillTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Wind Chill Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - }, - "Wind": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - }, - "WindGust": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - }, -} +SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( + AccuWeatherSensorDescription( + key="ApparentTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + name="Apparent Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="Ceiling", + icon="mdi:weather-fog", + name="Cloud Ceiling", + unit_metric=LENGTH_METERS, + unit_imperial=LENGTH_FEET, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="CloudCover", + icon="mdi:weather-cloudy", + name="Cloud Cover", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="DewPoint", + device_class=SensorDeviceClass.TEMPERATURE, + name="Dew Point", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + name="RealFeel Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureShade", + device_class=SensorDeviceClass.TEMPERATURE, + name="RealFeel Temperature Shade", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="Precipitation", + icon="mdi:weather-rainy", + name="Precipitation", + unit_metric=LENGTH_MILLIMETERS, + unit_imperial=LENGTH_INCHES, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="PressureTendency", + device_class="accuweather__pressure_tendency", + icon="mdi:gauge", + name="Pressure Tendency", + unit_metric=None, + unit_imperial=None, + ), + AccuWeatherSensorDescription( + key="UVIndex", + icon="mdi:weather-sunny", + name="UV Index", + unit_metric=UV_INDEX, + unit_imperial=UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="WetBulbTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + name="Wet Bulb Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="WindChillTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + name="Wind Chill Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="Wind", + icon="mdi:weather-windy", + name="Wind", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="WindGust", + icon="mdi:weather-windy", + name="Wind Gust", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), +) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 068b0fc83a9a2..fd391a81bad24 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -2,7 +2,7 @@ "domain": "accuweather", "name": "AccuWeather", "documentation": "https://www.home-assistant.io/integrations/accuweather/", - "requirements": ["accuweather==0.1.1"], + "requirements": ["accuweather==0.3.0"], "codeowners": ["@bieniu"], "config_flow": true, "quality_scale": "platinum", diff --git a/homeassistant/components/accuweather/model.py b/homeassistant/components/accuweather/model.py new file mode 100644 index 0000000000000..e74a6d460573b --- /dev/null +++ b/homeassistant/components/accuweather/model.py @@ -0,0 +1,14 @@ +"""Type definitions for AccuWeather integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class AccuWeatherSensorDescription(SensorEntityDescription): + """Class describing AccuWeather sensor entities.""" + + unit_metric: str | None = None + unit_imperial: str | None = None diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 722dd8869be88..448c00eb53fc4 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -1,166 +1,181 @@ """Support for the AccuWeather service.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - CONF_NAME, - DEVICE_CLASS_TEMPERATURE, -) +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AccuWeatherDataUpdateCoordinator from .const import ( + API_IMPERIAL, + API_METRIC, ATTR_FORECAST, - ATTR_ICON, - ATTR_LABEL, ATTRIBUTION, - COORDINATOR, DOMAIN, - FORECAST_DAYS, FORECAST_SENSOR_TYPES, MANUFACTURER, + MAX_FORECAST_DAYS, NAME, - OPTIONAL_SENSORS, SENSOR_TYPES, ) +from .model import AccuWeatherSensorDescription PARALLEL_UPDATES = 1 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Add AccuWeather entities from a config_entry.""" - name = config_entry.data[CONF_NAME] + name: str = entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [] - for sensor in SENSOR_TYPES: - sensors.append(AccuWeatherSensor(name, sensor, coordinator)) + sensors: list[AccuWeatherSensor] = [] + for description in SENSOR_TYPES: + sensors.append(AccuWeatherSensor(name, coordinator, description)) if coordinator.forecast: - for sensor in FORECAST_SENSOR_TYPES: - for day in FORECAST_DAYS: + for description in FORECAST_SENSOR_TYPES: + for day in range(MAX_FORECAST_DAYS + 1): # Some air quality/allergy sensors are only available for certain # locations. - if sensor in coordinator.data[ATTR_FORECAST][0]: + if description.key in coordinator.data[ATTR_FORECAST][0]: sensors.append( - AccuWeatherSensor(name, sensor, coordinator, forecast_day=day) + AccuWeatherSensor( + name, coordinator, description, forecast_day=day + ) ) - async_add_entities(sensors, False) + async_add_entities(sensors) class AccuWeatherSensor(CoordinatorEntity, SensorEntity): """Define an AccuWeather entity.""" - def __init__(self, name, kind, coordinator, forecast_day=None): + _attr_attribution = ATTRIBUTION + coordinator: AccuWeatherDataUpdateCoordinator + entity_description: AccuWeatherSensorDescription + + def __init__( + self, + name: str, + coordinator: AccuWeatherDataUpdateCoordinator, + description: AccuWeatherSensorDescription, + forecast_day: int | None = None, + ) -> None: """Initialize.""" super().__init__(coordinator) - self._name = name - self.kind = kind - self._device_class = None - self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" + self.entity_description = description + self._sensor_data = _get_sensor_data( + coordinator.data, forecast_day, description.key + ) + self._attrs: dict[str, Any] = {} + if forecast_day is not None: + self._attr_name = f"{name} {description.name} {forecast_day}d" + self._attr_unique_id = ( + f"{coordinator.location_key}-{description.key}-{forecast_day}".lower() + ) + else: + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = ( + f"{coordinator.location_key}-{description.key}".lower() + ) + if coordinator.is_metric: + self._unit_system = API_METRIC + self._attr_native_unit_of_measurement = description.unit_metric + else: + self._unit_system = API_IMPERIAL + self._attr_native_unit_of_measurement = description.unit_imperial + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.location_key)}, + manufacturer=MANUFACTURER, + name=NAME, + ) self.forecast_day = forecast_day @property - def name(self): - """Return the name.""" - if self.forecast_day is not None: - return f"{self._name} {FORECAST_SENSOR_TYPES[self.kind][ATTR_LABEL]} {self.forecast_day}d" - return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - if self.forecast_day is not None: - return f"{self.coordinator.location_key}-{self.kind}-{self.forecast_day}".lower() - return f"{self.coordinator.location_key}-{self.kind}".lower() - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.coordinator.location_key)}, - "name": NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def state(self): + def native_value(self) -> StateType: """Return the state.""" if self.forecast_day is not None: - if ( - FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - == DEVICE_CLASS_TEMPERATURE - ): - return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ - self.kind - ]["Value"] - if self.kind in ["WindDay", "WindNight", "WindGustDay", "WindGustNight"]: - return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ - self.kind - ]["Speed"]["Value"] - if self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: - return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ - self.kind - ]["Value"] - return self.coordinator.data[ATTR_FORECAST][self.forecast_day][self.kind] - if self.kind == "Ceiling": - return round(self.coordinator.data[self.kind][self._unit_system]["Value"]) - if self.kind == "PressureTendency": - return self.coordinator.data[self.kind]["LocalizedText"].lower() - if SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE: - return self.coordinator.data[self.kind][self._unit_system]["Value"] - if self.kind == "Precipitation": - return self.coordinator.data["PrecipitationSummary"][self.kind][ - self._unit_system - ]["Value"] - if self.kind in ["Wind", "WindGust"]: - return self.coordinator.data[self.kind]["Speed"][self._unit_system]["Value"] - return self.coordinator.data[self.kind] - - @property - def icon(self): - """Return the icon.""" - if self.forecast_day is not None: - return FORECAST_SENSOR_TYPES[self.kind][ATTR_ICON] - return SENSOR_TYPES[self.kind][ATTR_ICON] + if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: + return cast(float, self._sensor_data["Value"]) + if self.entity_description.key == "UVIndex": + return cast(int, self._sensor_data["Value"]) + if self.entity_description.key in ("Grass", "Mold", "Ragweed", "Tree", "Ozone"): + return cast(int, self._sensor_data["Value"]) + if self.entity_description.key == "Ceiling": + return round(self._sensor_data[self._unit_system]["Value"]) + if self.entity_description.key == "PressureTendency": + return cast(str, self._sensor_data["LocalizedText"].lower()) + if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: + return cast(float, self._sensor_data[self._unit_system]["Value"]) + if self.entity_description.key == "Precipitation": + return cast(float, self._sensor_data[self._unit_system]["Value"]) + if self.entity_description.key in ("Wind", "WindGust"): + return cast(float, self._sensor_data["Speed"][self._unit_system]["Value"]) + if self.entity_description.key in ( + "WindDay", + "WindNight", + "WindGustDay", + "WindGustNight", + ): + return cast(StateType, self._sensor_data["Speed"]["Value"]) + return cast(StateType, self._sensor_data) @property - def device_class(self): - """Return the device_class.""" - if self.forecast_day is not None: - return FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - if self.forecast_day is not None: - return FORECAST_SENSOR_TYPES[self.kind][self._unit_system] - return SENSOR_TYPES[self.kind][self._unit_system] - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.forecast_day is not None: - if self.kind in ["WindDay", "WindNight", "WindGustDay", "WindGustNight"]: - self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ - self.forecast_day - ][self.kind]["Direction"]["English"] - elif self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: - self._attrs["level"] = self.coordinator.data[ATTR_FORECAST][ - self.forecast_day - ][self.kind]["Category"] + if self.entity_description.key in ( + "WindDay", + "WindNight", + "WindGustDay", + "WindGustNight", + ): + self._attrs["direction"] = self._sensor_data["Direction"]["English"] + elif self.entity_description.key in ( + "Grass", + "Mold", + "Ozone", + "Ragweed", + "Tree", + "UVIndex", + ): + self._attrs["level"] = self._sensor_data["Category"] return self._attrs - if self.kind == "UVIndex": + if self.entity_description.key == "UVIndex": self._attrs["level"] = self.coordinator.data["UVIndexText"] - elif self.kind == "Precipitation": + elif self.entity_description.key == "Precipitation": self._attrs["type"] = self.coordinator.data["PrecipitationType"] return self._attrs - @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - return bool(self.kind not in OPTIONAL_SENSORS) + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._sensor_data = _get_sensor_data( + self.coordinator.data, self.forecast_day, self.entity_description.key + ) + self.async_write_ha_state() + + +def _get_sensor_data( + sensors: dict[str, Any], forecast_day: int | None, kind: str +) -> Any: + """Get sensor data.""" + if forecast_day is not None: + return sensors[ATTR_FORECAST][forecast_day][kind] + + if kind == "Precipitation": + return sensors["PrecipitationSummary"][kind] + + return sensors[kind] diff --git a/homeassistant/components/accuweather/system_health.py b/homeassistant/components/accuweather/system_health.py index 58c9ba35881ee..df1e607d15de3 100644 --- a/homeassistant/components/accuweather/system_health.py +++ b/homeassistant/components/accuweather/system_health.py @@ -1,10 +1,14 @@ """Provide info to system health.""" +from __future__ import annotations + +from typing import Any + from accuweather.const import ENDPOINT from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback -from .const import COORDINATOR, DOMAIN +from .const import DOMAIN @callback @@ -15,10 +19,10 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - remaining_requests = list(hass.data[DOMAIN].values())[0][ - COORDINATOR + remaining_requests = list(hass.data[DOMAIN].values())[ + 0 ].accuweather.requests_remaining return { diff --git a/homeassistant/components/accuweather/translations/ar.json b/homeassistant/components/accuweather/translations/ar.json new file mode 100644 index 0000000000000..0694e09601988 --- /dev/null +++ b/homeassistant/components/accuweather/translations/ar.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "requests_exceeded": "\u062a\u0645 \u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u0639\u062f\u062f \u0627\u0644\u0645\u0633\u0645\u0648\u062d \u0628\u0647 \u0645\u0646 \u0627\u0644\u0637\u0644\u0628\u0627\u062a \u0625\u0644\u0649 Accuweather API. \u0639\u0644\u064a\u0643 \u0627\u0644\u0627\u0646\u062a\u0638\u0627\u0631 \u0623\u0648 \u062a\u063a\u064a\u064a\u0631 \u0645\u0641\u062a\u0627\u062d API." + }, + "step": { + "user": { + "description": "\u0625\u0630\u0627 \u0643\u0646\u062a \u0628\u062d\u0627\u062c\u0629 \u0625\u0644\u0649 \u0645\u0633\u0627\u0639\u062f\u0629 \u0641\u064a \u0627\u0644\u062a\u0643\u0648\u064a\u0646 \u060c \u0641\u0642\u0645 \u0628\u0625\u0644\u0642\u0627\u0621 \u0646\u0638\u0631\u0629 \u0647\u0646\u0627: https://www.home-assistant.io/integrations/accuweather/ \n\n \u0644\u0627 \u064a\u062a\u0645 \u062a\u0645\u0643\u064a\u0646 \u0628\u0639\u0636 \u0623\u062c\u0647\u0632\u0629 \u0627\u0644\u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0628\u0634\u0643\u0644 \u0627\u0641\u062a\u0631\u0627\u0636\u064a. \u064a\u0645\u0643\u0646\u0643 \u062a\u0645\u0643\u064a\u0646\u0647\u0645 \u0641\u064a \u0633\u062c\u0644 \u0627\u0644\u0643\u064a\u0627\u0646 \u0628\u0639\u062f \u062a\u0643\u0648\u064a\u0646 \u0627\u0644\u062a\u0643\u0627\u0645\u0644.\n \u0644\u0627 \u064a\u062a\u0645 \u062a\u0645\u0643\u064a\u0646 \u062a\u0648\u0642\u0639\u0627\u062a \u0627\u0644\u0637\u0642\u0633 \u0627\u0641\u062a\u0631\u0627\u0636\u064a\u064b\u0627. \u064a\u0645\u0643\u0646\u0643 \u062a\u0645\u0643\u064a\u0646\u0647 \u0641\u064a \u062e\u064a\u0627\u0631\u0627\u062a \u0627\u0644\u062a\u0643\u0627\u0645\u0644.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u0627\u0644\u0646\u0634\u0631\u0629 \u0627\u0644\u062c\u0648\u064a\u0629" + }, + "description": "\u0646\u0638\u0631\u064b\u0627 \u0644\u0642\u064a\u0648\u062f \u0627\u0644\u0625\u0635\u062f\u0627\u0631 \u0627\u0644\u0645\u062c\u0627\u0646\u064a \u0645\u0646 \u0645\u0641\u062a\u0627\u062d AccuWeather API \u060c \u0639\u0646\u062f \u062a\u0645\u0643\u064a\u0646 \u0627\u0644\u062a\u0646\u0628\u0624 \u0628\u0627\u0644\u0637\u0642\u0633 \u060c \u0633\u064a\u062a\u0645 \u0625\u062c\u0631\u0627\u0621 \u062a\u062d\u062f\u064a\u062b\u0627\u062a \u0627\u0644\u0628\u064a\u0627\u0646\u0627\u062a \u0643\u0644 80 \u062f\u0642\u064a\u0642\u0629 \u0628\u062f\u0644\u0627\u064b \u0645\u0646 \u0643\u0644 40 \u062f\u0642\u064a\u0642\u0629.", + "title": "\u062e\u064a\u0627\u0631\u0627\u062a AccuWeather" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "\u0627\u0644\u0648\u0635\u0648\u0644 \u0625\u0644\u0649 \u062e\u0627\u062f\u0645 AccuWeather", + "remaining_requests": "\u0627\u0644\u0637\u0644\u0628\u0627\u062a \u0627\u0644\u0645\u062a\u0628\u0642\u064a\u0629 \u0627\u0644\u0645\u0633\u0645\u0648\u062d \u0628\u0647\u0627" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/bg.json b/homeassistant/components/accuweather/translations/bg.json new file mode 100644 index 0000000000000..b037c01144fb1 --- /dev/null +++ b/homeassistant/components/accuweather/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435" + }, + "title": "AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/de.json b/homeassistant/components/accuweather/translations/de.json index a9b23bacf6c17..17eb0ee31fcb1 100644 --- a/homeassistant/components/accuweather/translations/de.json +++ b/homeassistant/components/accuweather/translations/de.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", - "requests_exceeded": "Die zul\u00e4ssige Anzahl von Anforderungen an die Accuweather-API wurde \u00fcberschritten. Sie m\u00fcssen warten oder den API-Schl\u00fcssel \u00e4ndern." + "requests_exceeded": "Die zul\u00e4ssige Anzahl von Anforderungen an die Accuweather-API wurde \u00fcberschritten. Du musst warten oder den API-Schl\u00fcssel \u00e4ndern." }, "step": { "user": { diff --git a/homeassistant/components/accuweather/translations/es-419.json b/homeassistant/components/accuweather/translations/es-419.json index 92d5d5ef2c291..72d295da07373 100644 --- a/homeassistant/components/accuweather/translations/es-419.json +++ b/homeassistant/components/accuweather/translations/es-419.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave de API no v\u00e1lida", "requests_exceeded": "Se super\u00f3 el n\u00famero permitido de solicitudes a la API de Accuweather. Tiene que esperar o cambiar la clave de API." }, "step": { diff --git a/homeassistant/components/accuweather/translations/fr.json b/homeassistant/components/accuweather/translations/fr.json index a083ed09bdf6e..7c04e51da23bb 100644 --- a/homeassistant/components/accuweather/translations/fr.json +++ b/homeassistant/components/accuweather/translations/fr.json @@ -14,7 +14,7 @@ "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", - "name": "Nom de l'int\u00e9gration" + "name": "Nom" }, "description": "Si vous avez besoin d'aide pour la configuration, consultez le site suivant : https://www.home-assistant.io/integrations/accuweather/\n\nCertains capteurs ne sont pas activ\u00e9s par d\u00e9faut. Vous pouvez les activer dans le registre des entit\u00e9s apr\u00e8s la configuration de l'int\u00e9gration.\nLes pr\u00e9visions m\u00e9t\u00e9orologiques ne sont pas activ\u00e9es par d\u00e9faut. Vous pouvez l'activer dans les options d'int\u00e9gration.", "title": "AccuWeather" diff --git a/homeassistant/components/accuweather/translations/he.json b/homeassistant/components/accuweather/translations/he.json index 4c49313d97741..77c1e54f3e5c1 100644 --- a/homeassistant/components/accuweather/translations/he.json +++ b/homeassistant/components/accuweather/translations/he.json @@ -1,11 +1,41 @@ { "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "requests_exceeded": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05d7\u05e8\u05d9\u05d2\u05d4 \u05de\u05de\u05e1\u05e4\u05e8 \u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05d4\u05de\u05d5\u05ea\u05e8 \u05dc-API \u05e9\u05dc Accuweather. \u05e2\u05dc\u05d9\u05da \u05dc\u05d4\u05de\u05ea\u05d9\u05df \u05d0\u05d5 \u05dc\u05e9\u05e0\u05d5\u05ea \u05d0\u05ea \u05de\u05e4\u05ea\u05d7 \u05d4-API." + }, "step": { "user": { "data": { - "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" - } + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + }, + "description": "\u05d0\u05dd \u05d4\u05d9\u05e0\u05da \u05d6\u05e7\u05d5\u05e7 \u05dc\u05e2\u05d6\u05e8\u05d4 \u05e2\u05dd \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4, \u05d9\u05e9 \u05dc\u05e2\u05d9\u05d9\u05df \u05db\u05d0\u05df: https://www.home-assistant.io/integrations/accuweather/\n\n\u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05de\u05e1\u05d5\u05d9\u05de\u05d9\u05dd \u05d0\u05d9\u05e0\u05dd \u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e4\u05d5\u05da \u05d0\u05d5\u05ea\u05dd \u05dc\u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05d1\u05e8\u05d9\u05e9\u05d5\u05dd \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05dc\u05d0\u05d7\u05e8 \u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.\n\u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d4 \u05d6\u05de\u05d9\u05e0\u05d4 \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e4\u05d5\u05da \u05d0\u05d5\u05ea\u05d5 \u05dc\u05d6\u05de\u05d9\u05df \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.", + "title": "AccuWeather" } } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8" + }, + "description": "\u05d1\u05e9\u05dc \u05de\u05d2\u05d1\u05dc\u05d5\u05ea \u05d4\u05d2\u05d9\u05e8\u05e1\u05d4 \u05d4\u05d7\u05d9\u05e0\u05de\u05d9\u05ea \u05e9\u05dc \u05de\u05e4\u05ea\u05d7 \u05d4-API \u05e9\u05dc AccuWeather, \u05db\u05d0\u05e9\u05e8 \u05ea\u05e4\u05e2\u05d9\u05dc \u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8, \u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d9\u05d1\u05d5\u05e6\u05e2\u05d5 \u05db\u05dc 80 \u05d3\u05e7\u05d5\u05ea \u05d1\u05de\u05e7\u05d5\u05dd \u05db\u05dc 40 \u05d3\u05e7\u05d5\u05ea.", + "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea AccuWeather" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "\u05d4\u05e9\u05d2\u05ea \u05e9\u05e8\u05ea AccuWeather", + "remaining_requests": "\u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05d4\u05e0\u05d5\u05ea\u05e8\u05d5\u05ea \u05de\u05d5\u05ea\u05e8\u05d5\u05ea" + } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index 8a0f7f5a198f9..8b0409d1f221f 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs" + "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", + "requests_exceeded": "Accuweather API-hoz enged\u00e9lyezett lek\u00e9r\u00e9sek sz\u00e1ma t\u00fal lett l\u00e9pve. Meg kell v\u00e1rnia m\u00edg a tilt\u00e1s lej\u00e1r vagy m\u00f3dos\u00edtania kell az API-kulcsot." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, + "description": "Ha seg\u00edts\u00e9gre van sz\u00fcks\u00e9ge a konfigur\u00e1l\u00e1shoz, n\u00e9zze meg itt: https://www.home-assistant.io/integrations/accuweather/ \n\nEgyes \u00e9rz\u00e9kel\u0151k alap\u00e9rtelmez\u00e9s szerint nincsenek enged\u00e9lyezve. Az integr\u00e1ci\u00f3s konfigur\u00e1ci\u00f3 ut\u00e1n enged\u00e9lyezheti \u0151ket az entit\u00e1s-nyilv\u00e1ntart\u00e1sban.\nAz id\u0151j\u00e1r\u00e1s-el\u0151rejelz\u00e9s alap\u00e9rtelmez\u00e9s szerint nincs enged\u00e9lyezve. Ezt az integr\u00e1ci\u00f3s be\u00e1ll\u00edt\u00e1sokban enged\u00e9lyezheti.", "title": "AccuWeather" } } @@ -22,8 +24,18 @@ "options": { "step": { "user": { + "data": { + "forecast": "Id\u0151j\u00e1r\u00e1s el\u0151rejelz\u00e9s" + }, + "description": "Az AccuWeather API kulcs ingyenes verzi\u00f3j\u00e1nak korl\u00e1tai miatt, amikor enged\u00e9lyezi az id\u0151j\u00e1r\u00e1s -el\u0151rejelz\u00e9st, az adatfriss\u00edt\u00e9seket 40 percenk\u00e9nt 80 percenk\u00e9nt hajtj\u00e1k v\u00e9gre.", "title": "AccuWeather be\u00e1ll\u00edt\u00e1sok" } } + }, + "system_health": { + "info": { + "can_reach_server": "\u00c9rje el az AccuWeather szervert", + "remaining_requests": "Fennmarad\u00f3 enged\u00e9lyezett k\u00e9r\u00e9sek" + } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/ja.json b/homeassistant/components/accuweather/translations/ja.json new file mode 100644 index 0000000000000..10fe398ba04d4 --- /dev/null +++ b/homeassistant/components/accuweather/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "requests_exceeded": "Accuweather API\u3078\u306e\u30ea\u30af\u30a8\u30b9\u30c8\u6570\u304c\u8a31\u53ef\u3055\u308c\u305f\u6570\u3092\u8d85\u3048\u307e\u3057\u305f\u3002\u6642\u9593\u3092\u7f6e\u304f\u304b\u3001API\u30ad\u30fc\u3092\u5909\u66f4\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "\u8a2d\u5b9a\u306b\u3064\u3044\u3066\u30d8\u30eb\u30d7\u304c\u5fc5\u8981\u306a\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/accuweather/ \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\n\n\u4e00\u90e8\u306e\u30bb\u30f3\u30b5\u30fc\u306f\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u305b\u3093\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306e\u8a2d\u5b9a\u5f8c\u306b\u3001\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u30ec\u30b8\u30b9\u30c8\u30ea\u3067\u6709\u52b9\u306b\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002\n\u5929\u6c17\u4e88\u5831\u306f\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u305b\u3093\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u6709\u52b9\u306b\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u5929\u6c17\u4e88\u5831" + }, + "description": "\u5236\u9650\u306b\u3088\u308a\u7121\u6599\u30d0\u30fc\u30b8\u30e7\u30f3\u306eAccuWeather API\u30ad\u30fc\u3067\u306f\u3001\u5929\u6c17\u4e88\u5831\u3092\u6709\u52b9\u306b\u3057\u3066\u3082\u30c7\u30fc\u30bf\u306e\u66f4\u65b0\u306f40\u5206\u3067\u306f\u306a\u304f80\u5206\u3054\u3068\u3067\u3059\u3002", + "title": "AccuWeather\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "AccuWeather\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u30a2\u30af\u30bb\u30b9", + "remaining_requests": "\u6b8b\u308a\u306e\u8a31\u53ef\u3055\u308c\u305f\u30ea\u30af\u30a8\u30b9\u30c8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.ar.json b/homeassistant/components/accuweather/translations/sensor.ar.json new file mode 100644 index 0000000000000..948bd1c95a21d --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u0647\u0628\u0648\u0637", + "rising": "\u0627\u0631\u062a\u0641\u0627\u0639", + "steady": "\u062b\u0627\u0628\u062a" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.he.json b/homeassistant/components/accuweather/translations/sensor.he.json new file mode 100644 index 0000000000000..08c637f1ce13b --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u05d9\u05d5\u05e8\u05d3", + "rising": "\u05e2\u05d5\u05dc\u05d4", + "steady": "\u05d9\u05e6\u05d9\u05d1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.ja.json b/homeassistant/components/accuweather/translations/sensor.ja.json new file mode 100644 index 0000000000000..9db8f685dfe43 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u4e0b\u964d", + "rising": "\u4e0a\u6607", + "steady": "\u5b89\u5b9a" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.tr.json b/homeassistant/components/accuweather/translations/sensor.tr.json new file mode 100644 index 0000000000000..53c7067062a10 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "D\u00fc\u015f\u00fcyor", + "rising": "Y\u00fckseliyor", + "steady": "Sabit" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/tr.json b/homeassistant/components/accuweather/translations/tr.json index f79f9a0e3270e..7b0fa476458aa 100644 --- a/homeassistant/components/accuweather/translations/tr.json +++ b/homeassistant/components/accuweather/translations/tr.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "requests_exceeded": "Accuweather API i\u00e7in izin verilen istek say\u0131s\u0131 a\u015f\u0131ld\u0131. API Anahtar\u0131n\u0131 beklemeniz veya de\u011fi\u015ftirmeniz gerekir." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "Boylam", "name": "Ad" }, + "description": "Yap\u0131land\u0131rmayla ilgili yard\u0131ma ihtiyac\u0131n\u0131z varsa buraya bak\u0131n: https://www.home-assistant.io/integrations/accuweather/ \n\n Baz\u0131 sens\u00f6rler varsay\u0131lan olarak etkin de\u011fildir. Bunlar\u0131, entegrasyon yap\u0131land\u0131rmas\u0131ndan sonra varl\u0131k kay\u0131t defterinde etkinle\u015ftirebilirsiniz.\n Hava tahmini varsay\u0131lan olarak etkin de\u011fildir. Entegrasyon se\u00e7eneklerinde etkinle\u015ftirebilirsiniz.", "title": "AccuWeather" } } @@ -25,6 +27,7 @@ "data": { "forecast": "Hava Durumu tahmini" }, + "description": "AccuWeather API anahtar\u0131n\u0131n \u00fccretsiz s\u00fcr\u00fcm\u00fcn\u00fcn s\u0131n\u0131rlamalar\u0131 nedeniyle, hava tahminini etkinle\u015ftirdi\u011finizde, veri g\u00fcncellemeleri her 40 dakikada bir yerine 80 dakikada bir ger\u00e7ekle\u015ftirilir.", "title": "AccuWeather Se\u00e7enekleri" } } diff --git a/homeassistant/components/accuweather/translations/zh-Hant.json b/homeassistant/components/accuweather/translations/zh-Hant.json index eb3729fd2c495..11df415d4c963 100644 --- a/homeassistant/components/accuweather/translations/zh-Hant.json +++ b/homeassistant/components/accuweather/translations/zh-Hant.json @@ -5,18 +5,18 @@ }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", - "requests_exceeded": "\u5df2\u8d85\u904e Accuweather API \u5141\u8a31\u7684\u8acb\u6c42\u6b21\u6578\u3002\u5fc5\u9808\u7b49\u5019\u6216\u8b8a\u66f4 API \u5bc6\u9470\u3002" + "invalid_api_key": "API \u91d1\u9470\u7121\u6548", + "requests_exceeded": "\u5df2\u8d85\u904e Accuweather API \u5141\u8a31\u7684\u8acb\u6c42\u6b21\u6578\u3002\u5fc5\u9808\u7b49\u5019\u6216\u8b8a\u66f4 API \u91d1\u9470\u3002" }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6", "name": "\u540d\u7a31" }, - "description": "\u5047\u5982\u4f60\u9700\u8981\u5354\u52a9\u9032\u884c\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1\uff1ahttps://www.home-assistant.io/integrations/accuweather/\n\n\u67d0\u4e9b\u50b3\u611f\u5668\u9810\u8a2d\u70ba\u672a\u555f\u7528\uff0c\u53ef\u4ee5\u65bc\u6574\u5408\u8a2d\u5b9a\u4e2d\u555f\u7528\u9019\u4e9b\u5be6\u9ad4\u3002\u5929\u6c23\u9810\u5831\u9810\u8a2d\u672a\u958b\u555f\u3002\u53ef\u4ee5\u65bc\u6574\u5408\u9078\u9805\u4e2d\u958b\u555f\u3002", + "description": "\u5047\u5982\u4f60\u9700\u8981\u5354\u52a9\u9032\u884c\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1\uff1ahttps://www.home-assistant.io/integrations/accuweather/\n\n\u67d0\u4e9b\u611f\u6e2c\u5668\u9810\u8a2d\u70ba\u672a\u555f\u7528\uff0c\u53ef\u4ee5\u65bc\u6574\u5408\u8a2d\u5b9a\u4e2d\u555f\u7528\u9019\u4e9b\u5be6\u9ad4\u3002\u5929\u6c23\u9810\u5831\u9810\u8a2d\u672a\u958b\u555f\u3002\u53ef\u4ee5\u65bc\u6574\u5408\u9078\u9805\u4e2d\u958b\u555f\u3002", "title": "AccuWeather" } } @@ -27,7 +27,7 @@ "data": { "forecast": "\u5929\u6c23\u9810\u5831" }, - "description": "\u7531\u65bc AccuWeather API \u5bc6\u9470\u514d\u8cbb\u7248\u672c\u9650\u5236\uff0c\u7576\u958b\u555f\u5929\u6c23\u9810\u5831\u6642\u3001\u6578\u64da\u6703\u6bcf 80 \u5206\u9418\u66f4\u65b0\u4e00\u6b21\uff0c\u800c\u975e 40 \u5206\u9418\u3002", + "description": "\u7531\u65bc AccuWeather API \u91d1\u9470\u514d\u8cbb\u7248\u672c\u9650\u5236\uff0c\u7576\u958b\u555f\u5929\u6c23\u9810\u5831\u6642\u3001\u6578\u64da\u6703\u6bcf 80 \u5206\u9418\u66f4\u65b0\u4e00\u6b21\uff0c\u800c\u975e 40 \u5206\u9418\u3002", "title": "AccuWeather \u9078\u9805" } } diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 3c0dcfedf43f0..c97cc44aea3ed 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -1,5 +1,8 @@ """Support for the AccuWeather service.""" +from __future__ import annotations + from statistics import mean +from typing import Any, cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -10,17 +13,25 @@ ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, + Forecast, WeatherEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp +from . import AccuWeatherDataUpdateCoordinator from .const import ( + API_IMPERIAL, + API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, - COORDINATOR, DOMAIN, MANUFACTURER, NAME, @@ -29,52 +40,49 @@ PARALLEL_UPDATES = 1 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Add a AccuWeather weather entity from a config_entry.""" - name = config_entry.data[CONF_NAME] + name: str = entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([AccuWeatherEntity(name, coordinator)], False) + async_add_entities([AccuWeatherEntity(name, coordinator)]) class AccuWeatherEntity(CoordinatorEntity, WeatherEntity): """Define an AccuWeather entity.""" - def __init__(self, name, coordinator): + coordinator: AccuWeatherDataUpdateCoordinator + + def __init__( + self, name: str, coordinator: AccuWeatherDataUpdateCoordinator + ) -> None: """Initialize.""" super().__init__(coordinator) - self._name = name - self._attrs = {} - self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION + self._unit_system = API_METRIC if coordinator.is_metric else API_IMPERIAL + self._attr_name = name + self._attr_unique_id = coordinator.location_key + self._attr_temperature_unit = ( + TEMP_CELSIUS if coordinator.is_metric else TEMP_FAHRENHEIT + ) + self._attr_attribution = ATTRIBUTION + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.location_key)}, + manufacturer=MANUFACTURER, + name=NAME, + # You don't need to provide specific details for the URL, + # so passing in _ characters is fine if the location key + # is correct + configuration_url="http://accuweather.com/en/" + f"_/_/{coordinator.location_key}/" + f"weather-forecast/{coordinator.location_key}/", + ) @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self.coordinator.location_key - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.coordinator.location_key)}, - "name": NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" try: return [ @@ -86,57 +94,60 @@ def condition(self): return None @property - def temperature(self): + def temperature(self) -> float: """Return the temperature.""" - return self.coordinator.data["Temperature"][self._unit_system]["Value"] - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS if self.coordinator.is_metric else TEMP_FAHRENHEIT + return cast( + float, self.coordinator.data["Temperature"][self._unit_system]["Value"] + ) @property - def pressure(self): + def pressure(self) -> float: """Return the pressure.""" - return self.coordinator.data["Pressure"][self._unit_system]["Value"] + return cast( + float, self.coordinator.data["Pressure"][self._unit_system]["Value"] + ) @property - def humidity(self): + def humidity(self) -> int: """Return the humidity.""" - return self.coordinator.data["RelativeHumidity"] + return cast(int, self.coordinator.data["RelativeHumidity"]) @property - def wind_speed(self): + def wind_speed(self) -> float: """Return the wind speed.""" - return self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] + return cast( + float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] + ) @property - def wind_bearing(self): + def wind_bearing(self) -> int: """Return the wind bearing.""" - return self.coordinator.data["Wind"]["Direction"]["Degrees"] + return cast(int, self.coordinator.data["Wind"]["Direction"]["Degrees"]) @property - def visibility(self): + def visibility(self) -> float: """Return the visibility.""" - return self.coordinator.data["Visibility"][self._unit_system]["Value"] + return cast( + float, self.coordinator.data["Visibility"][self._unit_system]["Value"] + ) @property - def ozone(self): + def ozone(self) -> int | None: """Return the ozone level.""" # We only have ozone data for certain locations and only in the forecast data. if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get( "Ozone" ): - return self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"] + return cast(int, self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"]) return None @property - def forecast(self): + def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" if not self.coordinator.forecast: return None # remap keys from library to keys understood by the weather component - forecast = [ + return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], @@ -158,10 +169,9 @@ def forecast(self): } for item in self.coordinator.data[ATTR_FORECAST] ] - return forecast @staticmethod - def _calc_precipitation(day: dict) -> float: + def _calc_precipitation(day: dict[str, Any]) -> float: """Return sum of the precipitation.""" precip_sum = 0 precip_types = ["Rain", "Snow", "Ice"] diff --git a/homeassistant/components/acer_projector/const.py b/homeassistant/components/acer_projector/const.py new file mode 100644 index 0000000000000..98864ab957fa4 --- /dev/null +++ b/homeassistant/components/acer_projector/const.py @@ -0,0 +1,34 @@ +"""Use serial protocol of Acer projector to obtain state of the projector.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.const import STATE_OFF, STATE_ON + +CONF_WRITE_TIMEOUT: Final = "write_timeout" + +DEFAULT_NAME: Final = "Acer Projector" +DEFAULT_TIMEOUT: Final = 1 +DEFAULT_WRITE_TIMEOUT: Final = 1 + +ECO_MODE: Final = "ECO Mode" + +ICON: Final = "mdi:projector" + +INPUT_SOURCE: Final = "Input Source" + +LAMP: Final = "Lamp" +LAMP_HOURS: Final = "Lamp Hours" + +MODEL: Final = "Model" + +# Commands known to the projector +CMD_DICT: Final[dict[str, str]] = { + LAMP: "* 0 Lamp ?\r", + LAMP_HOURS: "* 0 Lamp\r", + INPUT_SOURCE: "* 0 Src ?\r", + ECO_MODE: "* 0 IR 052\r", + MODEL: "* 0 IR 035\r", + STATE_ON: "* 0 IR 001\r", + STATE_OFF: "* 0 IR 002\r", +} diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index 4a61ec793dbb8..f3e4b3b03e5eb 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -1,6 +1,9 @@ """Use serial protocol of Acer projector to obtain state of the projector.""" +from __future__ import annotations + import logging import re +from typing import Any import serial import voluptuous as vol @@ -14,39 +17,26 @@ STATE_ON, STATE_UNKNOWN, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import ( + CMD_DICT, + CONF_WRITE_TIMEOUT, + DEFAULT_NAME, + DEFAULT_TIMEOUT, + DEFAULT_WRITE_TIMEOUT, + ECO_MODE, + ICON, + INPUT_SOURCE, + LAMP, + LAMP_HOURS, +) _LOGGER = logging.getLogger(__name__) -CONF_WRITE_TIMEOUT = "write_timeout" - -DEFAULT_NAME = "Acer Projector" -DEFAULT_TIMEOUT = 1 -DEFAULT_WRITE_TIMEOUT = 1 - -ECO_MODE = "ECO Mode" - -ICON = "mdi:projector" - -INPUT_SOURCE = "Input Source" - -LAMP = "Lamp" -LAMP_HOURS = "Lamp Hours" - -MODEL = "Model" - -# Commands known to the projector -CMD_DICT = { - LAMP: "* 0 Lamp ?\r", - LAMP_HOURS: "* 0 Lamp\r", - INPUT_SOURCE: "* 0 Src ?\r", - ECO_MODE: "* 0 IR 052\r", - MODEL: "* 0 IR 035\r", - STATE_ON: "* 0 IR 001\r", - STATE_OFF: "* 0 IR 002\r", -} - - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_FILENAME): cv.isdevice, @@ -59,7 +49,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType, +) -> None: """Connect with serial port and return Acer Projector.""" serial_port = config[CONF_FILENAME] name = config[CONF_NAME] @@ -72,22 +67,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AcerSwitch(SwitchEntity): """Represents an Acer Projector as a switch.""" - def __init__(self, serial_port, name, timeout, write_timeout, **kwargs): + _attr_icon = ICON + + def __init__( + self, + serial_port: str, + name: str, + timeout: int, + write_timeout: int, + ) -> None: """Init of the Acer projector.""" self.ser = serial.Serial( - port=serial_port, timeout=timeout, write_timeout=write_timeout, **kwargs + port=serial_port, timeout=timeout, write_timeout=write_timeout ) self._serial_port = serial_port - self._name = name - self._state = False - self._available = False + self._attr_name = name self._attributes = { LAMP_HOURS: STATE_UNKNOWN, INPUT_SOURCE: STATE_UNKNOWN, ECO_MODE: STATE_UNKNOWN, } - def _write_read(self, msg): + def _write_read(self, msg: str) -> str: """Write to the projector and read the return.""" ret = "" # Sometimes the projector won't answer for no reason or the projector @@ -96,8 +97,7 @@ def _write_read(self, msg): try: if not self.ser.is_open: self.ser.open() - msg = msg.encode("utf-8") - self.ser.write(msg) + self.ser.write(msg.encode("utf-8")) # Size is an experience value there is no real limit. # AFAIK there is no limit and no end character so we will usually # need to wait for timeout @@ -107,62 +107,40 @@ def _write_read(self, msg): self.ser.close() return ret - def _write_read_format(self, msg): + def _write_read_format(self, msg: str) -> str: """Write msg, obtain answer and format output.""" # answers are formatted as ***\answer\r*** awns = self._write_read(msg) - match = re.search(r"\r(.+)\r", awns) - if match: + if match := re.search(r"\r(.+)\r", awns): return match.group(1) return STATE_UNKNOWN - @property - def available(self): - """Return if projector is available.""" - return self._available - - @property - def name(self): - """Return name of the projector.""" - return self._name - - @property - def is_on(self): - """Return if the projector is turned on.""" - return self._state - - @property - def extra_state_attributes(self): - """Return state attributes.""" - return self._attributes - - def update(self): + def update(self) -> None: """Get the latest state from the projector.""" - msg = CMD_DICT[LAMP] - awns = self._write_read_format(msg) + awns = self._write_read_format(CMD_DICT[LAMP]) if awns == "Lamp 1": - self._state = True - self._available = True + self._attr_is_on = True + self._attr_available = True elif awns == "Lamp 0": - self._state = False - self._available = True + self._attr_is_on = False + self._attr_available = True else: - self._available = False + self._attr_available = False for key in self._attributes: - msg = CMD_DICT.get(key) - if msg: + if msg := CMD_DICT.get(key): awns = self._write_read_format(msg) self._attributes[key] = awns + self._attr_extra_state_attributes = self._attributes - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the projector on.""" msg = CMD_DICT[STATE_ON] self._write_read(msg) - self._state = STATE_ON + self._attr_is_on = True - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the projector off.""" msg = CMD_DICT[STATE_OFF] self._write_read(msg) - self._state = STATE_OFF + self._attr_is_on = False diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index 078c499f2be9b..49b4d9b85e8dd 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -1,13 +1,14 @@ """The Rollease Acmeda Automate integration.""" from homeassistant import config_entries, core +from homeassistant.const import Platform from .const import DOMAIN from .hub import PulseHub CONF_HUBS = "hubs" -PLATFORMS = ["cover", "sensor"] +PLATFORMS = [Platform.COVER, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 15f9716db47ef..3338bf9667d75 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -13,7 +13,7 @@ class AcmedaBase(entity.Entity): """Base representation of an Acmeda roller.""" - def __init__(self, roller: aiopulse.Roller): + def __init__(self, roller: aiopulse.Roller) -> None: """Initialize the roller.""" self.roller = roller @@ -77,11 +77,11 @@ def name(self): return self.roller.name @property - def device_info(self): + def device_info(self) -> entity.DeviceInfo: """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.roller.name, - "manufacturer": "Rollease Acmeda", - "via_device": (DOMAIN, self.roller.hub.id), - } + return entity.DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Rollease Acmeda", + name=self.roller.name, + via_device=(DOMAIN, self.roller.hub.id), + ) diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index 1f288e84bc704..1db629e506aca 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_ID from .const import DOMAIN @@ -27,9 +28,9 @@ async def async_step_user(self, user_input=None): if ( user_input is not None and self.discovered_hubs is not None - and user_input["id"] in self.discovered_hubs + and user_input[CONF_ID] in self.discovered_hubs ): - return await self.async_create(self.discovered_hubs[user_input["id"]]) + return await self.async_create(self.discovered_hubs[user_input[CONF_ID]]) # Already configured hosts already_configured = { @@ -55,7 +56,7 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=vol.Schema( { - vol.Required("id"): vol.In( + vol.Required(CONF_ID): vol.In( {hub.id: f"{hub.id} {hub.host}" for hub in hubs} ) } @@ -65,4 +66,4 @@ async def async_step_user(self, user_input=None): async def async_create(self, hub): """Create the Acmeda Hub entry.""" await self.async_set_unique_id(hub.id, raise_on_progress=False) - return self.async_create_entry(title=hub.id, data={"host": hub.host}) + return self.async_create_entry(title=hub.id, data={CONF_HOST: hub.host}) diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 82c61202cd3ac..6c1de528abe32 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -61,7 +61,7 @@ def current_cover_tilt_position(self): None is unknown, 0 is closed, 100 is fully open. """ position = None - if self.roller.type in [7, 10]: + if self.roller.type in (7, 10): position = 100 - self.roller.closed_percent return position diff --git a/homeassistant/components/acmeda/manifest.json b/homeassistant/components/acmeda/manifest.json index ae72df5a32329..6313b177f4765 100644 --- a/homeassistant/components/acmeda/manifest.json +++ b/homeassistant/components/acmeda/manifest.json @@ -3,7 +3,7 @@ "name": "Rollease Acmeda Automate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/acmeda", - "requirements": ["aiopulse==0.4.2"], + "requirements": ["aiopulse==0.4.3"], "codeowners": ["@atmurray"], "iot_class": "local_push" } diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index 4f617c5726fdb..57e5b50bd1fec 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -1,6 +1,6 @@ """Support for Acmeda Roller Blind Batteries.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -33,8 +33,8 @@ def async_add_acmeda_sensors(): class AcmedaBattery(AcmedaBase, SensorEntity): """Representation of a Acmeda cover device.""" - device_class = DEVICE_CLASS_BATTERY - unit_of_measurement = PERCENTAGE + device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE @property def name(self): @@ -42,6 +42,6 @@ def name(self): return f"{super().name} Battery" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.roller.battery diff --git a/homeassistant/components/acmeda/translations/he.json b/homeassistant/components/acmeda/translations/he.json new file mode 100644 index 0000000000000..498f322a7b0dd --- /dev/null +++ b/homeassistant/components/acmeda/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" + }, + "step": { + "user": { + "data": { + "id": "\u05de\u05d6\u05d4\u05d4 \u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/acmeda/translations/hu.json b/homeassistant/components/acmeda/translations/hu.json index 6105977de80fd..f302995e7e977 100644 --- a/homeassistant/components/acmeda/translations/hu.json +++ b/homeassistant/components/acmeda/translations/hu.json @@ -2,6 +2,14 @@ "config": { "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "step": { + "user": { + "data": { + "id": "Gazdag\u00e9p azonos\u00edt\u00f3" + }, + "title": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt hubot" + } } } } \ No newline at end of file diff --git a/homeassistant/components/acmeda/translations/it.json b/homeassistant/components/acmeda/translations/it.json index 8592e6cc8da98..8b5ea51230ece 100644 --- a/homeassistant/components/acmeda/translations/it.json +++ b/homeassistant/components/acmeda/translations/it.json @@ -8,7 +8,7 @@ "data": { "id": "ID host" }, - "title": "Scegliere un hub da aggiungere" + "title": "Scegli un hub da aggiungere" } } } diff --git a/homeassistant/components/acmeda/translations/ja.json b/homeassistant/components/acmeda/translations/ja.json new file mode 100644 index 0000000000000..83eb75daebf83 --- /dev/null +++ b/homeassistant/components/acmeda/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "id": "\u30db\u30b9\u30c8ID" + }, + "title": "\u8ffd\u52a0\u3059\u308b\u30cf\u30d6\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/acmeda/translations/tr.json b/homeassistant/components/acmeda/translations/tr.json index aea81abdcba0c..3a870463feb35 100644 --- a/homeassistant/components/acmeda/translations/tr.json +++ b/homeassistant/components/acmeda/translations/tr.json @@ -1,10 +1,14 @@ { "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + }, "step": { "user": { "data": { "id": "Ana bilgisayar kimli\u011fi" - } + }, + "title": "Eklemek i\u00e7in bir merkez se\u00e7in" } } } diff --git a/homeassistant/components/actiontec/const.py b/homeassistant/components/actiontec/const.py new file mode 100644 index 0000000000000..de309b6847605 --- /dev/null +++ b/homeassistant/components/actiontec/const.py @@ -0,0 +1,14 @@ +"""Support for Actiontec MI424WR (Verizon FIOS) routers.""" +from __future__ import annotations + +import re +from typing import Final + +# mypy: disallow-any-generics + +LEASES_REGEX: Final[re.Pattern[str]] = re.compile( + r"(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})" + + r"\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))" + + r"\svalid\sfor:\s(?P(-?\d+))" + + r"\ssec" +) diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index c88ed546b9d06..cc26c191c8c2c 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -1,30 +1,28 @@ """Support for Actiontec MI424WR (Verizon FIOS) routers.""" -from collections import namedtuple +from __future__ import annotations + import logging -import re import telnetlib +from typing import Final import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) +from .const import LEASES_REGEX +from .model import Device -_LEASES_REGEX = re.compile( - r"(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})" - + r"\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))" - + r"\svalid\sfor:\s(?P(-?\d+))" - + r"\ssec" -) +_LOGGER: Final = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -33,43 +31,38 @@ ) -def get_scanner(hass, config): +def get_scanner(hass: HomeAssistant, config: ConfigType) -> DeviceScanner | None: """Validate the configuration and return an Actiontec scanner.""" scanner = ActiontecDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None -Device = namedtuple("Device", ["mac", "ip", "last_update"]) - - class ActiontecDeviceScanner(DeviceScanner): """This class queries an actiontec router for connected devices.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.last_results = [] + self.host: str = config[CONF_HOST] + self.username: str = config[CONF_USERNAME] + self.password: str = config[CONF_PASSWORD] + self.last_results: list[Device] = [] data = self.get_actiontec_data() self.success_init = data is not None _LOGGER.info("Scanner initialized") - def scan_devices(self): + def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client.mac for client in self.last_results] + return [client.mac_address for client in self.last_results] - def get_device_name(self, device): + def get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" - if not self.last_results: - return None for client in self.last_results: - if client.mac == device: - return client.ip + if client.mac_address == device: + return client.ip_address return None - def _update_info(self): + def _update_info(self) -> bool: """Ensure the information from the router is up to date. Return boolean if scanning successful. @@ -78,19 +71,15 @@ def _update_info(self): if not self.success_init: return False - now = dt_util.now() - actiontec_data = self.get_actiontec_data() - if not actiontec_data: + if (actiontec_data := self.get_actiontec_data()) is None: return False self.last_results = [ - Device(data["mac"], name, now) - for name, data in actiontec_data.items() - if data["timevalid"] > -60 + device for device in actiontec_data if device.timevalid > -60 ] _LOGGER.info("Scan successful") return True - def get_actiontec_data(self): + def get_actiontec_data(self) -> list[Device] | None: """Retrieve data from Actiontec MI424WR and return parsed result.""" try: telnet = telnetlib.Telnet(self.host) @@ -106,18 +95,20 @@ def get_actiontec_data(self): telnet.write(b"exit\n") except EOFError: _LOGGER.exception("Unexpected response from router") - return + return None except ConnectionRefusedError: _LOGGER.exception("Connection refused by router. Telnet enabled?") return None - devices = {} + devices: list[Device] = [] for lease in leases_result: - match = _LEASES_REGEX.search(lease.decode("utf-8")) + match = LEASES_REGEX.search(lease.decode("utf-8")) if match is not None: - devices[match.group("ip")] = { - "ip": match.group("ip"), - "mac": match.group("mac").upper(), - "timevalid": int(match.group("timevalid")), - } + devices.append( + Device( + match.group("ip"), + match.group("mac").upper(), + int(match.group("timevalid")), + ) + ) return devices diff --git a/homeassistant/components/actiontec/model.py b/homeassistant/components/actiontec/model.py new file mode 100644 index 0000000000000..ff28d6d4ac640 --- /dev/null +++ b/homeassistant/components/actiontec/model.py @@ -0,0 +1,11 @@ +"""Model definitions for Actiontec MI424WR (Verizon FIOS) routers.""" +from dataclasses import dataclass + + +@dataclass +class Device: + """Actiontec device class.""" + + ip_address: str + mac_address: str + timevalid: int diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py new file mode 100644 index 0000000000000..bf339e810c682 --- /dev/null +++ b/homeassistant/components/adax/__init__.py @@ -0,0 +1,19 @@ +"""The Adax integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Adax from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py new file mode 100644 index 0000000000000..48cbc9b270c76 --- /dev/null +++ b/homeassistant/components/adax/climate.py @@ -0,0 +1,159 @@ +"""Support for Adax wifi-enabled home heaters.""" +from __future__ import annotations + +from typing import Any + +from adax import Adax +from adax_local import Adax as AdaxLocal + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, + PRECISION_WHOLE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Adax thermostat with config flow.""" + if entry.data.get(CONNECTION_TYPE) == LOCAL: + adax_data_handler = AdaxLocal( + entry.data[CONF_IP_ADDRESS], + entry.data[CONF_TOKEN], + websession=async_get_clientsession(hass, verify_ssl=False), + ) + async_add_entities( + [LocalAdaxDevice(adax_data_handler, entry.data[CONF_UNIQUE_ID])], True + ) + return + + adax_data_handler = Adax( + entry.data[ACCOUNT_ID], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + async_add_entities( + ( + AdaxDevice(room, adax_data_handler) + for room in await adax_data_handler.get_rooms() + ), + True, + ) + + +class AdaxDevice(ClimateEntity): + """Representation of a heater.""" + + _attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + _attr_max_temp = 35 + _attr_min_temp = 5 + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: + """Initialize the heater.""" + self._device_id = heater_data["id"] + self._adax_data_handler = adax_data_handler + + self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, heater_data["id"])}, + name=self.name, + manufacturer="Adax", + ) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + temperature = max(self.min_temp, self.target_temperature or self.min_temp) + await self._adax_data_handler.set_room_target_temperature( + self._device_id, temperature, True + ) + elif hvac_mode == HVAC_MODE_OFF: + await self._adax_data_handler.set_room_target_temperature( + self._device_id, self.min_temp, False + ) + else: + return + await self._adax_data_handler.update() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._adax_data_handler.set_room_target_temperature( + self._device_id, temperature, True + ) + + async def async_update(self) -> None: + """Get the latest data.""" + for room in await self._adax_data_handler.get_rooms(): + if room["id"] != self._device_id: + continue + self._attr_name = room["name"] + self._attr_current_temperature = room.get("temperature") + self._attr_target_temperature = room.get("targetTemperature") + if room["heatingEnabled"]: + self._attr_hvac_mode = HVAC_MODE_HEAT + self._attr_icon = "mdi:radiator" + else: + self._attr_hvac_mode = HVAC_MODE_OFF + self._attr_icon = "mdi:radiator-off" + return + + +class LocalAdaxDevice(ClimateEntity): + """Representation of a heater.""" + + _attr_hvac_modes = [HVAC_MODE_HEAT] + _attr_hvac_mode = HVAC_MODE_HEAT + _attr_max_temp = 35 + _attr_min_temp = 5 + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, adax_data_handler, unique_id): + """Initialize the heater.""" + self._adax_data_handler = adax_data_handler + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Adax", + ) + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._adax_data_handler.set_target_temperature(temperature) + + async def async_update(self) -> None: + """Get the latest data.""" + data = await self._adax_data_handler.get_status() + self._attr_target_temperature = data["target_temperature"] + self._attr_current_temperature = data["current_temperature"] + self._attr_available = self._attr_current_temperature is not None diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py new file mode 100644 index 0000000000000..e392125842660 --- /dev/null +++ b/homeassistant/components/adax/config_flow.py @@ -0,0 +1,144 @@ +"""Config flow for Adax integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import adax +import adax_local +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + ACCOUNT_ID, + CLOUD, + CONNECTION_TYPE, + DOMAIN, + LOCAL, + WIFI_PSWD, + WIFI_SSID, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Adax.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required(CONNECTION_TYPE, default=CLOUD): vol.In( + ( + CLOUD, + LOCAL, + ) + ) + } + ) + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + if user_input[CONNECTION_TYPE] == LOCAL: + return await self.async_step_local() + return await self.async_step_cloud() + + async def async_step_local(self, user_input=None): + """Handle the local step.""" + data_schema = vol.Schema( + {vol.Required(WIFI_SSID): str, vol.Required(WIFI_PSWD): str} + ) + if user_input is None: + return self.async_show_form( + step_id="local", + data_schema=data_schema, + ) + + wifi_ssid = user_input[WIFI_SSID].replace(" ", "") + wifi_pswd = user_input[WIFI_PSWD].replace(" ", "") + configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd) + + try: + device_configured = await configurator.configure_device() + except adax_local.HeaterNotAvailable: + return self.async_abort(reason="heater_not_available") + except adax_local.HeaterNotFound: + return self.async_abort(reason="heater_not_found") + except adax_local.InvalidWifiCred: + return self.async_abort(reason="invalid_auth") + + if not device_configured: + return self.async_show_form( + step_id="local", + data_schema=data_schema, + errors={"base": "cannot_connect"}, + ) + + unique_id = configurator.mac_id + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data={ + CONF_IP_ADDRESS: configurator.device_ip, + CONF_TOKEN: configurator.access_token, + CONF_UNIQUE_ID: unique_id, + CONNECTION_TYPE: LOCAL, + }, + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the cloud step.""" + data_schema = vol.Schema( + {vol.Required(ACCOUNT_ID): int, vol.Required(CONF_PASSWORD): str} + ) + if user_input is None: + return self.async_show_form(step_id="cloud", data_schema=data_schema) + + errors = {} + + await self.async_set_unique_id(user_input[ACCOUNT_ID]) + self._abort_if_unique_id_configured() + + account_id = user_input[ACCOUNT_ID] + password = user_input[CONF_PASSWORD].replace(" ", "") + + token = await adax.get_adax_token( + async_get_clientsession(self.hass), account_id, password + ) + if token is None: + _LOGGER.info("Adax: Failed to login to retrieve token") + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="cloud", + data_schema=data_schema, + errors=errors, + ) + + return self.async_create_entry( + title=user_input[ACCOUNT_ID], + data={ + ACCOUNT_ID: account_id, + CONF_PASSWORD: password, + CONNECTION_TYPE: CLOUD, + }, + ) diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py new file mode 100644 index 0000000000000..86c627aa1301d --- /dev/null +++ b/homeassistant/components/adax/const.py @@ -0,0 +1,10 @@ +"""Constants for the Adax integration.""" +from typing import Final + +ACCOUNT_ID: Final = "account_id" +CLOUD = "Cloud" +CONNECTION_TYPE = "connection_type" +DOMAIN: Final = "adax" +LOCAL = "Local" +WIFI_SSID = "wifi_ssid" +WIFI_PSWD = "wifi_pswd" diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json new file mode 100644 index 0000000000000..75d389e912a83 --- /dev/null +++ b/homeassistant/components/adax/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adax", + "name": "Adax", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adax", + "requirements": [ + "adax==0.2.0", "Adax-local==0.1.1" + ], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json new file mode 100644 index 0000000000000..6157b7dfc919b --- /dev/null +++ b/homeassistant/components/adax/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "connection_type": "Select connection type" + }, + "description": "Select connection type. Local requires heaters with bluetooth" + }, + "local": { + "data": { + "wifi_ssid": "Wi-Fi SSID", + "wifi_pswd": "Wi-Fi Password" + }, + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + }, + "cloud": { + "data": { + "account_id": "Account ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "heater_not_available": "Heater not available. Try to reset the heater by pressing + and OK for some seconds.", + "heater_not_found": "Heater not found. Try to move the heater closer to Home Assistant computer.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/adax/translations/bg.json b/homeassistant/components/adax/translations/bg.json new file mode 100644 index 0000000000000..d76109bfe01f5 --- /dev/null +++ b/homeassistant/components/adax/translations/bg.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "cloud": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, + "local": { + "data": { + "wifi_pswd": "Wi-Fi \u043f\u0430\u0440\u043e\u043b\u0430", + "wifi_ssid": "Wi-Fi SSID" + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/ca.json b/homeassistant/components/adax/translations/ca.json new file mode 100644 index 0000000000000..4e9f5eeb31749 --- /dev/null +++ b/homeassistant/components/adax/translations/ca.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "heater_not_available": "Escalfador no disponible. Intenta reiniciar l'escalfador prement '+' i 'OK' durant uns segons.", + "heater_not_found": "No s'ha trobat l'escalfador. Intenta apropar-lo a l'ordinador amb Home Assistant.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "cloud": { + "data": { + "account_id": "ID del compte", + "password": "Contrasenya" + } + }, + "local": { + "data": { + "wifi_pswd": "Contrasenya Wi-Fi", + "wifi_ssid": "SSID Wi-Fi" + }, + "description": "Reinicia l'escalfador prement '+' i 'OK' fins que la pantalla mostri 'Reset'. A continuaci\u00f3 i abans de fer clic a Envia, mant\u00e9 premut el bot\u00f3 'OK' fins que el led blau comenci a parpellejar. La configuraci\u00f3 de l'escalfador pot trigar uns minuts." + }, + "user": { + "data": { + "account_id": "ID del compte", + "connection_type": "Selecciona el tipus de connexi\u00f3", + "host": "Amfitri\u00f3", + "password": "Contrasenya" + }, + "description": "Selecciona el tipus de connexi\u00f3. La local necessita escalfadors amb Bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/cs.json b/homeassistant/components/adax/translations/cs.json new file mode 100644 index 0000000000000..07eeaa34f63ae --- /dev/null +++ b/homeassistant/components/adax/translations/cs.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "heater_not_available": "Oh\u0159\u00edva\u010d nen\u00ed k dispozici. Zkuste resetovat oh\u0159\u00edva\u010d stisknut\u00edm tla\u010d\u00edtek + a OK na n\u011bkolik sekund.", + "heater_not_found": "Oh\u0159\u00edva\u010d nenalezen. Zkuste p\u0159em\u00edstit oh\u0159\u00edva\u010d bl\u00ed\u017ee k po\u010d\u00edta\u010di Home Assistant.", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "cloud": { + "data": { + "account_id": "ID \u00fa\u010dtu", + "password": "Heslo" + } + }, + "local": { + "data": { + "wifi_pswd": "Heslo Wi-Fi", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "Resetujte oh\u0159\u00edva\u010d stisknut\u00edm + a OK, dokud se nezobraz\u00ed \"Reset\". Pot\u00e9 stiskn\u011bte a podr\u017ete tla\u010d\u00edtko OK na oh\u0159\u00edva\u010di, dokud modr\u00e1 led dioda neza\u010dne blikat, ne\u017e stisknete tla\u010d\u00edtko Odeslat. Konfigurace oh\u0159\u00edva\u010de m\u016f\u017ee trvat n\u011bkolik minut." + }, + "user": { + "data": { + "account_id": "ID \u00fa\u010dtu", + "connection_type": "Vyberte typ p\u0159ipojen\u00ed", + "host": "Hostitel", + "password": "Heslo" + }, + "description": "Vyberte typ p\u0159ipojen\u00ed. Lok\u00e1ln\u00ed vy\u017eaduje oh\u0159\u00edva\u010de s bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/de.json b/homeassistant/components/adax/translations/de.json new file mode 100644 index 0000000000000..711a8b446451e --- /dev/null +++ b/homeassistant/components/adax/translations/de.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "heater_not_available": "Heizger\u00e4t nicht verf\u00fcgbar. Versuche das Heizger\u00e4t zur\u00fcckzusetzen, indem du + und OK einige Sekunden lang dr\u00fcckst.", + "heater_not_found": "Heizger\u00e4t nicht gefunden. Versuche das Heizger\u00e4t n\u00e4her an den Home Assistant-Computer zu bringen.", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "cloud": { + "data": { + "account_id": "Konto-ID", + "password": "Passwort" + } + }, + "local": { + "data": { + "wifi_pswd": "WLAN Passwort", + "wifi_ssid": "WLAN SSID" + }, + "description": "Setze das Heizger\u00e4t zur\u00fcck, indem du + und OK dr\u00fcckst, bis auf dem Display \"Reset\" angezeigt wird. Halte dann die OK-Taste am Heizger\u00e4t gedr\u00fcckt, bis die blaue LED zu blinken beginnt, und dr\u00fccke dann auf Senden. Das Konfigurieren des Heizger\u00e4ts kann einige Minuten dauern." + }, + "user": { + "data": { + "account_id": "Konto-ID", + "connection_type": "Verbindungstyp ausw\u00e4hlen", + "host": "Host", + "password": "Passwort" + }, + "description": "Verbindungstyp ausw\u00e4hlen. Lokal erfordert Heizungen mit Bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/en.json b/homeassistant/components/adax/translations/en.json new file mode 100644 index 0000000000000..ae31fdbc0418f --- /dev/null +++ b/homeassistant/components/adax/translations/en.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "heater_not_available": "Heater not available. Try to reset the heater by pressing + and OK for some seconds.", + "heater_not_found": "Heater not found. Try to move the heater closer to Home Assistant computer.", + "invalid_auth": "Invalid authentication" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "cloud": { + "data": { + "account_id": "Account ID", + "password": "Password" + } + }, + "local": { + "data": { + "wifi_pswd": "Wi-Fi Password", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + }, + "user": { + "data": { + "account_id": "Account ID", + "connection_type": "Select connection type", + "host": "Host", + "password": "Password" + }, + "description": "Select connection type. Local requires heaters with bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/es.json b/homeassistant/components/adax/translations/es.json new file mode 100644 index 0000000000000..985d0ab663f7e --- /dev/null +++ b/homeassistant/components/adax/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "step": { + "user": { + "data": { + "account_id": "ID de la cuenta", + "host": "Host", + "password": "Contrase\u00f1a" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/et.json b/homeassistant/components/adax/translations/et.json new file mode 100644 index 0000000000000..2d1546142953b --- /dev/null +++ b/homeassistant/components/adax/translations/et.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "heater_not_available": "K\u00fctteseade pole saadaval. Proovi k\u00fctteseadet l\u00e4htestada, vajutades m\u00f5ne sekundi jooksul + ja OK.", + "heater_not_found": "K\u00fctteseadet ei leitud. Proovi viia k\u00fctteseade Home Assistanti arvutile l\u00e4hemale.", + "invalid_auth": "Tuvastamine nurjus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise viga" + }, + "step": { + "cloud": { + "data": { + "account_id": "Konto ID", + "password": "Salas\u00f5na" + } + }, + "local": { + "data": { + "wifi_pswd": "Wi-Fi salas\u00f5na", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "L\u00e4htesta k\u00fctteseade, vajutades + ja OK kuni ekraanil kuvatakse \"Reset\". Seej\u00e4rel vajuta ja hoia kerisel nuppu OK kuni sinine led hakkab vilkuma enne nupu Edasta vajutamist. K\u00fctteseadme konfigureerimine v\u00f5ib v\u00f5tta aega m\u00f5ni minut." + }, + "user": { + "data": { + "account_id": "Konto ID", + "connection_type": "Vali \u00fchenduse t\u00fc\u00fcp", + "host": "Host", + "password": "Salas\u00f5na" + }, + "description": "Vali \u00fchenduse t\u00fc\u00fcp. Kohalik n\u00f5uab bluetoothiga k\u00fctteseadmeid" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/fr.json b/homeassistant/components/adax/translations/fr.json new file mode 100644 index 0000000000000..eefd0693e2495 --- /dev/null +++ b/homeassistant/components/adax/translations/fr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "heater_not_available": "Chauffage non disponible. Essayez de r\u00e9initialiser le chauffage en appuyant sur + et OK pendant quelques secondes.", + "heater_not_found": "Chauffage introuvable. Essayez de rapprocher le radiateur de l'ordinateur Home Assistant.", + "invalid_auth": "Authentification invalide" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" + }, + "step": { + "cloud": { + "data": { + "account_id": "Identifiant de compte", + "password": "Mot der passe" + } + }, + "local": { + "data": { + "wifi_pswd": "Mot de passe WiFi", + "wifi_ssid": "identifiant Wifi" + }, + "description": "R\u00e9initialisez le radiateur en appuyant sur + et OK jusqu'\u00e0 ce que l'\u00e9cran affiche \u00ab\u00a0Reset\u00a0\u00bb. Appuyez ensuite sur le bouton OK du radiateur et maintenez-le enfonc\u00e9 jusqu'\u00e0 ce que le voyant bleu commence \u00e0 clignoter avant d'appuyer sur Soumettre. La configuration du chauffage peut prendre quelques minutes." + }, + "user": { + "data": { + "account_id": "identifiant de compte", + "connection_type": "S\u00e9lectionner le type de connexion", + "host": "H\u00f4te", + "password": "Mot de passe" + }, + "description": "S\u00e9lectionnez le type de connexion. Local n\u00e9cessite des radiateurs avec Bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/he.json b/homeassistant/components/adax/translations/he.json new file mode 100644 index 0000000000000..0ba47926a0733 --- /dev/null +++ b/homeassistant/components/adax/translations/he.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "cloud": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + }, + "user": { + "data": { + "account_id": "\u05de\u05d6\u05d4\u05d4 \u05d7\u05e9\u05d1\u05d5\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/hu.json b/homeassistant/components/adax/translations/hu.json new file mode 100644 index 0000000000000..9f37837420f40 --- /dev/null +++ b/homeassistant/components/adax/translations/hu.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "heater_not_available": "A f\u0171t\u0151berendez\u00e9s nem \u00e1ll rendelkez\u00e9sre. Pr\u00f3b\u00e1lja meg gy\u00e1ri \u00e1llapotba vissza\u00e1ll\u00edtani a + \u00e9s az OK gomb nyomvatart\u00e1s\u00e1val n\u00e9h\u00e1ny m\u00e1sodpercig.", + "heater_not_found": "A f\u0171t\u0151berendez\u00e9s nem tal\u00e1lhat\u00f3. Pr\u00f3b\u00e1lja meg k\u00f6zelebb helyezni a Home Assistant sz\u00e1m\u00edt\u00f3g\u00e9phez.", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "cloud": { + "data": { + "account_id": "Fi\u00f3k ID", + "password": "Jelsz\u00f3" + } + }, + "local": { + "data": { + "wifi_pswd": "WiFi jelsz\u00f3", + "wifi_ssid": "WiFi ssid" + }, + "description": "\u00c1ll\u00edtsa vissza a f\u0171t\u0151berendez\u00e9st a + \u00e9s az OK gomb nyomvatart\u00e1s\u00e1val, m\u00edg a kijelz\u0151n a \"Reset\" (Vissza\u00e1ll\u00edt\u00e1s) felirat nem jelenik meg. Ezut\u00e1n nyomja meg \u00e9s tartsa lenyomva a f\u0171t\u0151berendez\u00e9s OK gombj\u00e1t, am\u00edg a k\u00e9k led villogni nem kezd, majd nyomja meg a K\u00fcld\u00e9s gombot. A f\u0171t\u0151berendez\u00e9s konfigur\u00e1l\u00e1sa n\u00e9h\u00e1ny percet vehet ig\u00e9nybe." + }, + "user": { + "data": { + "account_id": "Fi\u00f3k ID", + "connection_type": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t", + "host": "C\u00edm", + "password": "Jelsz\u00f3" + }, + "description": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t. A Helyi kapcsolathoz bluetooth-os f\u0171t\u0151berendez\u00e9sekre van sz\u00fcks\u00e9g" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/id.json b/homeassistant/components/adax/translations/id.json new file mode 100644 index 0000000000000..864a8ed623dc1 --- /dev/null +++ b/homeassistant/components/adax/translations/id.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "heater_not_available": "Pemanas tidak tersedia. Cobalah untuk mengatur ulang pemanas dengan menekan + dan OK selama beberapa detik.", + "heater_not_found": "Pemanas tidak ditemukan. Coba dekatkan pemanas ke komputer Home Assistant.", + "invalid_auth": "Autentikasi tidak valid" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "cloud": { + "data": { + "account_id": "ID Akun", + "password": "Kata Sandi" + } + }, + "local": { + "data": { + "wifi_pswd": "Kata sandi Wi-Fi", + "wifi_ssid": "SSID Wi-Fi" + }, + "description": "Atur ulang pemanas dengan menekan + dan OK hingga muncul tampilan 'Reset'. Kemudian tekan dan tahan tombol OK pada pemanas sampai led biru mulai berkedip sebelum menekan Submit. Mengonfigurasi pemanas mungkin memerlukan waktu beberapa menit." + }, + "user": { + "data": { + "account_id": "ID Akun", + "connection_type": "Pilih jenis koneksi", + "host": "Host", + "password": "Kata Sandi" + }, + "description": "Pilih jenis koneksi. Lokal membutuhkan pemanas dengan bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/it.json b/homeassistant/components/adax/translations/it.json new file mode 100644 index 0000000000000..17095fab12b74 --- /dev/null +++ b/homeassistant/components/adax/translations/it.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "heater_not_available": "Riscaldatore non disponibile. Prova a ripristinare il riscaldatore premendo + e OK per alcuni secondi.", + "heater_not_found": "Riscaldatore non trovato. Prova ad avvicinare il riscaldatore al computer Home Assistant.", + "invalid_auth": "Autenticazione non valida" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "cloud": { + "data": { + "account_id": "ID account", + "password": "Password" + } + }, + "local": { + "data": { + "wifi_pswd": "Password Wi-Fi", + "wifi_ssid": "SSID Wi-Fi" + }, + "description": "Ripristina il riscaldatore premendo + e OK finch\u00e9 il display non mostra 'Reset'. Quindi premi e tieni premuto il pulsante OK sul riscaldatore fino a quando il led blu inizia a lampeggiare prima di premere Invia. La configurazione del riscaldatore potrebbe richiedere alcuni minuti." + }, + "user": { + "data": { + "account_id": "ID account", + "connection_type": "Seleziona il tipo di connessione", + "host": "Host", + "password": "Password" + }, + "description": "Seleziona il tipo di connessione. Locale richiede riscaldatori con bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/ja.json b/homeassistant/components/adax/translations/ja.json new file mode 100644 index 0000000000000..e432e23e0815f --- /dev/null +++ b/homeassistant/components/adax/translations/ja.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "heater_not_available": "\u30d2\u30fc\u30bf\u30fc\u306f\u5229\u7528\u3067\u304d\u307e\u305b\u3093\u3002\u6570\u79d2\u9593\u3001+ \u3068 OK \u3092\u62bc\u3057\u3066\u30d2\u30fc\u30bf\u30fc\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "heater_not_found": "\u30d2\u30fc\u30bf\u30fc\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002\u30d2\u30fc\u30bf\u30fc\u3092Home Assistant\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u30fc\u306b\u8fd1\u3065\u3051\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "cloud": { + "data": { + "account_id": "\u30a2\u30ab\u30a6\u30f3\u30c8ID", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + }, + "local": { + "data": { + "wifi_pswd": "Wifi\u30d1\u30b9\u30ef\u30fc\u30c9", + "wifi_ssid": "Wifi ssid" + }, + "description": "\u30c7\u30a3\u30b9\u30d7\u30ec\u30a4\u306b\u3001 'Reset ' \u3068\u8868\u793a\u3055\u308c\u308b\u307e\u3067 + \u3068 OK \u3092\u62bc\u3057\u3066\u3001\u30d2\u30fc\u30bf\u30fc\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3059\u3002\u305d\u306e\u5f8c\u3001\u30d2\u30fc\u30bf\u30fc\u306eOK\u30dc\u30bf\u30f3\u3092\u9752\u306eLED\u304c\u70b9\u6ec5\u3057\u59cb\u3081\u308b\u307e\u3067\u62bc\u3057\u7d9a\u3051\u3066\u304b\u3089\u3001Submit\u3092\u62bc\u3057\u307e\u3059\u3002\u30d2\u30fc\u30bf\u30fc\u306e\u8a2d\u5b9a\u306b\u306f\u6570\u5206\u304b\u304b\u308b\u5834\u5408\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "user": { + "data": { + "account_id": "\u30a2\u30ab\u30a6\u30f3\u30c8ID", + "connection_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u306e\u9078\u629e", + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u30ed\u30fc\u30ab\u30eb\u306b\u306fBluetooth\u4ed8\u304d\u306e\u30d2\u30fc\u30bf\u30fc\u304c\u5fc5\u8981\u3067\u3059" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/lt.json b/homeassistant/components/adax/translations/lt.json new file mode 100644 index 0000000000000..5faf196f9528d --- /dev/null +++ b/homeassistant/components/adax/translations/lt.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "heater_not_available": "\u0160ildytuvas nepasiekiamas. Pabandykite i\u0161 naujo nustatyti \u0161ildytuv\u0105 paspausdami + ir OK kelias sekundes.", + "heater_not_found": "\u0160ildytuvas nerastas. Pabandykite \u0161ildytuv\u0105 laikyti ar\u010diau Home Assistant serverio" + }, + "step": { + "cloud": { + "data": { + "account_id": "Paskyros ID", + "password": "Slapta\u017eodis" + } + }, + "local": { + "data": { + "wifi_pswd": "Wifi slapta\u017eodis", + "wifi_ssid": "Wifi ssid" + }, + "description": "I\u0161 naujo nustatykite \u0161ildytuv\u0105 spausdami + ir OK, kol ekrane pasirodys \u201eReset\u201c. Tada paspauskite ir palaikykite OK mygtuk\u0105 ant \u0161ildytuvo, kol prad\u0117s mirks\u0117ti m\u0117lyna lemput\u0117, prie\u0161 paspausdami Patvirtinti. \u0160ildytuvo konfig\u016bravimas gali u\u017etrukti kelet\u0105 minu\u010di\u0173." + }, + "user": { + "data": { + "connection_type": "Pasirinkite ry\u0161io tip\u0105" + }, + "description": "Pasirinkite ry\u0161io tip\u0105. Vietiniams reikalingi \u0161ildytuvai su \u201eBluetooth\u201c." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/nl.json b/homeassistant/components/adax/translations/nl.json new file mode 100644 index 0000000000000..026c3c06dae3e --- /dev/null +++ b/homeassistant/components/adax/translations/nl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "heater_not_available": "Verwarming niet aanwezig. Probeer de kachel te resetten door enkele seconden op + en OK te drukken.", + "heater_not_found": "Verwarming niet gevonden. Probeer de verwarming dichter bij de Home Assistant-computer te plaatsen.", + "invalid_auth": "Ongeldige authenticatie" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "cloud": { + "data": { + "account_id": "Account ID", + "password": "Wachtwoord" + } + }, + "local": { + "data": { + "wifi_pswd": "Wi-Fi Wachtwoord", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "Reset de kachel door op + en OK te drukken totdat het display 'Reset' toont. Houd vervolgens de OK-knop op de verwarming ingedrukt totdat de blauwe led begint te knipperen voordat u op Verzenden drukt. Het configureren van de verwarming kan enkele minuten duren." + }, + "user": { + "data": { + "account_id": "Account ID", + "connection_type": "Selecteer verbindingstype", + "host": "Host", + "password": "Wachtwoord" + }, + "description": "Selecteer verbindingstype. Lokaal vereist verwarming met bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/no.json b/homeassistant/components/adax/translations/no.json new file mode 100644 index 0000000000000..23e8225067323 --- /dev/null +++ b/homeassistant/components/adax/translations/no.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "heater_not_available": "Varmeapparat ikke tilgjengelig. Pr\u00f8v \u00e5 tilbakestille varmeapparatet ved \u00e5 trykke p\u00e5 + og OK i noen sekunder.", + "heater_not_found": "Fant ikke varmeovn. Pr\u00f8v \u00e5 flytte varmeren n\u00e6rmere Home Assistant-datamaskinen.", + "invalid_auth": "Ugyldig godkjenning" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "cloud": { + "data": { + "account_id": "Konto-ID", + "password": "Passord" + } + }, + "local": { + "data": { + "wifi_pswd": "Wi-Fi-passord", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "Tilbakestill varmeren ved \u00e5 trykke p\u00e5 + og OK til displayet viser 'Tilbakestill'. Trykk deretter p\u00e5 og hold inne OK-knappen p\u00e5 varmeren til den bl\u00e5 lampen begynner \u00e5 blinke f\u00f8r du trykker p\u00e5 Send. Det kan ta noen minutter \u00e5 konfigurere varmeapparatet." + }, + "user": { + "data": { + "account_id": "Konto-ID", + "connection_type": "Velg tilkoblingstype", + "host": "Vert", + "password": "Passord" + }, + "description": "Velg tilkoblingstype. Lokalt krever varmeovner med bluetooth" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/pl.json b/homeassistant/components/adax/translations/pl.json new file mode 100644 index 0000000000000..2a8e101680585 --- /dev/null +++ b/homeassistant/components/adax/translations/pl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "heater_not_available": "Grzejnik niedost\u0119pny. Spr\u00f3buj go zresetowa\u0107, naciskaj\u0105c + i OK przez kilka sekund.", + "heater_not_found": "Nie znaleziono grzejnika. Spr\u00f3buj przesun\u0105\u0107 grzejnik bli\u017cej komputera z Home Assistant.", + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "cloud": { + "data": { + "account_id": "Identyfikator konta", + "password": "Has\u0142o" + } + }, + "local": { + "data": { + "wifi_pswd": "Has\u0142o WiFi", + "wifi_ssid": "SSID WiFi" + }, + "description": "Zresetuj grzejnik, naciskaj\u0105c + i OK, a\u017c na wy\u015bwietlaczu pojawi si\u0119 \u201eReset\u201d. Nast\u0119pnie naci\u015bnij i przytrzymaj przycisk OK na grzejniku, a\u017c niebieska dioda zacznie miga\u0107 przed naci\u015bni\u0119ciem przycisku \"Zatwierd\u017a\". Konfiguracja grzejnika mo\u017ce zaj\u0105\u0107 kilka minut." + }, + "user": { + "data": { + "account_id": "Identyfikator konta", + "connection_type": "Wybierz typ po\u0142\u0105czenia", + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o" + }, + "description": "Wybierz typ po\u0142\u0105czenia. \"Lokalny\" wymaga grzejnik\u00f3w z bluetooth." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/ru.json b/homeassistant/components/adax/translations/ru.json new file mode 100644 index 0000000000000..27596e8648560 --- /dev/null +++ b/homeassistant/components/adax/translations/ru.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "heater_not_available": "\u041d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0435\u0433\u043e, \u043d\u0430\u0436\u0430\u0432 \u043d\u0430 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0441\u0435\u043a\u0443\u043d\u0434 \u043a\u043d\u043e\u043f\u043a\u0438 + \u0438 \u041e\u041a.", + "heater_not_found": "\u041d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c \u043e\u0431\u043e\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c \u0431\u043b\u0438\u0436\u0435 \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 Home Assistant.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "cloud": { + "data": { + "account_id": "ID \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + }, + "local": { + "data": { + "wifi_pswd": "\u041f\u0430\u0440\u043e\u043b\u044c Wi-Fi", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "\u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044f, \u043d\u0430\u0436\u0438\u043c\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0438 + \u0438 OK, \u043f\u043e\u043a\u0430 \u043d\u0430 \u0434\u0438\u0441\u043f\u043b\u0435\u0435 \u043d\u0435 \u043f\u043e\u044f\u0432\u0438\u0442\u0441\u044f \u043d\u0430\u0434\u043f\u0438\u0441\u044c 'Reset'. \u0417\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 OK \u043d\u0430 \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u0435, \u043f\u043e\u043a\u0430 \u0441\u0438\u043d\u0438\u0439 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440 \u043d\u0435 \u043d\u0430\u0447\u043d\u0435\u0442 \u043c\u0438\u0433\u0430\u0442\u044c, \u043f\u043e\u0441\u043b\u0435 \u0447\u0435\u0433\u043e \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0437\u0434\u0435\u0441\u044c '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c'. \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044f \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u0438\u043d\u0443\u0442." + }, + "user": { + "data": { + "account_id": "ID \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438", + "connection_type": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f", + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f. \u0414\u043b\u044f \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043e\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u0438 \u0441 Bluetooth." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/tr.json b/homeassistant/components/adax/translations/tr.json new file mode 100644 index 0000000000000..bd7ef0fb6e974 --- /dev/null +++ b/homeassistant/components/adax/translations/tr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "heater_not_available": "Is\u0131t\u0131c\u0131 mevcut de\u011fil. + ve OK tu\u015flar\u0131na birka\u00e7 saniye basarak \u0131s\u0131t\u0131c\u0131y\u0131 s\u0131f\u0131rlamay\u0131 deneyin.", + "heater_not_found": "Is\u0131t\u0131c\u0131 bulunamad\u0131. Is\u0131t\u0131c\u0131y\u0131 Home Assistant bilgisayar\u0131na yakla\u015ft\u0131rmay\u0131 deneyin.", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "cloud": { + "data": { + "account_id": "Hesap Kimli\u011fi", + "password": "Parola" + } + }, + "local": { + "data": { + "wifi_pswd": "Kablosuz a\u011f parolas\u0131", + "wifi_ssid": "Wifi A\u011f Ad\u0131" + }, + "description": "Ekranda 'S\u0131f\u0131rla' g\u00f6r\u00fcnene kadar + ve OK tu\u015flar\u0131na basarak \u0131s\u0131t\u0131c\u0131y\u0131 s\u0131f\u0131rlay\u0131n. Ard\u0131ndan G\u00f6nder'e basmadan \u00f6nce mavi led yan\u0131p s\u00f6nmeye ba\u015flayana kadar \u0131s\u0131t\u0131c\u0131daki OK d\u00fc\u011fmesini bas\u0131l\u0131 tutun. Is\u0131t\u0131c\u0131y\u0131 yap\u0131land\u0131rmak birka\u00e7 dakika s\u00fcrebilir." + }, + "user": { + "data": { + "account_id": "Hesap Kimli\u011fi", + "connection_type": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in", + "host": "Sunucu", + "password": "Parola" + }, + "description": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in. Yerel Bluetooth'lu \u0131s\u0131t\u0131c\u0131lar gerektirir" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/zh-Hans.json b/homeassistant/components/adax/translations/zh-Hans.json new file mode 100644 index 0000000000000..2946f1ebefbc4 --- /dev/null +++ b/homeassistant/components/adax/translations/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "invalid_auth": "\u65e0\u6548\u7684\u6388\u6743" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "cloud": { + "data": { + "account_id": "\u5e10\u6237ID", + "password": "\u5bc6\u7801" + } + }, + "local": { + "data": { + "wifi_pswd": "WiFi \u5bc6\u7801", + "wifi_ssid": "WiFi \u540d\u79f0 (SSID)" + } + }, + "user": { + "data": { + "connection_type": "\u9009\u62e9\u8fde\u63a5\u7c7b\u578b", + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/zh-Hant.json b/homeassistant/components/adax/translations/zh-Hant.json new file mode 100644 index 0000000000000..0ad4bcba854fd --- /dev/null +++ b/homeassistant/components/adax/translations/zh-Hant.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "heater_not_available": "\u627e\u4e0d\u5230\u52a0\u71b1\u5668\uff0c\u8acb\u8a66\u8457\u6309\u4f4f + \u8207 OK \u5e7e\u79d2\u9032\u884c\u91cd\u7f6e\u3002", + "heater_not_found": "\u627e\u4e0d\u5230\u52a0\u71b1\u5668\uff0c\u8acb\u8a66\u8457\u5c07\u52a0\u71b1\u5668\u5f80 Home Assistant \u4f3a\u670d\u5668\u9760\u8fd1\u3002", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "cloud": { + "data": { + "account_id": "\u5e33\u865f ID", + "password": "\u5bc6\u78bc" + } + }, + "local": { + "data": { + "wifi_pswd": "Wi-Fi \u5bc6\u78bc", + "wifi_ssid": "Wi-Fi SSID" + }, + "description": "\u6309\u4f4f + \u8207 OK \u9032\u884c\u52a0\u71b1\u5668\u91cd\u7f6e\u3001\u76f4\u5230\u986f\u793a 'Reset'\u3002\u63a5\u8457\u6309\u4f4f\u52a0\u71b1\u5668\u4e0a\u7684 OK \u6309\u9215\u3001\u76f4\u5230\u85cd\u8272 LED \u71c8\u958b\u59cb\u9583\u720d\uff0c\u518d\u6309\u4e0b\u9001\u51fa\u3002\u52a0\u71b1\u5668\u8a2d\u5b9a\u53ef\u80fd\u9700\u8981\u5e7e\u5206\u9418\u6642\u9593\u3002" + }, + "user": { + "data": { + "account_id": "\u5e33\u865f ID", + "connection_type": "\u9078\u64c7\u9023\u7dda\u985e\u578b", + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc" + }, + "description": "\u9078\u64c7\u9023\u7dda\u985e\u578b\u3002\u672c\u5730\u7aef\u5c07\u9700\u8981\u5177\u5099\u85cd\u82bd\u52a0\u71b1\u5668" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 0a4a79b65f5da..9b419c444ce30 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -6,18 +6,7 @@ from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError import voluptuous as vol -from homeassistant.components.adguard.const import ( - CONF_FORCE, - DATA_ADGUARD_CLIENT, - DATA_ADGUARD_VERSION, - DOMAIN, - SERVICE_ADD_URL, - SERVICE_DISABLE_URL, - SERVICE_ENABLE_URL, - SERVICE_REFRESH, - SERVICE_REMOVE_URL, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -27,13 +16,27 @@ CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, Entity +from .const import ( + CONF_FORCE, + DATA_ADGUARD_CLIENT, + DATA_ADGUARD_VERSION, + DOMAIN, + SERVICE_ADD_URL, + SERVICE_DISABLE_URL, + SERVICE_ENABLE_URL, + SERVICE_REFRESH, + SERVICE_REMOVE_URL, +) + _LOGGER = logging.getLogger(__name__) SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) @@ -44,7 +47,7 @@ {vol.Optional(CONF_FORCE, default=False): cv.boolean} ) -PLATFORMS = ["sensor", "switch"] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -195,14 +198,23 @@ class AdGuardHomeDeviceEntity(AdGuardHomeEntity): @property def device_info(self) -> DeviceInfo: """Return device information about this AdGuard Home instance.""" - return { - "identifiers": { - (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) + if self._entry.source == SOURCE_HASSIO: + config_url = "homeassistant://hassio/ingress/a0d7b954_adguard" + else: + if self.adguard.tls: + config_url = f"https://{self.adguard.host}:{self.adguard.port}" + else: + config_url = f"http://{self.adguard.host}:{self.adguard.port}" + + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) # type: ignore }, - "name": "AdGuard Home", - "manufacturer": "AdGuard Team", - "sw_version": self.hass.data[DOMAIN][self._entry.entry_id].get( + manufacturer="AdGuard Team", + name="AdGuard Home", + sw_version=self.hass.data[DOMAIN][self._entry.entry_id].get( DATA_ADGUARD_VERSION ), - "entry_type": "service", - } + configuration_url=config_url, + ) diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index 11d97d98d62c6..aadbed4998028 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -6,6 +6,7 @@ from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, @@ -51,6 +52,7 @@ async def _show_hassio_form( self, errors: dict[str, str] | None = None ) -> FlowResult: """Show the Hass.io confirmation form to the user.""" + assert self._hassio_discovery return self.async_show_form( step_id="hassio_confirm", description_placeholders={"addon": self._hassio_discovery["addon"]}, @@ -65,23 +67,21 @@ async def async_step_user( if user_input is None: return await self._show_setup_form(user_input) - entries = self._async_current_entries() - for entry in entries: - if ( - entry.data[CONF_HOST] == user_input[CONF_HOST] - and entry.data[CONF_PORT] == user_input[CONF_PORT] - ): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) errors = {} session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + username: str | None = user_input.get(CONF_USERNAME) + password: str | None = user_input.get(CONF_PASSWORD) adguard = AdGuardHome( user_input[CONF_HOST], port=user_input[CONF_PORT], - username=user_input.get(CONF_USERNAME), - password=user_input.get(CONF_PASSWORD), + username=username, # type:ignore[arg-type] + password=password, # type:ignore[arg-type] tls=user_input[CONF_SSL], verify_ssl=user_input[CONF_VERIFY_SSL], session=session, @@ -105,14 +105,14 @@ async def async_step_user( }, ) - async def async_step_hassio(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Prepare configuration for a Hass.io AdGuard Home add-on. This flow is triggered by the discovery component. """ await self._async_handle_discovery_without_unique_id() - self._hassio_discovery = discovery_info + self._hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() async def async_step_hassio_confirm( @@ -126,6 +126,7 @@ async def async_step_hassio_confirm( session = async_get_clientsession(self.hass, False) + assert self._hassio_discovery adguard = AdGuardHome( self._hassio_discovery[CONF_HOST], port=self._hassio_discovery[CONF_PORT], diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 7499cf51d0cec..8134d2c4d43ea 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -62,7 +62,7 @@ def __init__( enabled_default: bool = True, ) -> None: """Initialize AdGuard Home sensor.""" - self._state = None + self._state: int | str | None = None self._unit_of_measurement = unit_of_measurement self.measurement = measurement @@ -82,12 +82,12 @@ def unique_id(self) -> str: ) @property - def state(self) -> str | None: + def native_value(self) -> int | str | None: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/adguard/services.yaml b/homeassistant/components/adguard/services.yaml index 2e97d164e3aae..5e4c2a157de44 100644 --- a/homeassistant/components/adguard/services.yaml +++ b/homeassistant/components/adguard/services.yaml @@ -59,8 +59,7 @@ refresh: fields: force: name: Force - description: Force update (by passes AdGuard Home throttling). - example: '"true" to force, "false" or omit for a regular refresh.' + description: Force update (bypasses AdGuard Home throttling). "true" to force, or "false" to omit for a regular refresh. default: false selector: boolean: diff --git a/homeassistant/components/adguard/translations/bg.json b/homeassistant/components/adguard/translations/bg.json index 82c658e7c15b3..9838fd97c1349 100644 --- a/homeassistant/components/adguard/translations/bg.json +++ b/homeassistant/components/adguard/translations/bg.json @@ -1,8 +1,10 @@ { "config": { "abort": { - "existing_instance_updated": "\u0410\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f.", - "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 AdGuard Home." + "existing_instance_updated": "\u0410\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "step": { "hassio_confirm": { @@ -11,7 +13,9 @@ }, "user": { "data": { + "host": "\u0425\u043e\u0441\u0442", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", "ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 SSL \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", "verify_ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043d\u0430\u0434\u0435\u0436\u0434\u0435\u043d \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442" diff --git a/homeassistant/components/adguard/translations/ca.json b/homeassistant/components/adguard/translations/ca.json index 82897df6b2aa2..300c843b57eed 100644 --- a/homeassistant/components/adguard/translations/ca.json +++ b/homeassistant/components/adguard/translations/ca.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "El servei ja est\u00e0 configurat", - "existing_instance_updated": "S'ha actualitzat la configuraci\u00f3 existent.", - "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + "existing_instance_updated": "S'ha actualitzat la configuraci\u00f3 existent." }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/adguard/translations/cs.json b/homeassistant/components/adguard/translations/cs.json index b56ed228b4ddf..f82589900d414 100644 --- a/homeassistant/components/adguard/translations/cs.json +++ b/homeassistant/components/adguard/translations/cs.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Slu\u017eba je ji\u017e nastavena", - "existing_instance_updated": "St\u00e1vaj\u00edc\u00ed nastaven\u00ed aktualizov\u00e1no.", - "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." + "existing_instance_updated": "St\u00e1vaj\u00edc\u00ed nastaven\u00ed aktualizov\u00e1no." }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" diff --git a/homeassistant/components/adguard/translations/da.json b/homeassistant/components/adguard/translations/da.json index 79a1937eba826..8bb4c26eed69e 100644 --- a/homeassistant/components/adguard/translations/da.json +++ b/homeassistant/components/adguard/translations/da.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "Opdaterede eksisterende konfiguration.", - "single_instance_allowed": "Kun en enkelt konfiguration af AdGuard Home er tilladt." + "existing_instance_updated": "Opdaterede eksisterende konfiguration." }, "step": { "hassio_confirm": { diff --git a/homeassistant/components/adguard/translations/de.json b/homeassistant/components/adguard/translations/de.json index 0819dc1c0a56d..b0a6b48024952 100644 --- a/homeassistant/components/adguard/translations/de.json +++ b/homeassistant/components/adguard/translations/de.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Der Dienst ist bereits konfiguriert", - "existing_instance_updated": "Bestehende Konfiguration wurde aktualisiert.", - "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + "existing_instance_updated": "Bestehende Konfiguration wurde aktualisiert." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" @@ -18,7 +17,7 @@ "host": "Host", "password": "Passwort", "port": "Port", - "ssl": "AdGuard Home verwendet ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, diff --git a/homeassistant/components/adguard/translations/en.json b/homeassistant/components/adguard/translations/en.json index 31eb1ff06a3a8..f354aaab10a2a 100644 --- a/homeassistant/components/adguard/translations/en.json +++ b/homeassistant/components/adguard/translations/en.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Service is already configured", - "existing_instance_updated": "Updated existing configuration.", - "single_instance_allowed": "Already configured. Only a single configuration possible." + "existing_instance_updated": "Updated existing configuration." }, "error": { "cannot_connect": "Failed to connect" diff --git a/homeassistant/components/adguard/translations/es-419.json b/homeassistant/components/adguard/translations/es-419.json index 8fac53b61ab17..6a734ffea9ac8 100644 --- a/homeassistant/components/adguard/translations/es-419.json +++ b/homeassistant/components/adguard/translations/es-419.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "Se actualiz\u00f3 la configuraci\u00f3n existente.", - "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + "existing_instance_updated": "Se actualiz\u00f3 la configuraci\u00f3n existente." }, "step": { "hassio_confirm": { diff --git a/homeassistant/components/adguard/translations/es.json b/homeassistant/components/adguard/translations/es.json index fa12995ea5985..5750808ab7617 100644 --- a/homeassistant/components/adguard/translations/es.json +++ b/homeassistant/components/adguard/translations/es.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "El servicio ya est\u00e1 configurado", - "existing_instance_updated": "Se ha actualizado la configuraci\u00f3n existente.", - "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + "existing_instance_updated": "Se ha actualizado la configuraci\u00f3n existente." }, "error": { "cannot_connect": "No se pudo conectar" diff --git a/homeassistant/components/adguard/translations/et.json b/homeassistant/components/adguard/translations/et.json index 1e53492510bb1..fc1d043994ee9 100644 --- a/homeassistant/components/adguard/translations/et.json +++ b/homeassistant/components/adguard/translations/et.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Teenus on juba seadistatud", - "existing_instance_updated": "Olemasolevad seaded v\u00e4rskendatud.", - "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + "existing_instance_updated": "Olemasolevad seaded v\u00e4rskendatud." }, "error": { "cannot_connect": "\u00dchendamine nurjus" diff --git a/homeassistant/components/adguard/translations/fr.json b/homeassistant/components/adguard/translations/fr.json index f97eb7a0df14c..da6dd866983a1 100644 --- a/homeassistant/components/adguard/translations/fr.json +++ b/homeassistant/components/adguard/translations/fr.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", - "existing_instance_updated": "La configuration existante a \u00e9t\u00e9 mise \u00e0 jour.", - "single_instance_allowed": "Une seule configuration d'AdGuard Home est autoris\u00e9e." + "existing_instance_updated": "La configuration existante a \u00e9t\u00e9 mise \u00e0 jour." }, "error": { "cannot_connect": "\u00c9chec de connexion" @@ -18,9 +17,9 @@ "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "ssl": "AdGuard Home utilise un certificat SSL", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "AdGuard Home utilise un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Configurez votre instance AdGuard Home pour permettre la surveillance et le contr\u00f4le." } diff --git a/homeassistant/components/adguard/translations/he.json b/homeassistant/components/adguard/translations/he.json index 1471fd6603b01..9970667cf4099 100644 --- a/homeassistant/components/adguard/translations/he.json +++ b/homeassistant/components/adguard/translations/he.json @@ -1,11 +1,23 @@ { "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, "step": { + "hassio_confirm": { + "title": "AdGuard Home \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05e8\u05d7\u05d1\u05ea Assistant Assistant" + }, "user": { "data": { - "host": "Host", + "host": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "port": "\u05e4\u05d5\u05e8\u05d8" + "port": "\u05e4\u05d5\u05e8\u05d8", + "ssl": "\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05d9\u05e9\u05d5\u05e8 SSL", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", + "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" } } } diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 3813fae8f3c4c..b04d67fbb8935 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -1,21 +1,27 @@ { "config": { "abort": { - "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "existing_instance_updated": "Friss\u00edtette a megl\u00e9v\u0151 konfigur\u00e1ci\u00f3t." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { + "hassio_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot AdGuard Home-hoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "title": "Az AdGuard Home a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "description": "\u00c1ll\u00edtsa be az AdGuard Home p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s az ir\u00e1ny\u00edt\u00e1st." } } } diff --git a/homeassistant/components/adguard/translations/id.json b/homeassistant/components/adguard/translations/id.json index d2e36cfe5b993..91d0652618418 100644 --- a/homeassistant/components/adguard/translations/id.json +++ b/homeassistant/components/adguard/translations/id.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "existing_instance_updated": "Memperbarui konfigurasi yang ada.", - "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + "already_configured": "Layanan sudah dikonfigurasi", + "existing_instance_updated": "Memperbarui konfigurasi yang ada." }, "error": { "cannot_connect": "Gagal terhubung" }, "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke AdGuard Home yang disediakan oleh add-on Supervisor {addon}?", + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke AdGuard Home yang disediakan oleh add-on: {addon}?", "title": "AdGuard Home melalui add-on Home Assistant" }, "user": { diff --git a/homeassistant/components/adguard/translations/it.json b/homeassistant/components/adguard/translations/it.json index 9383de7b853a4..e2653ffd91541 100644 --- a/homeassistant/components/adguard/translations/it.json +++ b/homeassistant/components/adguard/translations/it.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Il servizio \u00e8 gi\u00e0 configurato", - "existing_instance_updated": "Configurazione esistente aggiornata.", - "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + "existing_instance_updated": "Configurazione esistente aggiornata." }, "error": { "cannot_connect": "Impossibile connettersi" @@ -20,7 +19,7 @@ "port": "Porta", "ssl": "Utilizza un certificato SSL", "username": "Nome utente", - "verify_ssl": "Verificare il certificato SSL" + "verify_ssl": "Verifica il certificato SSL" }, "description": "Configura l'istanza di AdGuard Home per consentire il monitoraggio e il controllo." } diff --git a/homeassistant/components/adguard/translations/ja.json b/homeassistant/components/adguard/translations/ja.json new file mode 100644 index 0000000000000..33f24b1d8e590 --- /dev/null +++ b/homeassistant/components/adguard/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "existing_instance_updated": "\u65e2\u5b58\u306e\u8a2d\u5b9a\u3092\u66f4\u65b0\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308bAdGuard Home\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306eAdGuard Home" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "description": "\u76e3\u8996\u3068\u5236\u5fa1\u304c\u3067\u304d\u308b\u3088\u3046\u306b\u3001AdGuardHome\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/ko.json b/homeassistant/components/adguard/translations/ko.json index fa5b3254ad4a5..63d672a2fff1c 100644 --- a/homeassistant/components/adguard/translations/ko.json +++ b/homeassistant/components/adguard/translations/ko.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "existing_instance_updated": "\uae30\uc874 \uad6c\uc131\uc744 \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "existing_instance_updated": "\uae30\uc874 \uad6c\uc131\uc744 \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4." }, "error": { "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/adguard/translations/lb.json b/homeassistant/components/adguard/translations/lb.json index ae7e6ad99be64..f1bd1876dc740 100644 --- a/homeassistant/components/adguard/translations/lb.json +++ b/homeassistant/components/adguard/translations/lb.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "D\u00e9i bestehend Konfiguratioun ass ge\u00e4nnert.", - "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun m\u00e9iglech." + "existing_instance_updated": "D\u00e9i bestehend Konfiguratioun ass ge\u00e4nnert." }, "error": { "cannot_connect": "Feeler beim verbannen" diff --git a/homeassistant/components/adguard/translations/nl.json b/homeassistant/components/adguard/translations/nl.json index 3ad3fe741da56..9f991cbd4077d 100644 --- a/homeassistant/components/adguard/translations/nl.json +++ b/homeassistant/components/adguard/translations/nl.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Service is al geconfigureerd", - "existing_instance_updated": "Bestaande configuratie bijgewerkt.", - "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van AdGuard Home is toegestaan." + "existing_instance_updated": "Bestaande configuratie bijgewerkt." }, "error": { "cannot_connect": "Kan geen verbinding maken" diff --git a/homeassistant/components/adguard/translations/no.json b/homeassistant/components/adguard/translations/no.json index 442c5a9e6b4a0..fc95d3bde6669 100644 --- a/homeassistant/components/adguard/translations/no.json +++ b/homeassistant/components/adguard/translations/no.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "existing_instance_updated": "Oppdatert eksisterende konfigurasjon.", - "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + "existing_instance_updated": "Oppdatert eksisterende konfigurasjon." }, "error": { "cannot_connect": "Tilkobling mislyktes" diff --git a/homeassistant/components/adguard/translations/pl.json b/homeassistant/components/adguard/translations/pl.json index c194afb63dab7..7ea17b246bcea 100644 --- a/homeassistant/components/adguard/translations/pl.json +++ b/homeassistant/components/adguard/translations/pl.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", - "existing_instance_updated": "Zaktualizowano istniej\u0105c\u0105 konfiguracj\u0119", - "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + "existing_instance_updated": "Zaktualizowano istniej\u0105c\u0105 konfiguracj\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" diff --git a/homeassistant/components/adguard/translations/pt-BR.json b/homeassistant/components/adguard/translations/pt-BR.json index 959c7ba3638e1..5d291f4cadb64 100644 --- a/homeassistant/components/adguard/translations/pt-BR.json +++ b/homeassistant/components/adguard/translations/pt-BR.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "Configura\u00e7\u00e3o existente atualizada.", - "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do AdGuard Home \u00e9 permitida." + "existing_instance_updated": "Configura\u00e7\u00e3o existente atualizada." }, "step": { "hassio_confirm": { diff --git a/homeassistant/components/adguard/translations/pt.json b/homeassistant/components/adguard/translations/pt.json index a7e494936b86b..df9b6c03bc51d 100644 --- a/homeassistant/components/adguard/translations/pt.json +++ b/homeassistant/components/adguard/translations/pt.json @@ -1,8 +1,5 @@ { "config": { - "abort": { - "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." - }, "error": { "cannot_connect": "Falha na liga\u00e7\u00e3o" }, diff --git a/homeassistant/components/adguard/translations/ru.json b/homeassistant/components/adguard/translations/ru.json index b2eb34f061faa..b1bb7d3ccf704 100644 --- a/homeassistant/components/adguard/translations/ru.json +++ b/homeassistant/components/adguard/translations/ru.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", - "existing_instance_updated": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", - "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + "existing_instance_updated": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." diff --git a/homeassistant/components/adguard/translations/sl.json b/homeassistant/components/adguard/translations/sl.json index 34b03263cebfd..f878a2cc20663 100644 --- a/homeassistant/components/adguard/translations/sl.json +++ b/homeassistant/components/adguard/translations/sl.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "Posodobljena obstoje\u010da konfiguracija.", - "single_instance_allowed": "Dovoljena je samo ena konfiguracija AdGuard Home." + "existing_instance_updated": "Posodobljena obstoje\u010da konfiguracija." }, "step": { "hassio_confirm": { diff --git a/homeassistant/components/adguard/translations/sv.json b/homeassistant/components/adguard/translations/sv.json index ca6158eaf32b6..0b58d9dcc97f2 100644 --- a/homeassistant/components/adguard/translations/sv.json +++ b/homeassistant/components/adguard/translations/sv.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "Uppdaterade existerande konfiguration.", - "single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten." + "existing_instance_updated": "Uppdaterade existerande konfiguration." }, "step": { "hassio_confirm": { diff --git a/homeassistant/components/adguard/translations/tr.json b/homeassistant/components/adguard/translations/tr.json index 26bef46408a1e..c5577fdae4e87 100644 --- a/homeassistant/components/adguard/translations/tr.json +++ b/homeassistant/components/adguard/translations/tr.json @@ -1,19 +1,27 @@ { "config": { "abort": { - "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "existing_instance_updated": "Mevcut yap\u0131land\u0131rma g\u00fcncellendi." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan AdGuard Home'a ba\u011flanmak i\u00e7in Home Assistant'\u0131 yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla AdGuard Home" + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "description": "AdGuard Home \u00f6rne\u011finizi, izleme ve kontrole izin verecek \u015fekilde ayarlay\u0131n." } } } diff --git a/homeassistant/components/adguard/translations/uk.json b/homeassistant/components/adguard/translations/uk.json index 28d02f25b7e8f..34a336364a0ac 100644 --- a/homeassistant/components/adguard/translations/uk.json +++ b/homeassistant/components/adguard/translations/uk.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "existing_instance_updated": "\u041a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044f \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", - "single_instance_allowed": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0436\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u041c\u043e\u0436\u043d\u0430 \u0434\u043e\u0434\u0430\u0442\u0438 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e." + "existing_instance_updated": "\u041a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044f \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f" diff --git a/homeassistant/components/adguard/translations/zh-Hans.json b/homeassistant/components/adguard/translations/zh-Hans.json index 4204beb5268a8..0d60c5e8c0771 100644 --- a/homeassistant/components/adguard/translations/zh-Hans.json +++ b/homeassistant/components/adguard/translations/zh-Hans.json @@ -1,14 +1,23 @@ { "config": { "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", "existing_instance_updated": "\u66f4\u65b0\u4e86\u73b0\u6709\u914d\u7f6e\u3002" }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, "step": { "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", "password": "\u5bc6\u7801", - "username": "\u7528\u6237\u540d" - } + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66\u51ed\u8bc1" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 AdGuard Home \u5b9e\u4f8b\u4ee5\u5141\u8bb8\u76d1\u89c6\u548c\u63a7\u5236" } } } diff --git a/homeassistant/components/adguard/translations/zh-Hant.json b/homeassistant/components/adguard/translations/zh-Hant.json index eeec0d6b17cdc..7db4bbcea83bd 100644 --- a/homeassistant/components/adguard/translations/zh-Hant.json +++ b/homeassistant/components/adguard/translations/zh-Hant.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "existing_instance_updated": "\u5df2\u66f4\u65b0\u73fe\u6709\u8a2d\u5b9a\u3002", - "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + "existing_instance_updated": "\u5df2\u66f4\u65b0\u73fe\u6709\u8a2d\u5b9a\u3002" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index b17a066eba781..53687564cd288 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -268,15 +268,17 @@ def _device_notification_callback(self, notification, name): class AdsEntity(Entity): """Representation of ADS entity.""" + _attr_should_poll = False + def __init__(self, ads_hub, name, ads_var): """Initialize ADS binary sensor.""" - self._name = name - self._unique_id = ads_var self._state_dict = {} self._state_dict[STATE_KEY_STATE] = None self._ads_hub = ads_hub self._ads_var = ads_var self._event = None + self._attr_unique_id = ads_var + self._attr_name = name async def async_initialize_device( self, ads_var, plctype, state_key=STATE_KEY_STATE, factor=None @@ -305,27 +307,12 @@ async def async_event_set(): self._ads_hub.add_device_notification, ads_var, plctype, update ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await self._event.wait() except asyncio.TimeoutError: _LOGGER.debug("Variable %s: Timeout during first update", ads_var) @property - def name(self): - """Return the default name of the binary sensor.""" - return self._name - - @property - def unique_id(self): - """Return an unique identifier for this entity.""" - return self._unique_id - - @property - def should_poll(self): - """Return False because entity pushes its state to HA.""" - return False - - @property - def available(self): + def available(self) -> bool: """Return False if state has not been updated yet.""" return self._state_dict[STATE_KEY_STATE] is not None diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 0cf89dfa7cc3b..952562b49ecb5 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -1,14 +1,19 @@ """Support for ADS binary sensors.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOVING, DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity @@ -22,7 +27,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Binary Sensor platform for ADS.""" ads_hub = hass.data.get(DATA_ADS) @@ -40,18 +50,13 @@ class AdsBinarySensor(AdsEntity, BinarySensorEntity): def __init__(self, ads_hub, name, ads_var, device_class): """Initialize ADS binary sensor.""" super().__init__(ads_hub, name, ads_var) - self._device_class = device_class or DEVICE_CLASS_MOVING + self._attr_device_class = device_class or BinarySensorDeviceClass.MOVING async def async_added_to_hass(self): """Register device notification.""" await self.async_initialize_device(self._ads_var, self._ads_hub.PLCTYPE_BOOL) @property - def is_on(self): + def is_on(self) -> bool: """Return True if the entity is on.""" return self._state_dict[STATE_KEY_STATE] - - @property - def device_class(self): - """Return the device class.""" - return self._device_class diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 5348873c7d080..636b0f77ef0bd 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -1,4 +1,6 @@ """Support for ADS covers.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.cover import ( @@ -12,7 +14,10 @@ CoverEntity, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( CONF_ADS_VAR, @@ -44,7 +49,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the cover platform for ADS.""" ads_hub = hass.data[DATA_ADS] @@ -91,13 +101,13 @@ def __init__( ): """Initialize AdsCover entity.""" super().__init__(ads_hub, name, ads_var_is_closed) - if self._ads_var is None: + if self._attr_unique_id is None: if ads_var_position is not None: - self._unique_id = ads_var_position + self._attr_unique_id = ads_var_position elif ads_var_pos_set is not None: - self._unique_id = ads_var_pos_set + self._attr_unique_id = ads_var_pos_set elif ads_var_open is not None: - self._unique_id = ads_var_open + self._attr_unique_id = ads_var_open self._state_dict[STATE_KEY_POSITION] = None self._ads_var_position = ads_var_position @@ -105,7 +115,12 @@ def __init__( self._ads_var_open = ads_var_open self._ads_var_close = ads_var_close self._ads_var_stop = ads_var_stop - self._device_class = device_class + self._attr_device_class = device_class + self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + if ads_var_stop is not None: + self._attr_supported_features |= SUPPORT_STOP + if ads_var_pos_set is not None: + self._attr_supported_features |= SUPPORT_SET_POSITION async def async_added_to_hass(self): """Register device notification.""" @@ -119,11 +134,6 @@ async def async_added_to_hass(self): self._ads_var_position, self._ads_hub.PLCTYPE_BYTE, STATE_KEY_POSITION ) - @property - def device_class(self): - """Return the class of this cover.""" - return self._device_class - @property def is_closed(self): """Return if the cover is closed.""" @@ -138,19 +148,6 @@ def current_cover_position(self): """Return current position of cover.""" return self._state_dict[STATE_KEY_POSITION] - @property - def supported_features(self): - """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - - if self._ads_var_stop is not None: - supported_features |= SUPPORT_STOP - - if self._ads_var_pos_set is not None: - supported_features |= SUPPORT_SET_POSITION - - return supported_features - def stop_cover(self, **kwargs): """Fire the stop action.""" if self._ads_var_stop: @@ -185,7 +182,7 @@ def close_cover(self, **kwargs): self.set_cover_position(position=0) @property - def available(self): + def available(self) -> bool: """Return False if state has not been updated yet.""" if self._ads_var is not None or self._ads_var_position is not None: return ( diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 80ee5df0c4b23..2508d4866658e 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -1,4 +1,6 @@ """Support for ADS light sources.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.light import ( @@ -8,7 +10,10 @@ LightEntity, ) from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( CONF_ADS_VAR, @@ -29,7 +34,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the light platform for ADS.""" ads_hub = hass.data.get(DATA_ADS) @@ -48,6 +58,8 @@ def __init__(self, ads_hub, ads_var_enable, ads_var_brightness, name): super().__init__(ads_hub, name, ads_var_enable) self._state_dict[STATE_KEY_BRIGHTNESS] = None self._ads_var_brightness = ads_var_brightness + if ads_var_brightness is not None: + self._attr_supported_features = SUPPORT_BRIGHTNESS async def async_added_to_hass(self): """Register device notification.""" @@ -61,19 +73,12 @@ async def async_added_to_hass(self): ) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of the light (0..255).""" return self._state_dict[STATE_KEY_BRIGHTNESS] @property - def supported_features(self): - """Flag supported features.""" - if self._ads_var_brightness is not None: - return SUPPORT_BRIGHTNESS - return 0 - - @property - def is_on(self): + def is_on(self) -> bool: """Return True if the entity is on.""" return self._state_dict[STATE_KEY_STATE] diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 933950dcf1b34..c0966535a841e 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -1,10 +1,15 @@ """Support for ADS sensors.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components import ads from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import CONF_ADS_FACTOR, CONF_ADS_TYPE, CONF_ADS_VAR, STATE_KEY_STATE, AdsEntity @@ -28,7 +33,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up an ADS sensor device.""" ads_hub = hass.data.get(ads.DATA_ADS) @@ -49,7 +59,7 @@ class AdsSensor(AdsEntity, SensorEntity): def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, factor): """Initialize AdsSensor entity.""" super().__init__(ads_hub, name, ads_var) - self._unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._ads_type = ads_type self._factor = factor @@ -63,11 +73,6 @@ async def async_added_to_hass(self): ) @property - def state(self): + def native_value(self) -> StateType: """Return the state of the device.""" return self._state_dict[STATE_KEY_STATE] - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement diff --git a/homeassistant/components/ads/services.yaml b/homeassistant/components/ads/services.yaml index 5139662a52209..f6458029fb498 100644 --- a/homeassistant/components/ads/services.yaml +++ b/homeassistant/components/ads/services.yaml @@ -15,7 +15,6 @@ write_data_by_name: name: ADS type description: The data type of the variable to write to. required: true - example: "int" selector: select: options: @@ -29,7 +28,6 @@ write_data_by_name: name: Value description: The value to write to the variable. required: true - example: 1 selector: number: min: 0 diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index 9f807899e540e..cea4655ca29c7 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -1,9 +1,14 @@ """Support for ADS switch platform.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity @@ -17,7 +22,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up switch platform for ADS.""" ads_hub = hass.data.get(DATA_ADS) @@ -35,7 +45,7 @@ async def async_added_to_hass(self): await self.async_initialize_device(self._ads_var, self._ads_hub.PLCTYPE_BOOL) @property - def is_on(self): + def is_on(self) -> bool: """Return True if the entity is on.""" return self._state_dict[STATE_KEY_STATE] diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index ad3a95123c750..12c5a4593c504 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -1,23 +1,31 @@ """Advantage Air climate integration.""" - from datetime import timedelta import logging from advantage_air import ApiError, advantage_air -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN ADVANTAGE_AIR_SYNC_INTERVAL = 15 -PLATFORMS = ["climate", "cover", "binary_sensor", "sensor", "switch"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Advantage Air config.""" ip_address = entry.data[CONF_IP_ADDRESS] port = entry.data[CONF_PORT] @@ -62,7 +70,7 @@ async def async_change(change): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Advantage Air Config.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index f7b295c963471..73b10b158b079 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -1,10 +1,14 @@ """Binary Sensor platform for Advantage Air integration.""" +from __future__ import annotations from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - DEVICE_CLASS_PROBLEM, + BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirEntity @@ -12,12 +16,16 @@ PARALLEL_UPDATES = 0 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AdvantageAir motion platform.""" instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] - entities = [] + entities: list[BinarySensorEntity] = [] for ac_key, ac_device in instance["coordinator"].data["aircons"].items(): entities.append(AdvantageAirZoneFilter(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): @@ -33,20 +41,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirZoneFilter(AdvantageAirEntity, BinarySensorEntity): """Advantage Air Filter.""" - @property - def name(self): - """Return the name.""" - return f'{self._ac["name"]} Filter' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-filter' + _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def device_class(self): - """Return the device class of the vent.""" - return DEVICE_CLASS_PROBLEM + def __init__(self, instance, ac_key): + """Initialize an Advantage Air Filter.""" + super().__init__(instance, ac_key) + self._attr_name = f'{self._ac["name"]} Filter' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-filter' + ) @property def is_on(self): @@ -57,46 +61,37 @@ def is_on(self): class AdvantageAirZoneMotion(AdvantageAirEntity, BinarySensorEntity): """Advantage Air Zone Motion.""" - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} Motion' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-motion' + _attr_device_class = BinarySensorDeviceClass.MOTION - @property - def device_class(self): - """Return the device class of the vent.""" - return DEVICE_CLASS_MOTION + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone Motion.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} Motion' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-motion' + ) @property def is_on(self): """Return if motion is detect.""" - return self._zone["motion"] + return self._zone["motion"] == 20 class AdvantageAirZoneMyZone(AdvantageAirEntity, BinarySensorEntity): """Advantage Air Zone MyZone.""" - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} MyZone' + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-myzone' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone MyZone.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} MyZone' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-myzone' + ) @property def is_on(self): """Return if this zone is the myZone.""" return self._zone["number"] == self._ac["myZone"] - - @property - def entity_registry_enabled_default(self): - """Return false to disable this entity by default.""" - return False diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 60caf15be25bc..165ecf9bb1cac 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -1,4 +1,5 @@ """Climate platform for Advantage Air integration.""" +from __future__ import annotations from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( @@ -6,16 +7,21 @@ FAN_HIGH, FAN_LOW, FAN_MEDIUM, + HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ADVANTAGE_AIR_STATE_CLOSE, @@ -31,9 +37,18 @@ "cool": HVAC_MODE_COOL, "vent": HVAC_MODE_FAN_ONLY, "dry": HVAC_MODE_DRY, + "myauto": HVAC_MODE_AUTO, } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} +AC_HVAC_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_DRY, +] + ADVANTAGE_AIR_FAN_MODES = { "auto": FAN_AUTO, "low": FAN_LOW, @@ -43,25 +58,22 @@ HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} -AC_HVAC_MODES = [ - HVAC_MODE_OFF, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_DRY, -] ADVANTAGE_AIR_SERVICE_SET_MYZONE = "set_myzone" -ZONE_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] +ZONE_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_HEAT_COOL] PARALLEL_UPDATES = 0 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AdvantageAir climate platform.""" instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] - entities = [] + entities: list[ClimateEntity] = [] for ac_key, ac_device in instance["coordinator"].data["aircons"].items(): entities.append(AdvantageAirAC(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): @@ -81,39 +93,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirClimateEntity(AdvantageAirEntity, ClimateEntity): """AdvantageAir Climate class.""" - @property - def temperature_unit(self): - """Return the temperature unit.""" - return TEMP_CELSIUS - - @property - def target_temperature_step(self): - """Return the supported temperature step.""" - return PRECISION_WHOLE - - @property - def max_temp(self): - """Return the maximum supported temperature.""" - return 32 - - @property - def min_temp(self): - """Return the minimum supported temperature.""" - return 16 + _attr_temperature_unit = TEMP_CELSIUS + _attr_target_temperature_step = PRECISION_WHOLE + _attr_max_temp = 32 + _attr_min_temp = 16 class AdvantageAirAC(AdvantageAirClimateEntity): """AdvantageAir AC unit.""" - @property - def name(self): - """Return the name.""" - return self._ac["name"] + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_hvac_modes = AC_HVAC_MODES + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}' + def __init__(self, instance, ac_key): + """Initialize an AdvantageAir AC unit.""" + super().__init__(instance, ac_key) + self._attr_name = self._ac["name"] + self._attr_unique_id = f'{self.coordinator.data["system"]["rid"]}-{ac_key}' + if self._ac.get("myAutoModeEnabled"): + self._attr_hvac_modes = AC_HVAC_MODES + [HVAC_MODE_AUTO] @property def target_temperature(self): @@ -127,26 +126,11 @@ def hvac_mode(self): return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"]) return HVAC_MODE_OFF - @property - def hvac_modes(self): - """Return the supported HVAC modes.""" - return AC_HVAC_MODES - @property def fan_mode(self): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) - @property - def fan_modes(self): - """Return the supported fan modes.""" - return [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] - - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - async def async_set_hvac_mode(self, hvac_mode): """Set the HVAC Mode and State.""" if hvac_mode == HVAC_MODE_OFF: @@ -180,15 +164,23 @@ async def async_set_temperature(self, **kwargs): class AdvantageAirZone(AdvantageAirClimateEntity): """AdvantageAir Zone control.""" - @property - def name(self): - """Return the name.""" - return self._zone["name"] + _attr_hvac_modes = ZONE_HVAC_MODES + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + + def __init__(self, instance, ac_key, zone_key): + """Initialize an AdvantageAir Zone control.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = self._zone["name"] + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' + ) @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}' + def hvac_mode(self): + """Return the current state as HVAC mode.""" + if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: + return HVAC_MODE_HEAT_COOL + return HVAC_MODE_OFF @property def current_temperature(self): @@ -200,23 +192,6 @@ def target_temperature(self): """Return the target temperature.""" return self._zone["setTemp"] - @property - def hvac_mode(self): - """Return the current HVAC modes.""" - if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: - return HVAC_MODE_FAN_ONLY - return HVAC_MODE_OFF - - @property - def hvac_modes(self): - """Return supported HVAC modes.""" - return ZONE_HVAC_MODES - - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_TARGET_TEMPERATURE - async def async_set_hvac_mode(self, hvac_mode): """Set the HVAC Mode and State.""" if hvac_mode == HVAC_MODE_OFF: diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 69d66849cd61e..a54d6d8b53507 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -1,13 +1,15 @@ """Cover platform for Advantage Air integration.""" - from homeassistant.components.cover import ( ATTR_POSITION, - DEVICE_CLASS_DAMPER, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, + CoverDeviceClass, CoverEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ADVANTAGE_AIR_STATE_CLOSE, @@ -19,7 +21,11 @@ PARALLEL_UPDATES = 0 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AdvantageAir cover platform.""" instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] @@ -36,25 +42,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): """Advantage Air Cover Class.""" - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]}' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}' - - @property - def device_class(self): - """Return the device class of the vent.""" - return DEVICE_CLASS_DAMPER + _attr_device_class = CoverDeviceClass.DAMPER + _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Cover Class.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]}' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' + ) @property def is_closed(self): diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index ea20368c10fde..9514cc7915b5d 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -1,5 +1,6 @@ """Advantage Air parent entity class.""" +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -14,6 +15,13 @@ def __init__(self, instance, ac_key, zone_key=None): self.async_change = instance["async_change"] self.ac_key = ac_key self.zone_key = zone_key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])}, + manufacturer="Advantage Air", + model=self.coordinator.data["system"]["sysType"], + name=self.coordinator.data["system"]["name"], + sw_version=self.coordinator.data["system"]["myAppRev"], + ) @property def _ac(self): @@ -22,14 +30,3 @@ def _ac(self): @property def _zone(self): return self.coordinator.data["aircons"][self.ac_key]["zones"][self.zone_key] - - @property - def device_info(self): - """Return parent device information.""" - return { - "identifiers": {(DOMAIN, self.coordinator.data["system"]["rid"])}, - "name": self.coordinator.data["system"]["name"], - "manufacturer": "Advantage Air", - "model": self.coordinator.data["system"]["sysType"], - "sw_version": self.coordinator.data["system"]["myAppRev"], - } diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index 750d5457e17ab..6390ccea39c3d 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -3,8 +3,12 @@ "name": "Advantage Air", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/advantage_air", - "codeowners": ["@Bre77"], - "requirements": ["advantage_air==0.2.1"], + "codeowners": [ + "@Bre77" + ], + "requirements": [ + "advantage_air==0.2.5" + ], "quality_scale": "platinum", "iot_class": "local_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py new file mode 100644 index 0000000000000..ecc612ae1edd4 --- /dev/null +++ b/homeassistant/components/advantage_air/select.py @@ -0,0 +1,59 @@ +"""Select platform for Advantage Air integration.""" +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN +from .entity import AdvantageAirEntity + +ADVANTAGE_AIR_INACTIVE = "Inactive" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AdvantageAir toggle platform.""" + + instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + + entities = [] + for ac_key in instance["coordinator"].data["aircons"]: + entities.append(AdvantageAirMyZone(instance, ac_key)) + async_add_entities(entities) + + +class AdvantageAirMyZone(AdvantageAirEntity, SelectEntity): + """Representation of Advantage Air MyZone control.""" + + _attr_icon = "mdi:home-thermometer" + _attr_options = [ADVANTAGE_AIR_INACTIVE] + _number_to_name = {0: ADVANTAGE_AIR_INACTIVE} + _name_to_number = {ADVANTAGE_AIR_INACTIVE: 0} + + def __init__(self, instance, ac_key): + """Initialize an Advantage Air MyZone control.""" + super().__init__(instance, ac_key) + self._attr_name = f'{self._ac["name"]} MyZone' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-myzone' + ) + + for zone in instance["coordinator"].data["aircons"][ac_key]["zones"].values(): + if zone["type"] > 0: + self._name_to_number[zone["name"]] = zone["number"] + self._number_to_name[zone["number"]] = zone["name"] + self._attr_options.append(zone["name"]) + + @property + def current_option(self): + """Return the fresh air status.""" + return self._number_to_name[self._ac["myZone"]] + + async def async_select_option(self, option): + """Set the MyZone.""" + await self.async_change( + {self.ac_key: {"info": {"myZone": self._name_to_number[option]}}} + ) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 8f027b1bdafcb..8055c37a57193 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,9 +1,19 @@ """Sensor platform for Advantage Air integration.""" +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import PERCENTAGE +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirEntity @@ -15,19 +25,24 @@ PARALLEL_UPDATES = 0 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AdvantageAir sensor platform.""" instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] - entities = [] + entities: list[SensorEntity] = [] for ac_key, ac_device in instance["coordinator"].data["aircons"].items(): entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) for zone_key, zone in ac_device["zones"].items(): - # Only show damper sensors when zone is in temperature control + # Only show damper and temp sensors when zone is in temperature control if zone["type"] != 0: entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) + entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key)) # Only show wireless signal strength sensors when using wireless sensors if zone["rssi"] > 0: entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key)) @@ -44,32 +59,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air timer control.""" + _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__(self, instance, ac_key, action): """Initialize the Advantage Air timer control.""" super().__init__(instance, ac_key) self.action = action - self._time_key = f"countDownTo{self.action}" - - @property - def name(self): - """Return the name.""" - return f'{self._ac["name"]} Time To {self.action}' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-timeto{self.action}' + self._time_key = f"countDownTo{action}" + self._attr_name = f'{self._ac["name"]} Time To {action}' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-timeto{action}' + ) @property - def state(self): + def native_value(self): """Return the current value.""" return self._ac[self._time_key] - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return ADVANTAGE_AIR_SET_COUNTDOWN_UNIT - @property def icon(self): """Return a representative icon of the timer.""" @@ -86,28 +93,25 @@ async def set_time_to(self, **kwargs): class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone Vent Sensor.""" - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} Vent' + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-vent' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone Vent Sensor.""" + super().__init__(instance, ac_key, zone_key=zone_key) + self._attr_name = f'{self._zone["name"]} Vent' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-vent' + ) @property - def state(self): + def native_value(self): """Return the current value of the air vent.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: return self._zone["value"] return 0 - @property - def unit_of_measurement(self): - """Return the percent sign.""" - return PERCENTAGE - @property def icon(self): """Return a representative icon.""" @@ -119,26 +123,23 @@ def icon(self): class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone wireless signal sensor.""" - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} Signal' + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-signal' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone wireless signal sensor.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} Signal' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-signal' + ) @property - def state(self): + def native_value(self): """Return the current value of the wireless signal.""" return self._zone["rssi"] - @property - def unit_of_measurement(self): - """Return the percent sign.""" - return PERCENTAGE - @property def icon(self): """Return a representative icon.""" @@ -151,3 +152,26 @@ def icon(self): if self._zone["rssi"] >= 20: return "mdi:wifi-strength-1" return "mdi:wifi-strength-outline" + + +class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): + """Representation of Advantage Air Zone temperature sensor.""" + + _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone Temp Sensor.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} Temperature' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-temp' + ) + + @property + def native_value(self): + """Return the current value of the measured temperature.""" + return self._zone["measuredTemp"] diff --git a/homeassistant/components/advantage_air/services.yaml b/homeassistant/components/advantage_air/services.yaml index 24088421c9987..33f39065f8a76 100644 --- a/homeassistant/components/advantage_air/services.yaml +++ b/homeassistant/components/advantage_air/services.yaml @@ -10,7 +10,6 @@ set_time_to: name: Minutes description: Minutes until action required: true - example: "60" selector: number: min: 0 diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 6c687c1427ee4..90c3008232939 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -1,6 +1,8 @@ """Switch platform for Advantage Air integration.""" - +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ADVANTAGE_AIR_STATE_OFF, @@ -10,7 +12,11 @@ from .entity import AdvantageAirEntity -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AdvantageAir toggle platform.""" instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] @@ -25,26 +31,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirFreshAir(AdvantageAirEntity, ToggleEntity): """Representation of Advantage Air fresh air control.""" - @property - def name(self): - """Return the name.""" - return f'{self._ac["name"]} Fresh Air' + _attr_icon = "mdi:air-filter" - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-freshair' + def __init__(self, instance, ac_key): + """Initialize an Advantage Air fresh air control.""" + super().__init__(instance, ac_key) + self._attr_name = f'{self._ac["name"]} Fresh Air' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-freshair' + ) @property def is_on(self): """Return the fresh air status.""" return self._ac["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - @property - def icon(self): - """Return a representative icon of the fresh air switch.""" - return "mdi:air-filter" - async def async_turn_on(self, **kwargs): """Turn fresh air on.""" await self.async_change( diff --git a/homeassistant/components/advantage_air/translations/bg.json b/homeassistant/components/advantage_air/translations/bg.json new file mode 100644 index 0000000000000..2293e4d4c5319 --- /dev/null +++ b/homeassistant/components/advantage_air/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/translations/de.json b/homeassistant/components/advantage_air/translations/de.json index c761ac5c6bebb..0d3ead73fc02b 100644 --- a/homeassistant/components/advantage_air/translations/de.json +++ b/homeassistant/components/advantage_air/translations/de.json @@ -9,10 +9,10 @@ "step": { "user": { "data": { - "ip_address": "IP Adresse", + "ip_address": "IP-Adresse", "port": "Port" }, - "description": "Anschluss an die API Ihres Advantage Air Wandtabletts.", + "description": "Anschluss an die API deines Advantage Air Wandtabletts.", "title": "Verbinden" } } diff --git a/homeassistant/components/advantage_air/translations/es-419.json b/homeassistant/components/advantage_air/translations/es-419.json index f2f9a463527f6..502e9e00ddb93 100644 --- a/homeassistant/components/advantage_air/translations/es-419.json +++ b/homeassistant/components/advantage_air/translations/es-419.json @@ -2,6 +2,7 @@ "config": { "step": { "user": { + "description": "Con\u00e9ctese a la API de su tableta de pared Advantage Air.", "title": "Conectar" } } diff --git a/homeassistant/components/advantage_air/translations/he.json b/homeassistant/components/advantage_air/translations/he.json new file mode 100644 index 0000000000000..7c534baa9778e --- /dev/null +++ b/homeassistant/components/advantage_air/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", + "port": "\u05e4\u05ea\u05d7\u05d4" + }, + "description": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc- API \u05e9\u05dc \u05d4\u05d8\u05d0\u05d1\u05dc\u05d8 \u05e9\u05dc\u05da \u05d4\u05de\u05d5\u05ea\u05e7\u05df \u05e2\u05dc \u05d4\u05e7\u05d9\u05e8.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/translations/ja.json b/homeassistant/components/advantage_air/translations/ja.json new file mode 100644 index 0000000000000..9cc6ef6973744 --- /dev/null +++ b/homeassistant/components/advantage_air/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Advantage Air wall mounted tablet\u306eAPI\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002", + "title": "\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/translations/tr.json b/homeassistant/components/advantage_air/translations/tr.json index db639c593764b..678f9ec8cbcc7 100644 --- a/homeassistant/components/advantage_air/translations/tr.json +++ b/homeassistant/components/advantage_air/translations/tr.json @@ -9,9 +9,10 @@ "step": { "user": { "data": { - "ip_address": "\u0130p Adresi", + "ip_address": "IP Adresi", "port": "Port" }, + "description": "Advantage Air duvara monte tabletinizin API'sine ba\u011flan\u0131n.", "title": "Ba\u011flan" } } diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index a4a0526062db3..a914a23a0da65 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -7,42 +7,56 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -from .const import DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, PLATFORMS +from .const import ( + CONF_STATION_UPDATES, + DOMAIN, + ENTRY_NAME, + ENTRY_WEATHER_COORDINATOR, + PLATFORMS, +) from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AEMET OpenData as config entry.""" - name = config_entry.data[CONF_NAME] - api_key = config_entry.data[CONF_API_KEY] - latitude = config_entry.data[CONF_LATITUDE] - longitude = config_entry.data[CONF_LONGITUDE] + name = entry.data[CONF_NAME] + api_key = entry.data[CONF_API_KEY] + latitude = entry.data[CONF_LATITUDE] + longitude = entry.data[CONF_LONGITUDE] + station_updates = entry.options.get(CONF_STATION_UPDATES, True) aemet = AEMET(api_key) - weather_coordinator = WeatherUpdateCoordinator(hass, aemet, latitude, longitude) + weather_coordinator = WeatherUpdateCoordinator( + hass, aemet, latitude, longitude, station_updates + ) await weather_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = { + hass.data[DOMAIN][entry.entry_id] = { ENTRY_NAME: name, ENTRY_WEATHER_COORDINATOR: weather_coordinator, } - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/aemet/abstract_aemet_sensor.py b/homeassistant/components/aemet/abstract_aemet_sensor.py deleted file mode 100644 index 8847a5d094d48..0000000000000 --- a/homeassistant/components/aemet/abstract_aemet_sensor.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Abstraction form AEMET OpenData sensors.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ATTRIBUTION, SENSOR_DEVICE_CLASS, SENSOR_NAME, SENSOR_UNIT -from .weather_update_coordinator import WeatherUpdateCoordinator - - -class AbstractAemetSensor(CoordinatorEntity, SensorEntity): - """Abstract class for an AEMET OpenData sensor.""" - - def __init__( - self, - name, - unique_id, - sensor_type, - sensor_configuration, - coordinator: WeatherUpdateCoordinator, - ): - """Initialize the sensor.""" - super().__init__(coordinator) - self._name = name - self._unique_id = unique_id - self._sensor_type = sensor_type - self._sensor_name = sensor_configuration[SENSOR_NAME] - self._unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) - self._device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name}" - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def device_class(self): - """Return the device_class.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index f40725c618292..6c97ca98cb86f 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -4,9 +4,10 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -47,6 +48,35 @@ async def async_step_user(self, user_input=None): return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for AEMET.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_STATION_UPDATES, + default=self.config_entry.options.get(CONF_STATION_UPDATES), + ): bool, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + async def _is_aemet_api_online(hass, api_key): aemet = AEMET(api_key) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 390ccb860034a..4be90011f5a14 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,5 +1,11 @@ """Constant values for the AEMET OpenData component.""" +from __future__ import annotations +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -22,27 +28,21 @@ ) from homeassistant.const import ( DEGREE, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, PERCENTAGE, PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + Platform, ) ATTRIBUTION = "Powered by AEMET OpenData" -PLATFORMS = ["sensor", "weather"] +CONF_STATION_UPDATES = "station_updates" +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] DEFAULT_NAME = "AEMET" DOMAIN = "aemet" ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" -UPDATE_LISTENER = "update_listener" -SENSOR_NAME = "sensor_name" -SENSOR_UNIT = "sensor_unit" -SENSOR_DEVICE_CLASS = "sensor_device_class" ATTR_API_CONDITION = "condition" ATTR_API_FORECAST_DAILY = "forecast-daily" @@ -200,118 +200,154 @@ FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } -FORECAST_SENSOR_TYPES = { - ATTR_FORECAST_CONDITION: { - SENSOR_NAME: "Condition", - }, - ATTR_FORECAST_PRECIPITATION: { - SENSOR_NAME: "Precipitation", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: { - SENSOR_NAME: "Precipitation probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_FORECAST_TEMP: { - SENSOR_NAME: "Temperature", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_FORECAST_TEMP_LOW: { - SENSOR_NAME: "Temperature Low", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_FORECAST_TIME: { - SENSOR_NAME: "Time", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_FORECAST_WIND_BEARING: { - SENSOR_NAME: "Wind bearing", - SENSOR_UNIT: DEGREE, - }, - ATTR_FORECAST_WIND_SPEED: { - SENSOR_NAME: "Wind speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, -} -WEATHER_SENSOR_TYPES = { - ATTR_API_CONDITION: { - SENSOR_NAME: "Condition", - }, - ATTR_API_HUMIDITY: { - SENSOR_NAME: "Humidity", - SENSOR_UNIT: PERCENTAGE, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - ATTR_API_PRESSURE: { - SENSOR_NAME: "Pressure", - SENSOR_UNIT: PRESSURE_HPA, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - }, - ATTR_API_RAIN: { - SENSOR_NAME: "Rain", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_API_RAIN_PROB: { - SENSOR_NAME: "Rain probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_SNOW: { - SENSOR_NAME: "Snow", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_API_SNOW_PROB: { - SENSOR_NAME: "Snow probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_STATION_ID: { - SENSOR_NAME: "Station ID", - }, - ATTR_API_STATION_NAME: { - SENSOR_NAME: "Station name", - }, - ATTR_API_STATION_TIMESTAMP: { - SENSOR_NAME: "Station timestamp", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_API_STORM_PROB: { - SENSOR_NAME: "Storm probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_TEMPERATURE: { - SENSOR_NAME: "Temperature", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_API_TEMPERATURE_FEELING: { - SENSOR_NAME: "Temperature feeling", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_API_TOWN_ID: { - SENSOR_NAME: "Town ID", - }, - ATTR_API_TOWN_NAME: { - SENSOR_NAME: "Town name", - }, - ATTR_API_TOWN_TIMESTAMP: { - SENSOR_NAME: "Town timestamp", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_API_WIND_BEARING: { - SENSOR_NAME: "Wind bearing", - SENSOR_UNIT: DEGREE, - }, - ATTR_API_WIND_MAX_SPEED: { - SENSOR_NAME: "Wind max speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, - ATTR_API_WIND_SPEED: { - SENSOR_NAME: "Wind speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, -} +FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_FORECAST_CONDITION, + name="Condition", + ), + SensorEntityDescription( + key=ATTR_FORECAST_PRECIPITATION, + name="Precipitation", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + name="Precipitation probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TEMP_LOW, + name="Temperature Low", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TIME, + name="Time", + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_FORECAST_WIND_BEARING, + name="Wind bearing", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_WIND_SPEED, + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), +) +WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_CONDITION, + name="Condition", + ), + SensorEntityDescription( + key=ATTR_API_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_RAIN, + name="Rain", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_RAIN_PROB, + name="Rain probability", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_SNOW, + name="Snow", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_SNOW_PROB, + name="Snow probability", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_STATION_ID, + name="Station ID", + ), + SensorEntityDescription( + key=ATTR_API_STATION_NAME, + name="Station name", + ), + SensorEntityDescription( + key=ATTR_API_STATION_TIMESTAMP, + name="Station timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_API_STORM_PROB, + name="Storm probability", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_TEMPERATURE_FEELING, + name="Temperature feeling", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_TOWN_ID, + name="Town ID", + ), + SensorEntityDescription( + key=ATTR_API_TOWN_NAME, + name="Town name", + ), + SensorEntityDescription( + key=ATTR_API_TOWN_TIMESTAMP, + name="Town timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_API_WIND_BEARING, + name="Wind bearing", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_WIND_MAX_SPEED, + name="Wind max speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_WIND_SPEED, + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), +) WIND_BEARING_MAP = { "C": None, diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 26f9139aa9e57..8f33e9dbf0378 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -3,7 +3,7 @@ "name": "AEMET OpenData", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aemet", - "requirements": ["AEMET-OpenData==0.1.8"], + "requirements": ["AEMET-OpenData==0.2.1"], "codeowners": ["@noltari"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 6f43d66e011a5..685e9fb200bbb 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -1,6 +1,12 @@ """Support for the AEMET OpenData service.""" -from .abstract_aemet_sensor import AbstractAemetSensor +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.update_coordinator import CoordinatorEntity + from .const import ( + ATTRIBUTION, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, @@ -21,41 +27,53 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = domain_data[ENTRY_NAME] weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] - weather_sensor_types = WEATHER_SENSOR_TYPES - forecast_sensor_types = FORECAST_SENSOR_TYPES - - entities = [] - for sensor_type in MONITORED_CONDITIONS: - unique_id = f"{config_entry.unique_id}-{sensor_type}" - entities.append( - AemetSensor( - name, - unique_id, - sensor_type, - weather_sensor_types[sensor_type], + unique_id = config_entry.unique_id + entities: list[AbstractAemetSensor] = [ + AemetSensor(name, unique_id, weather_coordinator, description) + for description in WEATHER_SENSOR_TYPES + if description.key in MONITORED_CONDITIONS + ] + entities.extend( + [ + AemetForecastSensor( + name_prefix, + unique_id_prefix, weather_coordinator, + mode, + description, ) - ) - - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - - for sensor_type in FORECAST_MONITORED_CONDITIONS: - unique_id = f"{config_entry.unique_id}-forecast-{mode}-{sensor_type}" - entities.append( - AemetForecastSensor( - f"{name} Forecast", - unique_id, - sensor_type, - forecast_sensor_types[sensor_type], - weather_coordinator, - mode, - ) + for mode in FORECAST_MODES + if ( + (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") + and (unique_id_prefix := f"{unique_id}-forecast-{mode}") ) + for description in FORECAST_SENSOR_TYPES + if description.key in FORECAST_MONITORED_CONDITIONS + ] + ) async_add_entities(entities) +class AbstractAemetSensor(CoordinatorEntity, SensorEntity): + """Abstract class for an AEMET OpenData sensor.""" + + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__( + self, + name, + unique_id, + coordinator: WeatherUpdateCoordinator, + description: SensorEntityDescription, + ): + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + + class AemetSensor(AbstractAemetSensor): """Implementation of an AEMET OpenData sensor.""" @@ -63,20 +81,21 @@ def __init__( self, name, unique_id, - sensor_type, - sensor_configuration, weather_coordinator: WeatherUpdateCoordinator, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, weather_coordinator + name=name, + unique_id=f"{unique_id}-{description.key}", + coordinator=weather_coordinator, + description=description, ) - self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" - return self._weather_coordinator.data.get(self._sensor_type) + return self.coordinator.data.get(self.entity_description.key) class AemetForecastSensor(AbstractAemetSensor): @@ -86,30 +105,29 @@ def __init__( self, name, unique_id, - sensor_type, - sensor_configuration, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, weather_coordinator + name=name, + unique_id=f"{unique_id}-{description.key}", + coordinator=weather_coordinator, + description=description, ) - self._weather_coordinator = weather_coordinator self._forecast_mode = forecast_mode + self._attr_entity_registry_enabled_default = ( + self._forecast_mode == FORECAST_MODE_DAILY + ) @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._forecast_mode == FORECAST_MODE_DAILY - - @property - def state(self): + def native_value(self): """Return the state of the device.""" forecast = None - forecasts = self._weather_coordinator.data.get( + forecasts = self.coordinator.data.get( FORECAST_MODE_ATTR_API[self._forecast_mode] ) if forecasts: - forecast = forecasts[0].get(self._sensor_type) + forecast = forecasts[0].get(self.entity_description.key) return forecast diff --git a/homeassistant/components/aemet/strings.json b/homeassistant/components/aemet/strings.json index a25a503bade51..360f7c680ea20 100644 --- a/homeassistant/components/aemet/strings.json +++ b/homeassistant/components/aemet/strings.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Gather data from AEMET weather stations" + } + } + } } } diff --git a/homeassistant/components/aemet/translations/ar.json b/homeassistant/components/aemet/translations/ar.json new file mode 100644 index 0000000000000..68ba2eda2f2f0 --- /dev/null +++ b/homeassistant/components/aemet/translations/ar.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "station_updates": "\u062c\u0645\u0639 \u0627\u0644\u0628\u064a\u0627\u0646\u0627\u062a \u0645\u0646 \u0645\u062d\u0637\u0627\u062a \u0627\u0644\u0637\u0642\u0633 AEMET" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/bg.json b/homeassistant/components/aemet/translations/bg.json new file mode 100644 index 0000000000000..62d0a34441ad5 --- /dev/null +++ b/homeassistant/components/aemet/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ca.json b/homeassistant/components/aemet/translations/ca.json index 85b22e72d76f9..75784ddfc8789 100644 --- a/homeassistant/components/aemet/translations/ca.json +++ b/homeassistant/components/aemet/translations/ca.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Obt\u00e9 les dades de les estacions meteorol\u00f2giques d'AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/de.json b/homeassistant/components/aemet/translations/de.json index d531280572296..0704e7d71ba63 100644 --- a/homeassistant/components/aemet/translations/de.json +++ b/homeassistant/components/aemet/translations/de.json @@ -15,7 +15,16 @@ "name": "Name der Integration" }, "description": "Richte die AEMET OpenData Integration ein. Um den API-Schl\u00fcssel zu generieren, besuche https://opendata.aemet.es/centrodedescargas/altaUsuario", - "title": "[void]" + "title": "AEMET OpenData" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Sammeln von Daten von AEMET-Wetterstationen" + } } } } diff --git a/homeassistant/components/aemet/translations/en.json b/homeassistant/components/aemet/translations/en.json index 60e7f5f2ec22c..3888ccdafc064 100644 --- a/homeassistant/components/aemet/translations/en.json +++ b/homeassistant/components/aemet/translations/en.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Gather data from AEMET weather stations" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/es-419.json b/homeassistant/components/aemet/translations/es-419.json index 4b3db0a8833bc..3a02d682f34e9 100644 --- a/homeassistant/components/aemet/translations/es-419.json +++ b/homeassistant/components/aemet/translations/es-419.json @@ -5,8 +5,18 @@ "data": { "name": "Nombre de la integraci\u00f3n" }, + "description": "Configure la integraci\u00f3n de AEMET OpenData. Para generar la clave API vaya a https://opendata.aemet.es/centrodedescargas/altaUsuario", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Recopile datos de las estaciones meteorol\u00f3gicas de AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/es.json b/homeassistant/components/aemet/translations/es.json index ffe4d524754c0..558d87886d903 100644 --- a/homeassistant/components/aemet/translations/es.json +++ b/homeassistant/components/aemet/translations/es.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Obtener datos de las estaciones meteorol\u00f3gicas de AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/et.json b/homeassistant/components/aemet/translations/et.json index bc0a26179d56a..0d542fcc744e0 100644 --- a/homeassistant/components/aemet/translations/et.json +++ b/homeassistant/components/aemet/translations/et.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Koguandmeid AEMETi ilmajaamadest" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/fr.json b/homeassistant/components/aemet/translations/fr.json index bb1e792aa5e50..4ad76320f032e 100644 --- a/homeassistant/components/aemet/translations/fr.json +++ b/homeassistant/components/aemet/translations/fr.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Recueillir les donn\u00e9es des stations m\u00e9t\u00e9orologiques AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/he.json b/homeassistant/components/aemet/translations/he.json new file mode 100644 index 0000000000000..5a7d693afecf6 --- /dev/null +++ b/homeassistant/components/aemet/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/hu.json b/homeassistant/components/aemet/translations/hu.json index d810691046e54..31a7654efd9f1 100644 --- a/homeassistant/components/aemet/translations/hu.json +++ b/homeassistant/components/aemet/translations/hu.json @@ -14,8 +14,18 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "Az integr\u00e1ci\u00f3 neve" }, + "description": "\u00c1ll\u00edtsa be az AEMET OpenData integr\u00e1ci\u00f3t. Az API-kulcs el\u0151\u00e1ll\u00edt\u00e1s\u00e1hoz keresse fel a https://opendata.aemet.es/centrodedescargas/altaUsuario webhelyet.", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Gy\u0171jts\u00f6n adatokat az AEMET meteorol\u00f3giai \u00e1llom\u00e1sokr\u00f3l" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/id.json b/homeassistant/components/aemet/translations/id.json index fa678cbbbe0b2..e3a602a9a7cfd 100644 --- a/homeassistant/components/aemet/translations/id.json +++ b/homeassistant/components/aemet/translations/id.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Kumpulkan data dari stasiun cuaca AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/it.json b/homeassistant/components/aemet/translations/it.json index 112630028b968..a55e003ca4e83 100644 --- a/homeassistant/components/aemet/translations/it.json +++ b/homeassistant/components/aemet/translations/it.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Raccogli i dati dalle stazioni meteorologiche AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ja.json b/homeassistant/components/aemet/translations/ja.json new file mode 100644 index 0000000000000..a3a0904c2235d --- /dev/null +++ b/homeassistant/components/aemet/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u540d\u524d" + }, + "description": "AEMET OpenData\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002 API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://opendata.aemet.es/centrodedescargas/altaUsuario \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "AEMET OpenData" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "AEMET weather station\u304b\u3089\u30c7\u30fc\u30bf\u3092\u53ce\u96c6\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/nl.json b/homeassistant/components/aemet/translations/nl.json index 77589e2049071..40fab5d9e0f5d 100644 --- a/homeassistant/components/aemet/translations/nl.json +++ b/homeassistant/components/aemet/translations/nl.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Verzamel gegevens van AEMET-weerstations" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/no.json b/homeassistant/components/aemet/translations/no.json index 48cbc9916caed..fe36ff835ee92 100644 --- a/homeassistant/components/aemet/translations/no.json +++ b/homeassistant/components/aemet/translations/no.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Samle inn data fra AEMET v\u00e6rstasjoner" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/pl.json b/homeassistant/components/aemet/translations/pl.json index 2c5c24fae2aa6..8531ca47fd6b7 100644 --- a/homeassistant/components/aemet/translations/pl.json +++ b/homeassistant/components/aemet/translations/pl.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Zbieraj dane ze stacji pogodowych AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ru.json b/homeassistant/components/aemet/translations/ru.json index 4da9a032d2b56..f9278af712b85 100644 --- a/homeassistant/components/aemet/translations/ru.json +++ b/homeassistant/components/aemet/translations/ru.json @@ -14,9 +14,18 @@ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 AEMET OpenData. \u0427\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043d\u0430 https://opendata.aemet.es/centrodedescargas/altaUsuario.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 AEMET OpenData. \u0427\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043d\u0430 https://opendata.aemet.es/centrodedescargas/altaUsuario.", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "\u0421\u0431\u043e\u0440 \u0434\u0430\u043d\u043d\u044b\u0445 \u0441 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0439 AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/tr.json b/homeassistant/components/aemet/translations/tr.json new file mode 100644 index 0000000000000..7cb0048b0e0ce --- /dev/null +++ b/homeassistant/components/aemet/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Entegrasyonun ad\u0131" + }, + "description": "AEMET OpenData entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://opendata.aemet.es/centrodedescargas/altaUsuario adresine gidin.", + "title": "AEMET OpenData" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "AEMET hava istasyonlar\u0131ndan veri toplay\u0131n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/zh-Hant.json b/homeassistant/components/aemet/translations/zh-Hant.json index 75b251ae2ff5f..e064a6c01921d 100644 --- a/homeassistant/components/aemet/translations/zh-Hant.json +++ b/homeassistant/components/aemet/translations/zh-Hant.json @@ -4,19 +4,28 @@ "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" }, "error": { - "invalid_api_key": "API \u5bc6\u9470\u7121\u6548" + "invalid_api_key": "API \u91d1\u9470\u7121\u6548" }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6", "name": "\u6574\u5408\u540d\u7a31" }, - "description": "\u6b32\u8a2d\u5b9a AEMET OpenData \u6574\u5408\u3002\u8acb\u81f3 https://opendata.aemet.es/centrodedescargas/altaUsuario \u7522\u751f API \u5bc6\u9470", + "description": "\u6b32\u8a2d\u5b9a AEMET OpenData \u6574\u5408\u3002\u8acb\u81f3 https://opendata.aemet.es/centrodedescargas/altaUsuario \u7522\u751f API \u91d1\u9470", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "\u81ea AEMET \u6c23\u8c61\u7ad9\u7372\u5f97\u8cc7\u6599" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index e54a297cc091e..07bb0bfba8364 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -39,6 +39,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AemetWeather(CoordinatorEntity, WeatherEntity): """Implementation of an AEMET OpenData sensor.""" + _attr_attribution = ATTRIBUTION + _attr_temperature_unit = TEMP_CELSIUS + def __init__( self, name, @@ -48,25 +51,18 @@ def __init__( ): """Initialize the sensor.""" super().__init__(coordinator) - self._name = name - self._unique_id = unique_id self._forecast_mode = forecast_mode - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION + self._attr_entity_registry_enabled_default = ( + self._forecast_mode == FORECAST_MODE_DAILY + ) + self._attr_name = name + self._attr_unique_id = unique_id @property def condition(self): """Return the current condition.""" return self.coordinator.data[ATTR_API_CONDITION] - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._forecast_mode == FORECAST_MODE_DAILY - @property def forecast(self): """Return the forecast array.""" @@ -77,11 +73,6 @@ def humidity(self): """Return the humidity.""" return self.coordinator.data[ATTR_API_HUMIDITY] - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def pressure(self): """Return the pressure.""" @@ -92,16 +83,6 @@ def temperature(self): """Return the temperature.""" return self.coordinator.data[ATTR_API_TEMPERATURE] - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id - @property def wind_bearing(self): """Return the temperature.""" diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 7aab23488b56d..d791158b9deab 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -1,4 +1,6 @@ """Weather data coordinator for the AEMET OpenData service.""" +from __future__ import annotations + from dataclasses import dataclass, field from datetime import timedelta import logging @@ -95,7 +97,7 @@ def format_condition(condition: str) -> str: return condition -def format_float(value) -> float: +def format_float(value) -> float | None: """Try converting string to float.""" try: return float(value) @@ -103,7 +105,7 @@ def format_float(value) -> float: return None -def format_int(value) -> int: +def format_int(value) -> int | None: """Try converting string to int.""" try: return int(value) @@ -118,7 +120,7 @@ class TownNotFound(UpdateFailed): class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" - def __init__(self, hass, aemet, latitude, longitude): + def __init__(self, hass, aemet, latitude, longitude, station_updates): """Initialize coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL @@ -129,6 +131,7 @@ def __init__(self, hass, aemet, latitude, longitude): self._town = None self._latitude = latitude self._longitude = longitude + self._station_updates = station_updates self._data = { "daily": None, "hourly": None, @@ -137,7 +140,7 @@ def __init__(self, hass, aemet, latitude, longitude): async def _async_update_data(self): data = {} - with async_timeout.timeout(120): + async with async_timeout.timeout(120): weather_response = await self._get_aemet_weather() data = self._convert_weather_response(weather_response) return data @@ -210,7 +213,7 @@ def _get_weather_and_forecast(self): ) station = None - if self._get_weather_station(): + if self._station_updates and self._get_weather_station(): station = self._aemet.get_conventional_observation_station_data( self._station[AEMET_ATTR_IDEMA] ) @@ -395,8 +398,7 @@ def _get_hourly_forecast_from_weather_response(self, weather_response, now): return None def _convert_forecast_day(self, date, day): - condition = self._get_condition_day(day) - if not condition: + if not (condition := self._get_condition_day(day)): return None return { @@ -412,8 +414,7 @@ def _convert_forecast_day(self, date, day): } def _convert_forecast_hour(self, date, day, hour): - condition = self._get_condition(day, hour) - if not condition: + if not (condition := self._get_condition(day, hour)): return None forecast_dt = date.replace(hour=hour, minute=0, second=0) @@ -432,13 +433,8 @@ def _convert_forecast_hour(self, date, day, hour): def _calc_precipitation(self, day, hour): """Calculate the precipitation.""" - rain_value = self._get_rain(day, hour) - if not rain_value: - rain_value = 0 - - snow_value = self._get_snow(day, hour) - if not snow_value: - snow_value = 0 + rain_value = self._get_rain(day, hour) or 0 + snow_value = self._get_snow(day, hour) or 0 if round(rain_value + snow_value, 1) == 0: return None @@ -446,13 +442,8 @@ def _calc_precipitation(self, day, hour): def _calc_precipitation_prob(self, day, hour): """Calculate the precipitation probability (hour).""" - rain_value = self._get_rain_prob(day, hour) - if not rain_value: - rain_value = 0 - - snow_value = self._get_snow_prob(day, hour) - if not snow_value: - snow_value = 0 + rain_value = self._get_rain_prob(day, hour) or 0 + snow_value = self._get_snow_prob(day, hour) or 0 if rain_value == 0 and snow_value == 0: return None diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py index ef7d2397daf3f..d0176cde15daf 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -1,2 +1,42 @@ """Constants for the Aftership integration.""" -DOMAIN = "aftership" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +DOMAIN: Final = "aftership" + +ATTRIBUTION: Final = "Information provided by AfterShip" +ATTR_TRACKINGS: Final = "trackings" + +BASE: Final = "https://track.aftership.com/" + +CONF_SLUG: Final = "slug" +CONF_TITLE: Final = "title" +CONF_TRACKING_NUMBER: Final = "tracking_number" + +DEFAULT_NAME: Final = "aftership" +UPDATE_TOPIC: Final = f"{DOMAIN}_update" + +ICON: Final = "mdi:package-variant-closed" + +MIN_TIME_BETWEEN_UPDATES: Final = timedelta(minutes=15) + +SERVICE_ADD_TRACKING: Final = "add_tracking" +SERVICE_REMOVE_TRACKING: Final = "remove_tracking" + +ADD_TRACKING_SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_TRACKING_NUMBER): cv.string, + vol.Optional(CONF_TITLE): cv.string, + vol.Optional(CONF_SLUG): cv.string, + } +) + +REMOVE_TRACKING_SERVICE_SCHEMA: Final = vol.Schema( + {vol.Required(CONF_SLUG): cv.string, vol.Required(CONF_TRACKING_NUMBER): cv.string} +) diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index a5ffc511a26c0..be3fd74d6bdbe 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -1,53 +1,48 @@ """Support for non-delivered packages recorded in AfterShip.""" -from datetime import timedelta +from __future__ import annotations + +from http import HTTPStatus import logging +from typing import Any, Final from pyaftership.tracker import Tracking import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME, HTTP_OK +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, + SensorEntity, +) +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.service import ServiceCall +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Information provided by AfterShip" -ATTR_TRACKINGS = "trackings" - -BASE = "https://track.aftership.com/" - -CONF_SLUG = "slug" -CONF_TITLE = "title" -CONF_TRACKING_NUMBER = "tracking_number" - -DEFAULT_NAME = "aftership" -UPDATE_TOPIC = f"{DOMAIN}_update" - -ICON = "mdi:package-variant-closed" - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - -SERVICE_ADD_TRACKING = "add_tracking" -SERVICE_REMOVE_TRACKING = "remove_tracking" - -ADD_TRACKING_SERVICE_SCHEMA = vol.Schema( - { - vol.Required(CONF_TRACKING_NUMBER): cv.string, - vol.Optional(CONF_TITLE): cv.string, - vol.Optional(CONF_SLUG): cv.string, - } +from .const import ( + ADD_TRACKING_SERVICE_SCHEMA, + ATTR_TRACKINGS, + ATTRIBUTION, + BASE, + CONF_SLUG, + CONF_TITLE, + CONF_TRACKING_NUMBER, + DEFAULT_NAME, + DOMAIN, + ICON, + MIN_TIME_BETWEEN_UPDATES, + REMOVE_TRACKING_SERVICE_SCHEMA, + SERVICE_ADD_TRACKING, + SERVICE_REMOVE_TRACKING, + UPDATE_TOPIC, ) -REMOVE_TRACKING_SERVICE_SCHEMA = vol.Schema( - {vol.Required(CONF_SLUG): cv.string, vol.Required(CONF_TRACKING_NUMBER): cv.string} -) +_LOGGER: Final = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -55,7 +50,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the AfterShip sensor platform.""" apikey = config[CONF_API_KEY] name = config[CONF_NAME] @@ -65,7 +65,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= await aftership.get_trackings() - if not aftership.meta or aftership.meta["code"] != HTTP_OK: + if not aftership.meta or aftership.meta["code"] != HTTPStatus.OK: _LOGGER.error( "No tracking data found. Check API key is correct: %s", aftership.meta ) @@ -75,7 +75,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([instance], True) - async def handle_add_tracking(call): + async def handle_add_tracking(call: ServiceCall) -> None: """Call when a user adds a new Aftership tracking from Home Assistant.""" title = call.data.get(CONF_TITLE) slug = call.data.get(CONF_SLUG) @@ -91,7 +91,7 @@ async def handle_add_tracking(call): schema=ADD_TRACKING_SERVICE_SCHEMA, ) - async def handle_remove_tracking(call): + async def handle_remove_tracking(call: ServiceCall) -> None: """Call when a user removes an Aftership tracking from Home Assistant.""" slug = call.data[CONF_SLUG] tracking_number = call.data[CONF_TRACKING_NUMBER] @@ -110,39 +110,28 @@ async def handle_remove_tracking(call): class AfterShipSensor(SensorEntity): """Representation of a AfterShip sensor.""" - def __init__(self, aftership, name): + _attr_attribution = ATTRIBUTION + _attr_native_unit_of_measurement: str = "packages" + _attr_icon: str = ICON + + def __init__(self, aftership: Tracking, name: str) -> None: """Initialize the sensor.""" - self._attributes = {} - self._name = name - self._state = None + self._attributes: dict[str, Any] = {} + self._state: int | None = None self.aftership = aftership + self._attr_name = name @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "packages" - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return attributes for the sensor.""" return self._attributes - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( @@ -150,27 +139,27 @@ async def async_added_to_hass(self): ) ) - async def _force_update(self): + async def _force_update(self) -> None: """Force update of data.""" await self.async_update(no_throttle=True) self.async_write_ha_state() @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self, **kwargs): + async def async_update(self, **kwargs: Any) -> None: """Get the latest data from the AfterShip API.""" await self.aftership.get_trackings() if not self.aftership.meta: _LOGGER.error("Unknown errors when querying") return - if self.aftership.meta["code"] != HTTP_OK: + if self.aftership.meta["code"] != HTTPStatus.OK: _LOGGER.error( "Errors when querying AfterShip. %s", str(self.aftership.meta) ) return status_to_ignore = {"delivered"} - status_counts = {} + status_counts: dict[str, int] = {} trackings = [] not_delivered_count = 0 @@ -204,7 +193,6 @@ async def async_update(self, **kwargs): _LOGGER.debug("Ignoring %s as it has status: %s", name, status) self._attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, **status_counts, ATTR_TRACKINGS: trackings, } diff --git a/homeassistant/components/aftership/services.yaml b/homeassistant/components/aftership/services.yaml index e4d90646aa68d..62e339dbda89a 100644 --- a/homeassistant/components/aftership/services.yaml +++ b/homeassistant/components/aftership/services.yaml @@ -2,7 +2,7 @@ add_tracking: name: Add tracking - description: Add new tracking to Aftership. + description: Add new tracking number to Aftership. fields: tracking_number: name: Tracking number @@ -26,7 +26,7 @@ add_tracking: remove_tracking: name: Remove tracking - description: Remove a tracking from Aftership. + description: Remove a tracking number from Aftership. fields: tracking_number: name: Tracking number diff --git a/homeassistant/components/agent_dvr/__init__.py b/homeassistant/components/agent_dvr/__init__.py index 5b765da7f8ebf..373b5c2e29118 100644 --- a/homeassistant/components/agent_dvr/__init__.py +++ b/homeassistant/components/agent_dvr/__init__.py @@ -3,6 +3,7 @@ from agent import AgentError from agent.a import Agent +from homeassistant.const import Platform from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -12,7 +13,7 @@ ATTRIBUTION = "ispyconnect.com" DEFAULT_BRAND = "Agent DVR by ispyconnect.com" -FORWARDS = ["alarm_control_panel", "camera"] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] async def async_setup_entry(hass, config_entry): @@ -35,7 +36,7 @@ async def async_setup_entry(hass, config_entry): hass.data[AGENT_DOMAIN][config_entry.entry_id] = {CONNECTION: agent_client} - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -46,14 +47,16 @@ async def async_setup_entry(hass, config_entry): sw_version=agent_client.version, ) - hass.config_entries.async_setup_platforms(config_entry, FORWARDS) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(config_entry, FORWARDS) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) await hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION].close() diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 3e093ae46a810..572f80a138f62 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -11,6 +11,7 @@ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, ) +from homeassistant.helpers.entity import DeviceInfo from .const import CONNECTION, DOMAIN as AGENT_DOMAIN @@ -35,90 +36,60 @@ async def async_setup_entry( class AgentBaseStation(AlarmControlPanelEntity): """Representation of an Agent DVR Alarm Control Panel.""" + _attr_icon = ICON + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + def __init__(self, client): """Initialize the alarm control panel.""" - self._state = None self._client = client - self._unique_id = f"{client.unique}_CP" - name = CONST_ALARM_CONTROL_PANEL_NAME - self._name = name = f"{client.name} {name}" - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - - @property - def device_info(self): - """Return the device info for adding the entity to the agent object.""" - return { - "identifiers": {(AGENT_DOMAIN, self._client.unique)}, - "manufacturer": "Agent", - "model": CONST_ALARM_CONTROL_PANEL_NAME, - "sw_version": self._client.version, - } + self._attr_name = f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}" + self._attr_unique_id = f"{client.unique}_CP" + self._attr_device_info = DeviceInfo( + identifiers={(AGENT_DOMAIN, client.unique)}, + manufacturer="Agent", + model=CONST_ALARM_CONTROL_PANEL_NAME, + sw_version=client.version, + ) async def async_update(self): """Update the state of the device.""" await self._client.update() + self._attr_available = self._client.is_available armed = self._client.is_armed if armed is None: - self._state = None + self._attr_state = None return if armed: prof = (await self._client.get_active_profile()).lower() - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY if prof == CONF_HOME_MODE_NAME: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME elif prof == CONF_NIGHT_MODE_NAME: - self._state = STATE_ALARM_ARMED_NIGHT + self._attr_state = STATE_ALARM_ARMED_NIGHT else: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED async def async_alarm_disarm(self, code=None): """Send disarm command.""" await self._client.disarm() - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED async def async_alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_AWAY_MODE_NAME) - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY async def async_alarm_arm_home(self, code=None): """Send arm home command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_HOME_MODE_NAME) - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME async def async_alarm_arm_night(self, code=None): """Send arm night command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_NIGHT_MODE_NAME) - self._state = STATE_ALARM_ARMED_NIGHT - - @property - def name(self): - """Return the name of the base station.""" - return self._name - - @property - def available(self) -> bool: - """Device available.""" - return self._client.is_available - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id + self._attr_state = STATE_ALARM_ARMED_NIGHT diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index 6b2363f50d560..474d1f08b80db 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -13,6 +13,7 @@ ) from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import DeviceInfo from .const import ( ATTRIBUTION, @@ -69,48 +70,41 @@ class AgentCamera(MjpegCamera): def __init__(self, device): """Initialize as a subclass of MjpegCamera.""" - self._servername = device.client.name - self.server_url = device.client._server_url - device_info = { CONF_NAME: device.name, - CONF_MJPEG_URL: f"{self.server_url}{device.mjpeg_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", - CONF_STILL_IMAGE_URL: f"{self.server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", + CONF_MJPEG_URL: f"{device.client._server_url}{device.mjpeg_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", + CONF_STILL_IMAGE_URL: f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", } self.device = device self._removed = False - self._name = f"{self._servername} {device.name}" - self._unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" + self._attr_name = f"{device.client.name} {device.name}" + self._attr_unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" super().__init__(device_info) - - @property - def device_info(self): - """Return the device info for adding the entity to the agent object.""" - return { - "identifiers": {(AGENT_DOMAIN, self._unique_id)}, - "name": self._name, - "manufacturer": "Agent", - "model": "Camera", - "sw_version": self.device.client.version, - } + self._attr_device_info = DeviceInfo( + identifiers={(AGENT_DOMAIN, self.unique_id)}, + manufacturer="Agent", + model="Camera", + name=self.name, + sw_version=device.client.version, + ) async def async_update(self): """Update our state from the Agent API.""" try: await self.device.update() if self._removed: - _LOGGER.debug("%s reacquired", self._name) + _LOGGER.debug("%s reacquired", self.name) self._removed = False except AgentError: # server still available - camera error if self.device.client.is_available and not self._removed: - _LOGGER.error("%s lost", self._name) + _LOGGER.error("%s lost", self.name) self._removed = True - - @property - def extra_state_attributes(self): - """Return the Agent DVR camera state attributes.""" - return { + self._attr_icon = "mdi:camcorder-off" + if self.is_on: + self._attr_icon = "mdi:camcorder" + self._attr_available = self.device.client.is_available + self._attr_extra_state_attributes = { ATTR_ATTRIBUTION: ATTRIBUTION, "editable": False, "enabled": self.is_on, @@ -141,11 +135,6 @@ def is_detected(self) -> bool: """Return whether the monitor has alerted.""" return self.device.detected - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.device.client.is_available - @property def connected(self) -> bool: """Return True if entity is connected.""" @@ -161,23 +150,11 @@ def is_on(self) -> bool: """Return true if on.""" return self.device.online - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - if self.is_on: - return "mdi:camcorder" - return "mdi:camcorder-off" - @property def motion_detection_enabled(self): """Return the camera motion detection status.""" return self.device.detector_active - @property - def unique_id(self) -> str: - """Return a unique identifier for this agent object.""" - return self._unique_id - async def async_enable_alerts(self): """Enable alerts.""" await self.device.alerts_on() diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py index a21e6855337d2..7dd3c7d5bc36e 100644 --- a/homeassistant/components/agent_dvr/config_flow.py +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -33,9 +33,7 @@ async def async_step_user(self, user_input=None): try: await agent_client.update() - except AgentConnectionError: - pass - except AgentError: + except (AgentConnectionError, AgentError): pass await agent_client.close() diff --git a/homeassistant/components/agent_dvr/translations/bg.json b/homeassistant/components/agent_dvr/translations/bg.json new file mode 100644 index 0000000000000..527adb67bf7be --- /dev/null +++ b/homeassistant/components/agent_dvr/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/agent_dvr/translations/de.json b/homeassistant/components/agent_dvr/translations/de.json index 10a8307ada1d1..a8c31dd0ca81f 100644 --- a/homeassistant/components/agent_dvr/translations/de.json +++ b/homeassistant/components/agent_dvr/translations/de.json @@ -13,7 +13,7 @@ "host": "Host", "port": "Port" }, - "title": "Richten Sie den Agent DVR ein" + "title": "Richte den Agent DVR ein" } } } diff --git a/homeassistant/components/agent_dvr/translations/fr.json b/homeassistant/components/agent_dvr/translations/fr.json index e78c1da7d8bd8..1b641dd38abbe 100644 --- a/homeassistant/components/agent_dvr/translations/fr.json +++ b/homeassistant/components/agent_dvr/translations/fr.json @@ -4,13 +4,13 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "already_in_progress": "La configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Configurer l'agent DVR" diff --git a/homeassistant/components/agent_dvr/translations/he.json b/homeassistant/components/agent_dvr/translations/he.json index 6268822a90a63..d37b99a2f450c 100644 --- a/homeassistant/components/agent_dvr/translations/he.json +++ b/homeassistant/components/agent_dvr/translations/he.json @@ -1,12 +1,16 @@ { "config": { "abort": { - "already_configured": "\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "step": { "user": { "data": { - "host": "Host", + "host": "\u05de\u05d0\u05e8\u05d7", "port": "\u05e4\u05d5\u05e8\u05d8" } } diff --git a/homeassistant/components/agent_dvr/translations/hu.json b/homeassistant/components/agent_dvr/translations/hu.json index 49968ceea75ed..83751d72eafcc 100644 --- a/homeassistant/components/agent_dvr/translations/hu.json +++ b/homeassistant/components/agent_dvr/translations/hu.json @@ -4,15 +4,16 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" - } + }, + "title": "\u00c1ll\u00edtsa be az Agent DVR-t" } } } diff --git a/homeassistant/components/agent_dvr/translations/it.json b/homeassistant/components/agent_dvr/translations/it.json index 8c33cfcc63b8c..92983fd9073da 100644 --- a/homeassistant/components/agent_dvr/translations/it.json +++ b/homeassistant/components/agent_dvr/translations/it.json @@ -13,7 +13,7 @@ "host": "Host", "port": "Porta" }, - "title": "Configurare Agent DVR" + "title": "Configura Agent DVR" } } } diff --git a/homeassistant/components/agent_dvr/translations/ja.json b/homeassistant/components/agent_dvr/translations/ja.json new file mode 100644 index 0000000000000..091f9a8947560 --- /dev/null +++ b/homeassistant/components/agent_dvr/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "Agent DVR\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/agent_dvr/translations/tr.json b/homeassistant/components/agent_dvr/translations/tr.json index 31dddab779548..dcfcbed937628 100644 --- a/homeassistant/components/agent_dvr/translations/tr.json +++ b/homeassistant/components/agent_dvr/translations/tr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "port": "Port" }, "title": "Agent DVR'\u0131 kurun" diff --git a/homeassistant/components/agent_dvr/translations/zh-Hans.json b/homeassistant/components/agent_dvr/translations/zh-Hans.json index 2941dfd938301..68393fce47058 100644 --- a/homeassistant/components/agent_dvr/translations/zh-Hans.json +++ b/homeassistant/components/agent_dvr/translations/zh-Hans.json @@ -1,7 +1,20 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, "error": { + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u8fdb\u884c\u4e2d", "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3" + }, + "title": "\u914d\u7f6e Agent DVR" + } } } } \ No newline at end of file diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index d69a02f83bd79..d0aa1fd4a7634 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -1,42 +1,43 @@ """Component for handling Air Quality data for your location.""" +from __future__ import annotations + from datetime import timedelta import logging -from typing import final +from typing import Final, final -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType, StateType -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -ATTR_AQI = "air_quality_index" -ATTR_CO2 = "carbon_dioxide" -ATTR_CO = "carbon_monoxide" -ATTR_N2O = "nitrogen_oxide" -ATTR_NO = "nitrogen_monoxide" -ATTR_NO2 = "nitrogen_dioxide" -ATTR_OZONE = "ozone" -ATTR_PM_0_1 = "particulate_matter_0_1" -ATTR_PM_10 = "particulate_matter_10" -ATTR_PM_2_5 = "particulate_matter_2_5" -ATTR_SO2 = "sulphur_dioxide" +ATTR_AQI: Final = "air_quality_index" +ATTR_CO2: Final = "carbon_dioxide" +ATTR_CO: Final = "carbon_monoxide" +ATTR_N2O: Final = "nitrogen_oxide" +ATTR_NO: Final = "nitrogen_monoxide" +ATTR_NO2: Final = "nitrogen_dioxide" +ATTR_OZONE: Final = "ozone" +ATTR_PM_0_1: Final = "particulate_matter_0_1" +ATTR_PM_10: Final = "particulate_matter_10" +ATTR_PM_2_5: Final = "particulate_matter_2_5" +ATTR_SO2: Final = "sulphur_dioxide" -DOMAIN = "air_quality" +DOMAIN: Final = "air_quality" -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL: Final = timedelta(seconds=30) -PROP_TO_ATTR = { +PROP_TO_ATTR: Final[dict[str, str]] = { "air_quality_index": ATTR_AQI, - "attribution": ATTR_ATTRIBUTION, "carbon_dioxide": ATTR_CO2, "carbon_monoxide": ATTR_CO, "nitrogen_oxide": ATTR_N2O, @@ -50,7 +51,7 @@ } -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the air quality component.""" component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL @@ -59,98 +60,94 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) class AirQualityEntity(Entity): """ABC for air quality data.""" @property - def particulate_matter_2_5(self): + def particulate_matter_2_5(self) -> StateType: """Return the particulate matter 2.5 level.""" raise NotImplementedError() @property - def particulate_matter_10(self): + def particulate_matter_10(self) -> StateType: """Return the particulate matter 10 level.""" return None @property - def particulate_matter_0_1(self): + def particulate_matter_0_1(self) -> StateType: """Return the particulate matter 0.1 level.""" return None @property - def air_quality_index(self): + def air_quality_index(self) -> StateType: """Return the Air Quality Index (AQI).""" return None @property - def ozone(self): + def ozone(self) -> StateType: """Return the O3 (ozone) level.""" return None @property - def carbon_monoxide(self): + def carbon_monoxide(self) -> StateType: """Return the CO (carbon monoxide) level.""" return None @property - def carbon_dioxide(self): + def carbon_dioxide(self) -> StateType: """Return the CO2 (carbon dioxide) level.""" return None @property - def attribution(self): - """Return the attribution.""" - return None - - @property - def sulphur_dioxide(self): + def sulphur_dioxide(self) -> StateType: """Return the SO2 (sulphur dioxide) level.""" return None @property - def nitrogen_oxide(self): + def nitrogen_oxide(self) -> StateType: """Return the N2O (nitrogen oxide) level.""" return None @property - def nitrogen_monoxide(self): + def nitrogen_monoxide(self) -> StateType: """Return the NO (nitrogen monoxide) level.""" return None @property - def nitrogen_dioxide(self): + def nitrogen_dioxide(self) -> StateType: """Return the NO2 (nitrogen dioxide) level.""" return None @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, str | int | float]: """Return the state attributes.""" - data = {} + data: dict[str, str | int | float] = {} for prop, attr in PROP_TO_ATTR.items(): - value = getattr(self, prop) - if value is not None: + if (value := getattr(self, prop)) is not None: data[attr] = value return data @property - def state(self): + def state(self) -> StateType: """Return the current state.""" return self.particulate_matter_2_5 @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement of this entity.""" return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 26e14a7642eea..83a9a50ec7f58 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -11,9 +11,11 @@ from airly.exceptions import AirlyError import async_timeout +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,7 +33,7 @@ NO_AIRLY_SENSORS, ) -PLATFORMS = ["air_quality", "sensor"] +PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -111,6 +113,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # Remove air_quality entities from registry if they exist + ent_reg = entity_registry.async_get(hass) + unique_id = f"{coordinator.latitude}-{coordinator.longitude}" + if entity_id := ent_reg.async_get_entity_id( + AIR_QUALITY_PLATFORM, DOMAIN, unique_id + ): + _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) + ent_reg.async_remove(entity_id) + return True @@ -136,7 +147,7 @@ def __init__( longitude: float, update_interval: timedelta, use_nearest: bool, - ): + ) -> None: """Initialize.""" self.latitude = latitude self.longitude = longitude @@ -156,7 +167,7 @@ async def _async_update_data(self) -> dict[str, str | float | int]: measurements = self.airly.create_measurements_session_point( self.latitude, self.longitude ) - with async_timeout.timeout(20): + async with async_timeout.timeout(20): try: await measurements.update() except (AirlyError, ClientConnectorError) as error: diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py deleted file mode 100644 index 337d3a723fa02..0000000000000 --- a/homeassistant/components/airly/air_quality.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Support for the Airly air_quality service.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.components.air_quality import ( - ATTR_AQI, - ATTR_PM_2_5, - ATTR_PM_10, - AirQualityEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import AirlyDataUpdateCoordinator -from .const import ( - ATTR_API_ADVICE, - ATTR_API_CAQI, - ATTR_API_CAQI_DESCRIPTION, - ATTR_API_CAQI_LEVEL, - ATTR_API_PM10, - ATTR_API_PM10_LIMIT, - ATTR_API_PM10_PERCENT, - ATTR_API_PM25, - ATTR_API_PM25_LIMIT, - ATTR_API_PM25_PERCENT, - ATTRIBUTION, - DEFAULT_NAME, - DOMAIN, - LABEL_ADVICE, - MANUFACTURER, -) - -LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description" -LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" -LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" -LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" -LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit" -LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit" - -PARALLEL_UPDATES = 1 - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up Airly air_quality entity based on a config entry.""" - name = entry.data[CONF_NAME] - - coordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([AirlyAirQuality(coordinator, name)], False) - - -class AirlyAirQuality(CoordinatorEntity, AirQualityEntity): - """Define an Airly air quality.""" - - coordinator: AirlyDataUpdateCoordinator - - def __init__(self, coordinator: AirlyDataUpdateCoordinator, name: str) -> None: - """Initialize.""" - super().__init__(coordinator) - self._name = name - self._icon = "mdi:blur" - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def air_quality_index(self) -> float | None: - """Return the air quality index.""" - return round_state(self.coordinator.data[ATTR_API_CAQI]) - - @property - def particulate_matter_2_5(self) -> float | None: - """Return the particulate matter 2.5 level.""" - return round_state(self.coordinator.data.get(ATTR_API_PM25)) - - @property - def particulate_matter_10(self) -> float | None: - """Return the particulate matter 10 level.""" - return round_state(self.coordinator.data.get(ATTR_API_PM10)) - - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{self.coordinator.latitude}-{self.coordinator.longitude}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": { - ( - DOMAIN, - f"{self.coordinator.latitude}-{self.coordinator.longitude}", - ) - }, - "name": DEFAULT_NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - attrs = { - LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION], - LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE], - LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL], - } - if ATTR_API_PM25 in self.coordinator.data: - attrs[LABEL_PM_2_5_LIMIT] = self.coordinator.data[ATTR_API_PM25_LIMIT] - attrs[LABEL_PM_2_5_PERCENT] = round( - self.coordinator.data[ATTR_API_PM25_PERCENT] - ) - if ATTR_API_PM10 in self.coordinator.data: - attrs[LABEL_PM_10_LIMIT] = self.coordinator.data[ATTR_API_PM10_LIMIT] - attrs[LABEL_PM_10_PERCENT] = round( - self.coordinator.data[ATTR_API_PM10_PERCENT] - ) - return attrs - - -def round_state(state: float | None) -> float | None: - """Round state.""" - return round(state) if state else state diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 598aa15b9b6ac..10e4990ee0516 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Airly.""" from __future__ import annotations +from http import HTTPStatus from typing import Any from aiohttp import ClientSession @@ -10,14 +11,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, - HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, -) +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -60,9 +54,9 @@ async def async_step_user( use_nearest=True, ) except AirlyError as err: - if err.status_code == HTTP_UNAUTHORIZED: + if err.status_code == HTTPStatus.UNAUTHORIZED: errors["base"] = "invalid_api_key" - if err.status_code == HTTP_NOT_FOUND: + if err.status_code == HTTPStatus.NOT_FOUND: errors["base"] = "wrong_location" else: if not location_point_valid: @@ -109,7 +103,7 @@ async def test_location( measurements = airly.create_measurements_session_point( latitude=latitude, longitude=longitude ) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await measurements.update() current = measurements.current diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 5136f54d6f291..801bca58412c9 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -3,33 +3,26 @@ from typing import Final -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - PRESSURE_HPA, - TEMP_CELSIUS, -) - -from .model import SensorDescription - ATTR_API_ADVICE: Final = "ADVICE" ATTR_API_CAQI: Final = "CAQI" ATTR_API_CAQI_DESCRIPTION: Final = "DESCRIPTION" ATTR_API_CAQI_LEVEL: Final = "LEVEL" ATTR_API_HUMIDITY: Final = "HUMIDITY" -ATTR_API_PM1: Final = "PM1" ATTR_API_PM10: Final = "PM10" -ATTR_API_PM10_LIMIT: Final = "PM10_LIMIT" -ATTR_API_PM10_PERCENT: Final = "PM10_PERCENT" +ATTR_API_PM1: Final = "PM1" ATTR_API_PM25: Final = "PM25" -ATTR_API_PM25_LIMIT: Final = "PM25_LIMIT" -ATTR_API_PM25_PERCENT: Final = "PM25_PERCENT" ATTR_API_PRESSURE: Final = "PRESSURE" ATTR_API_TEMPERATURE: Final = "TEMPERATURE" +ATTR_ADVICE: Final = "advice" +ATTR_DESCRIPTION: Final = "description" +ATTR_LEVEL: Final = "level" +ATTR_LIMIT: Final = "limit" +ATTR_PERCENT: Final = "percent" + +SUFFIX_PERCENT: Final = "PERCENT" +SUFFIX_LIMIT: Final = "LIMIT" + ATTRIBUTION: Final = "Data provided by Airly" CONF_USE_NEAREST: Final = "use_nearest" DEFAULT_NAME: Final = "Airly" @@ -39,30 +32,4 @@ MAX_UPDATE_INTERVAL: Final = 90 MIN_UPDATE_INTERVAL: Final = 5 NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." - -SENSOR_TYPES: dict[str, SensorDescription] = { - ATTR_API_PM1: { - "device_class": None, - "icon": "mdi:blur", - "label": ATTR_API_PM1, - "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - ATTR_API_HUMIDITY: { - "device_class": DEVICE_CLASS_HUMIDITY, - "icon": None, - "label": ATTR_API_HUMIDITY.capitalize(), - "unit": PERCENTAGE, - }, - ATTR_API_PRESSURE: { - "device_class": DEVICE_CLASS_PRESSURE, - "icon": None, - "label": ATTR_API_PRESSURE.capitalize(), - "unit": PRESSURE_HPA, - }, - ATTR_API_TEMPERATURE: { - "device_class": DEVICE_CLASS_TEMPERATURE, - "icon": None, - "label": ATTR_API_TEMPERATURE.capitalize(), - "unit": TEMP_CELSIUS, - }, -} +URL = "https://airly.org/map/#{latitude},{longitude}" diff --git a/homeassistant/components/airly/model.py b/homeassistant/components/airly/model.py deleted file mode 100644 index 42091d449e34a..0000000000000 --- a/homeassistant/components/airly/model.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Type definitions for Airly integration.""" -from __future__ import annotations - -from typing import TypedDict - - -class SensorDescription(TypedDict): - """Sensor description class.""" - - device_class: str | None - icon: str | None - label: str - unit: str diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index b978afb25a987..3dab90f562071 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -1,12 +1,27 @@ """Support for the Airly sensor service.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONF_NAME, + PERCENTAGE, + PRESSURE_HPA, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -14,18 +29,94 @@ from . import AirlyDataUpdateCoordinator from .const import ( + ATTR_ADVICE, + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + ATTR_API_HUMIDITY, ATTR_API_PM1, + ATTR_API_PM10, + ATTR_API_PM25, ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, + ATTR_DESCRIPTION, + ATTR_LEVEL, + ATTR_LIMIT, + ATTR_PERCENT, ATTRIBUTION, DEFAULT_NAME, DOMAIN, MANUFACTURER, - SENSOR_TYPES, + SUFFIX_LIMIT, + SUFFIX_PERCENT, + URL, ) PARALLEL_UPDATES = 1 +@dataclass +class AirlySensorEntityDescription(SensorEntityDescription): + """Class describing Airly sensor entities.""" + + value: Callable = round + + +SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( + AirlySensorEntityDescription( + key=ATTR_API_CAQI, + device_class=SensorDeviceClass.AQI, + name=ATTR_API_CAQI, + native_unit_of_measurement="CAQI", + ), + AirlySensorEntityDescription( + key=ATTR_API_PM1, + device_class=SensorDeviceClass.PM1, + name=ATTR_API_PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM25, + device_class=SensorDeviceClass.PM25, + name="PM2.5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM10, + device_class=SensorDeviceClass.PM10, + name=ATTR_API_PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + name=ATTR_API_HUMIDITY.capitalize(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda value: round(value, 1), + ), + AirlySensorEntityDescription( + key=ATTR_API_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + name=ATTR_API_PRESSURE.capitalize(), + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + name=ATTR_API_TEMPERATURE.capitalize(), + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value=lambda value: round(value, 1), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -35,10 +126,10 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] sensors = [] - for sensor in SENSOR_TYPES: + for description in SENSOR_TYPES: # When we use the nearest method, we are not sure which sensors are available - if coordinator.data.get(sensor): - sensors.append(AirlySensor(coordinator, name, sensor)) + if coordinator.data.get(description.key): + sensors.append(AirlySensor(coordinator, name, description)) async_add_entities(sensors, False) @@ -47,68 +138,59 @@ class AirlySensor(CoordinatorEntity, SensorEntity): """Define an Airly sensor.""" coordinator: AirlyDataUpdateCoordinator + entity_description: AirlySensorEntityDescription def __init__( - self, coordinator: AirlyDataUpdateCoordinator, name: str, kind: str + self, + coordinator: AirlyDataUpdateCoordinator, + name: str, + description: AirlySensorEntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) - self._name = name - self._description = SENSOR_TYPES[kind] - self.kind = kind - self._state = None - self._unit_of_measurement = None - self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{coordinator.latitude}-{coordinator.longitude}")}, + manufacturer=MANUFACTURER, + name=DEFAULT_NAME, + configuration_url=URL.format( + latitude=coordinator.latitude, longitude=coordinator.longitude + ), + ) + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = ( + f"{coordinator.latitude}-{coordinator.longitude}-{description.key}".lower() + ) + self._attrs: dict[str, Any] = {ATTR_ATTRIBUTION: ATTRIBUTION} + self.entity_description = description @property - def name(self) -> str: - """Return the name.""" - return f"{self._name} {self._description['label']}" - - @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" - self._state = self.coordinator.data[self.kind] - if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]: - return round(cast(float, self._state)) - return round(cast(float, self._state), 1) + state = self.coordinator.data[self.entity_description.key] + return cast(StateType, self.entity_description.value(state)) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.entity_description.key == ATTR_API_CAQI: + self._attrs[ATTR_LEVEL] = self.coordinator.data[ATTR_API_CAQI_LEVEL] + self._attrs[ATTR_ADVICE] = self.coordinator.data[ATTR_API_ADVICE] + self._attrs[ATTR_DESCRIPTION] = self.coordinator.data[ + ATTR_API_CAQI_DESCRIPTION + ] + if self.entity_description.key == ATTR_API_PM25: + self._attrs[ATTR_LIMIT] = self.coordinator.data[ + f"{ATTR_API_PM25}_{SUFFIX_LIMIT}" + ] + self._attrs[ATTR_PERCENT] = round( + self.coordinator.data[f"{ATTR_API_PM25}_{SUFFIX_PERCENT}"] + ) + if self.entity_description.key == ATTR_API_PM10: + self._attrs[ATTR_LIMIT] = self.coordinator.data[ + f"{ATTR_API_PM10}_{SUFFIX_LIMIT}" + ] + self._attrs[ATTR_PERCENT] = round( + self.coordinator.data[f"{ATTR_API_PM10}_{SUFFIX_PERCENT}"] + ) return self._attrs - - @property - def icon(self) -> str | None: - """Return the icon.""" - return self._description["icon"] - - @property - def device_class(self) -> str | None: - """Return the device_class.""" - return self._description["device_class"] - - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": { - ( - DOMAIN, - f"{self.coordinator.latitude}-{self.coordinator.longitude}", - ) - }, - "name": DEFAULT_NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._description["unit"] diff --git a/homeassistant/components/airly/translations/bg.json b/homeassistant/components/airly/translations/bg.json index f0836c8e5edf8..955cc8c1ac4b7 100644 --- a/homeassistant/components/airly/translations/bg.json +++ b/homeassistant/components/airly/translations/bg.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447", "wrong_location": "\u0412 \u0442\u0430\u0437\u0438 \u043e\u0431\u043b\u0430\u0441\u0442 \u043d\u044f\u043c\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u0442\u0435\u043b\u043d\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Airly." }, "step": { diff --git a/homeassistant/components/airly/translations/ca.json b/homeassistant/components/airly/translations/ca.json index e76cec94f4ccd..0d5177df33e15 100644 --- a/homeassistant/components/airly/translations/ca.json +++ b/homeassistant/components/airly/translations/ca.json @@ -15,7 +15,7 @@ "longitude": "Longitud", "name": "Nom" }, - "description": "Configura una integraci\u00f3 de qualitat d'aire Airly. Per generar la clau API, v\u00e9s a https://developer.airly.eu/register", + "description": "Configura la integraci\u00f3 de qualitat de l'aire Airly. Per generar la clau API, v\u00e9s a https://developer.airly.eu/register", "title": "Airly" } } diff --git a/homeassistant/components/airly/translations/fr.json b/homeassistant/components/airly/translations/fr.json index a23f455e0b82c..945de28e07a92 100644 --- a/homeassistant/components/airly/translations/fr.json +++ b/homeassistant/components/airly/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration des coordonn\u00e9es d'Airly est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_api_key": "Cl\u00e9 API invalide", @@ -13,7 +13,7 @@ "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", - "name": "Nom de l'int\u00e9gration" + "name": "Nom" }, "description": "Configurez l'int\u00e9gration de la qualit\u00e9 de l'air Airly. Pour g\u00e9n\u00e9rer une cl\u00e9 API, rendez-vous sur https://developer.airly.eu/register.", "title": "Airly" diff --git a/homeassistant/components/airly/translations/he.json b/homeassistant/components/airly/translations/he.json index 4c49313d97741..8faa6ac2092c4 100644 --- a/homeassistant/components/airly/translations/he.json +++ b/homeassistant/components/airly/translations/he.json @@ -1,10 +1,20 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, "step": { "user": { "data": { - "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" - } + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + }, + "title": "\u05d0\u05d5\u05d5\u05e8\u05d9\u05e8\u05d9" } } } diff --git a/homeassistant/components/airly/translations/hu.json b/homeassistant/components/airly/translations/hu.json index b9fd0c9e05cc5..f730edde85fde 100644 --- a/homeassistant/components/airly/translations/hu.json +++ b/homeassistant/components/airly/translations/hu.json @@ -22,6 +22,7 @@ }, "system_health": { "info": { + "can_reach_server": "\u00c9rje el az Airly szervert", "requests_per_day": "Enged\u00e9lyezett k\u00e9r\u00e9sek naponta", "requests_remaining": "Fennmarad\u00f3 enged\u00e9lyezett k\u00e9r\u00e9sek" } diff --git a/homeassistant/components/airly/translations/it.json b/homeassistant/components/airly/translations/it.json index 385b8117437b2..170455ffb15d8 100644 --- a/homeassistant/components/airly/translations/it.json +++ b/homeassistant/components/airly/translations/it.json @@ -15,7 +15,7 @@ "longitude": "Logitudine", "name": "Nome" }, - "description": "Configurazione dell'integrazione della qualit\u00e0 dell'aria Airly. Per generare la chiave API andare su https://developer.airly.eu/register", + "description": "Configurazione dell'integrazione della qualit\u00e0 dell'aria Airly. Per generare la chiave API vai su https://developer.airly.eu/register", "title": "Airly" } } diff --git a/homeassistant/components/airly/translations/ja.json b/homeassistant/components/airly/translations/ja.json new file mode 100644 index 0000000000000..6835412e6db7e --- /dev/null +++ b/homeassistant/components/airly/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "wrong_location": "\u3053\u306e\u30a8\u30ea\u30a2\u306b\u3001Airly\u306e\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306f\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "Airly\u306e\u7a7a\u6c17\u54c1\u8cea\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002 API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://developer.airly.eu/register \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Airly" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Airly\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u30a2\u30af\u30bb\u30b9", + "requests_per_day": "1\u65e5\u3042\u305f\u308a\u306e\u8a31\u53ef\u3055\u308c\u305f\u30ea\u30af\u30a8\u30b9\u30c8", + "requests_remaining": "\u6b8b\u308a\u306e\u8a31\u53ef\u3055\u308c\u305f\u30ea\u30af\u30a8\u30b9\u30c8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/tr.json b/homeassistant/components/airly/translations/tr.json index 144acc1e1aedd..fcae9294da28b 100644 --- a/homeassistant/components/airly/translations/tr.json +++ b/homeassistant/components/airly/translations/tr.json @@ -4,21 +4,27 @@ "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "wrong_location": "Bu b\u00f6lgede Airly \u00f6l\u00e7\u00fcm istasyonu yok." }, "step": { "user": { "data": { "api_key": "API Anahtar\u0131", "latitude": "Enlem", - "longitude": "Boylam" - } + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Airly hava kalitesi entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://developer.airly.eu/register adresine gidin.", + "title": "Airly" } } }, "system_health": { "info": { - "can_reach_server": "Airly sunucusuna eri\u015fin" + "can_reach_server": "Airly sunucusuna eri\u015fin", + "requests_per_day": "G\u00fcnl\u00fck izin verilen istek say\u0131s\u0131", + "requests_remaining": "Kalan izin verilen istekler" } } } \ No newline at end of file diff --git a/homeassistant/components/airly/translations/zh-Hant.json b/homeassistant/components/airly/translations/zh-Hant.json index 19ef2ae7532cd..e289bc7cd5047 100644 --- a/homeassistant/components/airly/translations/zh-Hant.json +++ b/homeassistant/components/airly/translations/zh-Hant.json @@ -4,18 +4,18 @@ "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" }, "error": { - "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "invalid_api_key": "API \u91d1\u9470\u7121\u6548", "wrong_location": "\u8a72\u5340\u57df\u6c92\u6709 Arily \u76e3\u6e2c\u7ad9\u3002" }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6", "name": "\u540d\u7a31" }, - "description": "\u6b32\u8a2d\u5b9a Airly \u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u8acb\u81f3 https://developer.airly.eu/register \u7522\u751f API \u5bc6\u9470", + "description": "\u6b32\u8a2d\u5b9a Airly \u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u8acb\u81f3 https://developer.airly.eu/register \u7522\u751f API \u91d1\u9470", "title": "Airly" } } diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 0b27a4a9dfdf9..9c6babe1136da 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -8,7 +8,13 @@ from pyairnow.errors import AirNowError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -33,10 +39,10 @@ ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AirNow from a config entry.""" api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] @@ -64,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 2d3adc8d1e2ae..974530c95040a 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,14 +1,19 @@ """Support for the AirNow sensor service.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirNowDataUpdateCoordinator from .const import ( ATTR_API_AQI, ATTR_API_AQI_DESCRIPTION, @@ -22,72 +27,73 @@ ATTRIBUTION = "Data provided by AirNow" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" - PARALLEL_UPDATES = 1 -SENSOR_TYPES = { - ATTR_API_AQI: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_AQI, - ATTR_UNIT: "aqi", - }, - ATTR_API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_PM25, - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - ATTR_API_O3: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_O3, - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_AQI, + icon="mdi:blur", + name=ATTR_API_AQI, + native_unit_of_measurement="aqi", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_PM25, + icon="mdi:blur", + name=ATTR_API_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_O3, + icon="mdi:blur", + name=ATTR_API_O3, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up AirNow sensor entities based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] - for sensor in SENSOR_TYPES: - sensors.append(AirNowSensor(coordinator, sensor)) + entities = [AirNowSensor(coordinator, description) for description in SENSOR_TYPES] - async_add_entities(sensors, False) + async_add_entities(entities, False) class AirNowSensor(CoordinatorEntity, SensorEntity): """Define an AirNow sensor.""" - def __init__(self, coordinator, kind): + coordinator: AirNowDataUpdateCoordinator + + def __init__( + self, + coordinator: AirNowDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) - self.kind = kind - self._device_class = None + self.entity_description = description self._state = None - self._icon = None - self._unit_of_measurement = None self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_name = f"AirNow {description.name}" + self._attr_unique_id = ( + f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" + ) @property - def name(self): - """Return the name.""" - return f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}" - - @property - def state(self): + def native_value(self): """Return the state.""" - self._state = self.coordinator.data[self.kind] + self._state = self.coordinator.data.get(self.entity_description.key) + return self._state @property def extra_state_attributes(self): """Return the state attributes.""" - if self.kind == ATTR_API_AQI: + if self.entity_description.key == ATTR_API_AQI: self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[ ATTR_API_AQI_DESCRIPTION ] @@ -96,24 +102,3 @@ def extra_state_attributes(self): ] return self._attrs - - @property - def icon(self): - """Return the icon.""" - self._icon = SENSOR_TYPES[self.kind][ATTR_ICON] - return self._icon - - @property - def device_class(self): - """Return the device_class.""" - return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self.kind][ATTR_UNIT] diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index a73ad6d179c53..9fc5bd3bcccfc 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -1,5 +1,4 @@ { - "title": "AirNow", "config": { "step": { "user": { diff --git a/homeassistant/components/airnow/translations/bg.json b/homeassistant/components/airnow/translations/bg.json index 5d274ec2b73c7..11928153a0997 100644 --- a/homeassistant/components/airnow/translations/bg.json +++ b/homeassistant/components/airnow/translations/bg.json @@ -1,7 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "invalid_location": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0440\u0435\u0437\u0443\u043b\u0442\u0430\u0442\u0438 \u0437\u0430 \u0442\u043e\u0432\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/airnow/translations/de.json b/homeassistant/components/airnow/translations/de.json index 8c2b47c1bd496..adf9ddf85a34f 100644 --- a/homeassistant/components/airnow/translations/de.json +++ b/homeassistant/components/airnow/translations/de.json @@ -14,9 +14,10 @@ "data": { "api_key": "API-Schl\u00fcssel", "latitude": "Breitengrad", - "longitude": "L\u00e4ngengrad" + "longitude": "L\u00e4ngengrad", + "radius": "Stationsradius (Meilen; optional)" }, - "description": "Richten Sie die AirNow-Luftqualit\u00e4tsintegration ein. Um den API-Schl\u00fcssel zu generieren, besuchen Sie https://docs.airnowapi.org/account/request/.", + "description": "Richte die AirNow-Luftqualit\u00e4tsintegration ein. Um den API-Schl\u00fcssel zu generieren, besuche https://docs.airnowapi.org/account/request/.", "title": "AirNow" } } diff --git a/homeassistant/components/airnow/translations/fr.json b/homeassistant/components/airnow/translations/fr.json index ff85d9318e940..686dedb9bb692 100644 --- a/homeassistant/components/airnow/translations/fr.json +++ b/homeassistant/components/airnow/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "invalid_location": "Aucun r\u00e9sultat trouv\u00e9 pour cet emplacement", "unknown": "Erreur inattendue" @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "api_key": "Cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", "radius": "Rayon d'action de la station (en miles, facultatif)" diff --git a/homeassistant/components/airnow/translations/he.json b/homeassistant/components/airnow/translations/he.json new file mode 100644 index 0000000000000..84ccc63e4db13 --- /dev/null +++ b/homeassistant/components/airnow/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airnow/translations/hu.json b/homeassistant/components/airnow/translations/hu.json index 418450f24198c..3f1bef471eec1 100644 --- a/homeassistant/components/airnow/translations/hu.json +++ b/homeassistant/components/airnow/translations/hu.json @@ -14,8 +14,10 @@ "data": { "api_key": "API kulcs", "latitude": "Sz\u00e9less\u00e9g", - "longitude": "Hossz\u00fas\u00e1g" + "longitude": "Hossz\u00fas\u00e1g", + "radius": "\u00c1llom\u00e1s sugara (m\u00e9rf\u00f6ld; opcion\u00e1lis)" }, + "description": "\u00c1ll\u00edtsa be az AirNow leveg\u0151min\u0151s\u00e9gi integr\u00e1ci\u00f3t. Az API-kulcs el\u0151\u00e1ll\u00edt\u00e1s\u00e1hoz keresse fel a https://docs.airnowapi.org/account/request/ oldalt.", "title": "AirNow" } } diff --git a/homeassistant/components/airnow/translations/ja.json b/homeassistant/components/airnow/translations/ja.json new file mode 100644 index 0000000000000..d535f7fc0446f --- /dev/null +++ b/homeassistant/components/airnow/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_location": "\u305d\u306e\u5834\u6240\u306b\u5bfe\u3059\u308b\u7d50\u679c\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "radius": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u534a\u5f84(\u30de\u30a4\u30eb; \u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "description": "AirNow\u306e\u7a7a\u6c17\u54c1\u8cea\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002 API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://docs.airnowapi.org/account/request/ \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "AirNow" + } + } + }, + "title": "AirNow" +} \ No newline at end of file diff --git a/homeassistant/components/airnow/translations/tr.json b/homeassistant/components/airnow/translations/tr.json index 06af714dc8742..590332b496cdb 100644 --- a/homeassistant/components/airnow/translations/tr.json +++ b/homeassistant/components/airnow/translations/tr.json @@ -17,6 +17,7 @@ "longitude": "Boylam", "radius": "\u0130stasyon Yar\u0131\u00e7ap\u0131 (mil; iste\u011fe ba\u011fl\u0131)" }, + "description": "AirNow hava kalitesi entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://docs.airnowapi.org/account/request/ adresine gidin.", "title": "AirNow" } } diff --git a/homeassistant/components/airnow/translations/zh-Hant.json b/homeassistant/components/airnow/translations/zh-Hant.json index 0cdb4a11bedc6..08d7aab5878b5 100644 --- a/homeassistant/components/airnow/translations/zh-Hant.json +++ b/homeassistant/components/airnow/translations/zh-Hant.json @@ -12,12 +12,12 @@ "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6", "radius": "\u89c0\u6e2c\u7ad9\u534a\u5f91\uff08\u82f1\u91cc\uff1b\u9078\u9805\uff09" }, - "description": "\u6b32\u8a2d\u5b9a AirNow \u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u8acb\u81f3 https://docs.airnowapi.org/account/request/ \u7522\u751f API \u5bc6\u9470", + "description": "\u6b32\u8a2d\u5b9a AirNow \u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u8acb\u81f3 https://docs.airnowapi.org/account/request/ \u7522\u751f API \u91d1\u9470", "title": "AirNow" } } diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py new file mode 100644 index 0000000000000..352c02496376f --- /dev/null +++ b/homeassistant/components/airthings/__init__.py @@ -0,0 +1,62 @@ +"""The Airthings integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from airthings import Airthings, AirthingsError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] +SCAN_INTERVAL = timedelta(minutes=6) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airthings from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + airthings = Airthings( + entry.data[CONF_ID], + entry.data[CONF_SECRET], + async_get_clientsession(hass), + ) + + async def _update_method(): + """Get the latest data from Airthings.""" + try: + return await airthings.update_devices() + except AirthingsError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_update_method, + update_interval=SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py new file mode 100644 index 0000000000000..842f05d76dbc7 --- /dev/null +++ b/homeassistant/components/airthings/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Airthings integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import airthings +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID): str, + vol.Required(CONF_SECRET): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airthings.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "url": "https://dashboard.airthings.com/integrations/api-integration", + }, + ) + + errors = {} + + try: + await airthings.get_token( + async_get_clientsession(self.hass), + user_input[CONF_ID], + user_input[CONF_SECRET], + ) + except airthings.AirthingsConnectionError: + errors["base"] = "cannot_connect" + except airthings.AirthingsAuthError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Airthings", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py new file mode 100644 index 0000000000000..70de549141b32 --- /dev/null +++ b/homeassistant/components/airthings/const.py @@ -0,0 +1,6 @@ +"""Constants for the Airthings integration.""" + +DOMAIN = "airthings" + +CONF_ID = "id" +CONF_SECRET = "secret" diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json new file mode 100644 index 0000000000000..24585804b45f1 --- /dev/null +++ b/homeassistant/components/airthings/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airthings", + "name": "Airthings", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airthings", + "requirements": ["airthings_cloud==0.1.0"], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py new file mode 100644 index 0000000000000..a753a0b213f49 --- /dev/null +++ b/homeassistant/components/airthings/sensor.py @@ -0,0 +1,161 @@ +"""Support for Airthings sensors.""" +from __future__ import annotations + +from airthings import AirthingsDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + PRESSURE_MBAR, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +SENSORS: dict[str, SensorEntityDescription] = { + "radonShortTermAvg": SensorEntityDescription( + key="radonShortTermAvg", + native_unit_of_measurement="Bq/m³", + name="Radon", + ), + "temp": SensorEntityDescription( + key="temp", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + ), + "humidity": SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + ), + "pressure": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=PRESSURE_MBAR, + name="Pressure", + ), + "battery": SensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + name="Battery", + ), + "co2": SensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="CO2", + ), + "voc": SensorEntityDescription( + key="voc", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="VOC", + ), + "light": SensorEntityDescription( + key="light", + native_unit_of_measurement=PERCENTAGE, + name="Light", + ), + "virusRisk": SensorEntityDescription( + key="virusRisk", + name="Virus Risk", + ), + "mold": SensorEntityDescription( + key="mold", + name="Mold", + ), + "rssi": SensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + name="RSSI", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "pm1": SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + name="PM1", + ), + "pm25": SensorEntityDescription( + key="pm25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + name="PM25", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airthings sensor.""" + + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [ + AirthingsHeaterEnergySensor( + coordinator, + airthings_device, + SENSORS[sensor_types], + ) + for airthings_device in coordinator.data.values() + for sensor_types in airthings_device.sensor_types + if sensor_types in SENSORS + ] + async_add_entities(entities) + + +class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): + """Representation of a Airthings Sensor device.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, + coordinator: DataUpdateCoordinator, + airthings_device: AirthingsDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_name = f"{airthings_device.name} {entity_description.name}" + self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" + self._id = airthings_device.device_id + self._attr_device_info = DeviceInfo( + configuration_url="https://dashboard.airthings.com/", + identifiers={(DOMAIN, airthings_device.device_id)}, + name=airthings_device.name, + manufacturer="Airthings", + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self._id].sensors[self.entity_description.key] diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json new file mode 100644 index 0000000000000..32f3fbc695451 --- /dev/null +++ b/homeassistant/components/airthings/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "id": "ID", + "secret": "Secret", + "description": "Login at {url} to find your credentials" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/bg.json b/homeassistant/components/airthings/translations/bg.json new file mode 100644 index 0000000000000..df9d136dfe8f9 --- /dev/null +++ b/homeassistant/components/airthings/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "id": "ID" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ca.json b/homeassistant/components/airthings/translations/ca.json new file mode 100644 index 0000000000000..c90f9cc636412 --- /dev/null +++ b/homeassistant/components/airthings/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "description": "Inicia sessi\u00f3 a {url} per obtenir les credencials", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/cs.json b/homeassistant/components/airthings/translations/cs.json new file mode 100644 index 0000000000000..740a9675b96f4 --- /dev/null +++ b/homeassistant/components/airthings/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "id": "ID" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/de.json b/homeassistant/components/airthings/translations/de.json new file mode 100644 index 0000000000000..7bd5e347776be --- /dev/null +++ b/homeassistant/components/airthings/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "description": "Melde dich unter {url} an, um deine Zugangsdaten zu finden", + "id": "ID", + "secret": "Geheimnis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/en.json b/homeassistant/components/airthings/translations/en.json new file mode 100644 index 0000000000000..a7430dedd8115 --- /dev/null +++ b/homeassistant/components/airthings/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "description": "Login at {url} to find your credentials", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/et.json b/homeassistant/components/airthings/translations/et.json new file mode 100644 index 0000000000000..708416f16c1d9 --- /dev/null +++ b/homeassistant/components/airthings/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise t\u00f5rge", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "description": "Logi sisse aadressil {url}, et leida oma mandaadid", + "id": "Kasutajatunnus", + "secret": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/fr.json b/homeassistant/components/airthings/translations/fr.json new file mode 100644 index 0000000000000..1ad84e8bd997b --- /dev/null +++ b/homeassistant/components/airthings/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "description": "Connectez-vous sur {url} pour trouver vos identifiants", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/he.json b/homeassistant/components/airthings/translations/he.json new file mode 100644 index 0000000000000..c6c0d910ae421 --- /dev/null +++ b/homeassistant/components/airthings/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "id": "\u05de\u05d6\u05d4\u05d4", + "secret": "\u05e1\u05d5\u05d3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/hu.json b/homeassistant/components/airthings/translations/hu.json new file mode 100644 index 0000000000000..136348d38b453 --- /dev/null +++ b/homeassistant/components/airthings/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "description": "Jelentkezzen be a {url} c\u00edmen hogy megkapja hiteles\u00edt\u0151 adatait", + "id": "Azonos\u00edt\u00f3", + "secret": "Titok" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/id.json b/homeassistant/components/airthings/translations/id.json new file mode 100644 index 0000000000000..b019ddd0aed81 --- /dev/null +++ b/homeassistant/components/airthings/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "description": "Masuk di {url} untuk menemukan kredensial Anda", + "id": "ID", + "secret": "Kode Rahasia" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/it.json b/homeassistant/components/airthings/translations/it.json new file mode 100644 index 0000000000000..68a0c152f56e1 --- /dev/null +++ b/homeassistant/components/airthings/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "description": "Accedi a {url} per trovare le tue credenziali", + "id": "ID", + "secret": "Segreto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ja.json b/homeassistant/components/airthings/translations/ja.json new file mode 100644 index 0000000000000..04a39045b39cd --- /dev/null +++ b/homeassistant/components/airthings/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "description": "{url} \u306b\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u3001\u8a8d\u8a3c\u60c5\u5831\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "id": "ID", + "secret": "\u30b7\u30fc\u30af\u30ec\u30c3\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/nl.json b/homeassistant/components/airthings/translations/nl.json new file mode 100644 index 0000000000000..3f0e753b37527 --- /dev/null +++ b/homeassistant/components/airthings/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "description": "Log in op {url} om uw inloggegevens te vinden", + "id": "ID", + "secret": "Geheim" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/no.json b/homeassistant/components/airthings/translations/no.json new file mode 100644 index 0000000000000..8609dff2e1651 --- /dev/null +++ b/homeassistant/components/airthings/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "description": "Logg p\u00e5 {url} \u00e5 finne legitimasjonen din", + "id": "ID", + "secret": "Hemmelig" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/pl.json b/homeassistant/components/airthings/translations/pl.json new file mode 100644 index 0000000000000..6c9cb9b5678ce --- /dev/null +++ b/homeassistant/components/airthings/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "description": "Zaloguj si\u0119 pod {url}, aby znale\u017a\u0107 swoje dane uwierzytelniaj\u0105ce", + "id": "ID", + "secret": "Sekret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ru.json b/homeassistant/components/airthings/translations/ru.json new file mode 100644 index 0000000000000..6ec7077860e5e --- /dev/null +++ b/homeassistant/components/airthings/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "description": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u0430\u043d\u043d\u044b\u0435 \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430 \u044d\u0442\u043e\u0439 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435: {url}", + "id": "ID", + "secret": "\u0421\u0435\u043a\u0440\u0435\u0442\u043d\u044b\u0439 \u043a\u043e\u0434" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/tr.json b/homeassistant/components/airthings/translations/tr.json new file mode 100644 index 0000000000000..211b7e6dc89d1 --- /dev/null +++ b/homeassistant/components/airthings/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "description": "Kimlik bilgilerinizi bulmak i\u00e7in {url} adresinden giri\u015f yap\u0131n", + "id": "ID", + "secret": "Gizli" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/zh-Hant.json b/homeassistant/components/airthings/translations/zh-Hant.json new file mode 100644 index 0000000000000..6317b5903a990 --- /dev/null +++ b/homeassistant/components/airthings/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "description": "\u767b\u5165 {url} \u4ee5\u53d6\u5f97\u6191\u8b49", + "id": "ID", + "secret": "\u79c1\u9470" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py new file mode 100644 index 0000000000000..7b0673ecfe490 --- /dev/null +++ b/homeassistant/components/airtouch4/__init__.py @@ -0,0 +1,81 @@ +"""The AirTouch4 integration.""" +import logging + +from airtouch4pyapi import AirTouch +from airtouch4pyapi.airtouch import AirTouchStatus + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AirTouch4 from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + info = airtouch.GetAcs() + if not info: + raise ConfigEntryNotReady + coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py new file mode 100644 index 0000000000000..5bac0c7c9a37b --- /dev/null +++ b/homeassistant/components/airtouch4/climate.py @@ -0,0 +1,336 @@ +"""AirTouch 4 component to control of AirTouch 4 Climate Devices.""" + +import logging + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE +AT_TO_HA_STATE = { + "Heat": HVAC_MODE_HEAT, + "Cool": HVAC_MODE_COOL, + "AutoHeat": HVAC_MODE_AUTO, # airtouch reports either autoheat or autocool + "AutoCool": HVAC_MODE_AUTO, + "Auto": HVAC_MODE_AUTO, + "Dry": HVAC_MODE_DRY, + "Fan": HVAC_MODE_FAN_ONLY, +} + +HA_STATE_TO_AT = { + HVAC_MODE_HEAT: "Heat", + HVAC_MODE_COOL: "Cool", + HVAC_MODE_AUTO: "Auto", + HVAC_MODE_DRY: "Dry", + HVAC_MODE_FAN_ONLY: "Fan", + HVAC_MODE_OFF: "Off", +} + +AT_TO_HA_FAN_SPEED = { + "Quiet": FAN_DIFFUSE, + "Low": FAN_LOW, + "Medium": FAN_MEDIUM, + "High": FAN_HIGH, + "Powerful": FAN_FOCUS, + "Auto": FAN_AUTO, + "Turbo": "turbo", +} + +AT_GROUP_MODES = [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + +HA_FAN_SPEED_TO_AT = {value: key for key, value in AT_TO_HA_FAN_SPEED.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Airtouch 4.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + info = coordinator.data + entities = [ + AirtouchGroup(coordinator, group["group_number"], info) + for group in info["groups"] + ] + [AirtouchAC(coordinator, ac["ac_number"], info) for ac in info["acs"]] + + _LOGGER.debug(" Found entities %s", entities) + + async_add_entities(entities) + + +class AirtouchAC(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 ac.""" + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, coordinator, ac_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._ac_number = ac_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetAcs()[self._ac_number] + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetAcs()[self._ac_number] + return super()._handle_coordinator_update() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for this device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.name, + manufacturer="Airtouch", + model="Airtouch 4", + ) + + @property + def unique_id(self): + """Return unique ID for this device.""" + return f"ac_{self._ac_number}" + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def name(self): + """Return the name of the climate device.""" + return f"AC {self._ac_number}" + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) + modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] + modes.append(HVAC_MODE_OFF) + return modes + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + await self._airtouch.SetCoolingModeForAc( + self._ac_number, HA_STATE_TO_AT[hvac_mode] + ) + # in case it isn't already, unless the HVAC mode was off, then the ac should be on + await self.async_turn_on() + self._unit = self._airtouch.GetAcs()[self._ac_number] + _LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._ac_number, fan_mode) + await self._airtouch.SetFanSpeedForAc( + self._ac_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self._unit = self._airtouch.GetAcs()[self._ac_number] + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) + await self._airtouch.TurnAcOn(self._ac_number) + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnAcOff(self._ac_number) + self.async_write_ha_state() + + +class AirtouchGroup(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 group.""" + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_temperature_unit = TEMP_CELSIUS + _attr_hvac_modes = AT_GROUP_MODES + + def __init__(self, coordinator, group_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._group_number = group_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + return super()._handle_coordinator_update() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for this device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Airtouch", + model="Airtouch 4", + name=self.name, + ) + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self._group_number + + @property + def min_temp(self): + """Return Minimum Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint + + @property + def max_temp(self): + """Return Max Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint + + @property + def name(self): + """Return the name of the climate device.""" + return self._unit.GroupName + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + return self._unit.TargetSetpoint + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + # there are other power states that aren't 'on' but still count as on (eg. 'Turbo') + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return HVAC_MODE_FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + if self.hvac_mode == HVAC_MODE_OFF: + await self.async_turn_on() + self._unit = self._airtouch.GetGroups()[self._group_number] + _LOGGER.debug( + "Setting operation mode of %s to %s", self._group_number, hvac_mode + ) + self.async_write_ha_state() + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup( + self._group_number + ) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + _LOGGER.debug("Setting temp of %s to %s", self._group_number, str(temp)) + self._unit = await self._airtouch.SetGroupToTemperature( + self._group_number, int(temp) + ) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._group_number, fan_mode) + self._unit = await self._airtouch.SetFanSpeedByGroup( + self._group_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + await self._airtouch.TurnGroupOn(self._group_number) + + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) + await self._airtouch.TurnAcOn( + self._airtouch.GetGroupByGroupNumber(self._group_number).BelongsToAc + ) + # this might cause the ac object to be wrong, so force the shared data + # store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnGroupOff(self._group_number) + # this will cause the ac object to be wrong + # (ac turns off automatically if no groups are running) + # so force the shared data store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py new file mode 100644 index 0000000000000..e395c71349b47 --- /dev/null +++ b/homeassistant/components/airtouch4/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for AirTouch4.""" +from airtouch4pyapi import AirTouch, AirTouchStatus +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class AirtouchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an Airtouch config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + host = user_input[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + airtouch_status = airtouch.Status + airtouch_has_groups = bool( + airtouch.Status == AirTouchStatus.OK and airtouch.GetGroups() + ) + + if airtouch_status != AirTouchStatus.OK: + errors["base"] = "cannot_connect" + elif not airtouch_has_groups: + errors["base"] = "no_units" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) diff --git a/homeassistant/components/airtouch4/const.py b/homeassistant/components/airtouch4/const.py new file mode 100644 index 0000000000000..e110a6cee8115 --- /dev/null +++ b/homeassistant/components/airtouch4/const.py @@ -0,0 +1,3 @@ +"""Constants for the AirTouch4 integration.""" + +DOMAIN = "airtouch4" diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json new file mode 100644 index 0000000000000..8297081ae9dff --- /dev/null +++ b/homeassistant/components/airtouch4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "airtouch4", + "name": "AirTouch 4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airtouch4", + "requirements": [ + "airtouch4pyapi==1.0.5" + ], + "codeowners": [ + "@LonePurpleWolf" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json new file mode 100644 index 0000000000000..5259b20fb7356 --- /dev/null +++ b/homeassistant/components/airtouch4/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "title": "Setup your AirTouch 4 connection details.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + } + } +} diff --git a/homeassistant/components/airtouch4/translations/bg.json b/homeassistant/components/airtouch4/translations/bg.json new file mode 100644 index 0000000000000..4c9b4c409d043 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/ca.json b/homeassistant/components/airtouch4/translations/ca.json new file mode 100644 index 0000000000000..083c4a0ba875d --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "no_units": "No s'han trobat grups AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Configura els detalls de connexi\u00f3 d'AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/cs.json b/homeassistant/components/airtouch4/translations/cs.json new file mode 100644 index 0000000000000..6fabc170b6e60 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/de.json b/homeassistant/components/airtouch4/translations/de.json new file mode 100644 index 0000000000000..84f93d09962fc --- /dev/null +++ b/homeassistant/components/airtouch4/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "no_units": "Es konnten keine AirTouch 4-Gruppen gefunden werden." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Richte deine AirTouch 4-Verbindungsdetails ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/el.json b/homeassistant/components/airtouch4/translations/el.json new file mode 100644 index 0000000000000..004cb1a268f05 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/el.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 {intergration}." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/en.json b/homeassistant/components/airtouch4/translations/en.json new file mode 100644 index 0000000000000..0f86b78724987 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Setup your AirTouch 4 connection details." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/es.json b/homeassistant/components/airtouch4/translations/es.json new file mode 100644 index 0000000000000..65616d2a2e923 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "no_units": "No se pudo encontrar ning\u00fan grupo AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Configura los detalles de conexi\u00f3n de tu AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/et.json b/homeassistant/components/airtouch4/translations/et.json new file mode 100644 index 0000000000000..2b42935b18e1d --- /dev/null +++ b/homeassistant/components/airtouch4/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "no_units": "Ei leidnud \u00fchtegi AirTouch 4 gruppi." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "AirTouch 4 \u00fchenduse \u00fcksikasjade seadistamine." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/fr.json b/homeassistant/components/airtouch4/translations/fr.json new file mode 100644 index 0000000000000..8dce645e3f03e --- /dev/null +++ b/homeassistant/components/airtouch4/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "no_units": "Impossible de trouver des groupes AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + }, + "title": "Configurez les d\u00e9tails de votre connexion AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/he.json b/homeassistant/components/airtouch4/translations/he.json new file mode 100644 index 0000000000000..25fe66938d7e7 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/hu.json b/homeassistant/components/airtouch4/translations/hu.json new file mode 100644 index 0000000000000..861582fad3e03 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen kapcsol\u00f3d\u00e1s", + "no_units": "Nem tal\u00e1lhat\u00f3 AirTouch 4 csoport." + }, + "step": { + "user": { + "data": { + "host": "C\u00edm" + }, + "title": "\u00c1ll\u00edtsa be az AirTouch 4 csatlakoz\u00e1si adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/id.json b/homeassistant/components/airtouch4/translations/id.json new file mode 100644 index 0000000000000..9af558b9a4534 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/id.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "no_units": "Tidak dapat menemukan Grup AirTouch 4 apa pun." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Siapkan detail koneksi AirTouch 4 Anda." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/it.json b/homeassistant/components/airtouch4/translations/it.json new file mode 100644 index 0000000000000..f9a72a50e3386 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "no_units": "Impossibile trovare alcun gruppo AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Imposta i dettagli della connessione AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/ja.json b/homeassistant/components/airtouch4/translations/ja.json new file mode 100644 index 0000000000000..9cc4a7464e1e8 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "no_units": "AirTouch 4 Groups\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "title": "AirTouch 4\u63a5\u7d9a\u306e\u8a73\u7d30\u8a2d\u5b9a\u3092\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/nl.json b/homeassistant/components/airtouch4/translations/nl.json new file mode 100644 index 0000000000000..b0697ea04bfa0 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "no_units": "Kan geen AirTouch 4-groepen vinden." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Stel uw AirTouch 4 verbindingsgegevens in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/no.json b/homeassistant/components/airtouch4/translations/no.json new file mode 100644 index 0000000000000..66bf4e3b91526 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "no_units": "Kan ikke finne noen AirTouch 4 -grupper." + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "title": "Konfigurer AirTouch 4 -tilkoblingsdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/pl.json b/homeassistant/components/airtouch4/translations/pl.json new file mode 100644 index 0000000000000..55f0b72b1a7af --- /dev/null +++ b/homeassistant/components/airtouch4/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "no_units": "Nie mo\u017cna znale\u017a\u0107 \u017cadnych grup AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "title": "Konfiguracja po\u0142\u0105czenia AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/ru.json b/homeassistant/components/airtouch4/translations/ru.json new file mode 100644 index 0000000000000..cbb7b10de790b --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "no_units": "\u0413\u0440\u0443\u043f\u043f\u044b AirTouch 4 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "AirTouch 4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/tr.json b/homeassistant/components/airtouch4/translations/tr.json new file mode 100644 index 0000000000000..3add2bcac2030 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/tr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "no_units": "Herhangi bir AirTouch 4 Grubu bulunamad\u0131." + }, + "step": { + "user": { + "data": { + "host": "Sunucu" + }, + "title": "AirTouch 4 ba\u011flant\u0131 ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 ayarlay\u0131n." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/zh-Hant.json b/homeassistant/components/airtouch4/translations/zh-Hant.json new file mode 100644 index 0000000000000..9ac310b531b3c --- /dev/null +++ b/homeassistant/components/airtouch4/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "no_units": "\u627e\u4e0d\u5230\u4efb\u4f55 AirTouch 4 \u7fa4\u7d44\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u8a2d\u5b9a AirTouch 4 \u9023\u7dda\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index ac34c16d3d023..f65bb7b14c749 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1,6 +1,10 @@ """The airvisual component.""" +from __future__ import annotations + +from collections.abc import Mapping from datetime import timedelta from math import ceil +from typing import Any, Dict, cast from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( @@ -10,8 +14,8 @@ NodeProError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_IP_ADDRESS, CONF_LATITUDE, @@ -19,10 +23,16 @@ CONF_PASSWORD, CONF_SHOW_ON_MAP, CONF_STATE, + Platform, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + entity_registry, +) +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -42,22 +52,17 @@ LOGGER, ) -PLATFORMS = ["air_quality", "sensor"] - -DATA_LISTENER = "listener" +PLATFORMS = [Platform.SENSOR] DEFAULT_ATTRIBUTION = "Data provided by AirVisual" DEFAULT_NODE_PRO_UPDATE_INTERVAL = timedelta(minutes=1) -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @callback -def async_get_geography_id(geography_dict): +def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str: """Generate a unique ID from a geography dict.""" - if not geography_dict: - return - if CONF_CITY in geography_dict: return ", ".join( ( @@ -72,7 +77,9 @@ def async_get_geography_id(geography_dict): @callback -def async_get_cloud_api_update_interval(hass, api_key, num_consumers): +def async_get_cloud_api_update_interval( + hass: HomeAssistant, api_key: str, num_consumers: int +) -> timedelta: """Get a leveled scan interval for a particular cloud API key. This will shift based on the number of active consumers, thus keeping the user @@ -93,18 +100,23 @@ def async_get_cloud_api_update_interval(hass, api_key, num_consumers): @callback -def async_get_cloud_coordinators_by_api_key(hass, api_key): +def async_get_cloud_coordinators_by_api_key( + hass: HomeAssistant, api_key: str +) -> list[DataUpdateCoordinator]: """Get all DataUpdateCoordinator objects related to a particular API key.""" - coordinators = [] - for entry_id, coordinator in hass.data[DOMAIN][DATA_COORDINATOR].items(): - config_entry = hass.config_entries.async_get_entry(entry_id) - if config_entry.data.get(CONF_API_KEY) == api_key: - coordinators.append(coordinator) - return coordinators + return [ + coordinator + for entry_id, attrs in hass.data[DOMAIN].items() + if (entry := hass.config_entries.async_get_entry(entry_id)) + and (coordinator := attrs.get(DATA_COORDINATOR)) + and entry.data.get(CONF_API_KEY) == api_key + ] @callback -def async_sync_geo_coordinator_update_intervals(hass, api_key): +def async_sync_geo_coordinator_update_intervals( + hass: HomeAssistant, api_key: str +) -> None: """Sync the update interval for geography-based data coordinators (by API key).""" coordinators = async_get_cloud_coordinators_by_api_key(hass, api_key) @@ -124,31 +136,27 @@ def async_sync_geo_coordinator_update_intervals(hass, api_key): coordinator.update_interval = update_interval -async def async_setup(hass, config): - """Set up the AirVisual component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}, DATA_LISTENER: {}} - return True - - @callback -def _standardize_geography_config_entry(hass, config_entry): +def _standardize_geography_config_entry( + hass: HomeAssistant, entry: ConfigEntry +) -> None: """Ensure that geography config entries have appropriate properties.""" entry_updates = {} - if not config_entry.unique_id: + if not entry.unique_id: # If the config entry doesn't already have a unique ID, set one: - entry_updates["unique_id"] = config_entry.data[CONF_API_KEY] - if not config_entry.options: + entry_updates["unique_id"] = entry.data[CONF_API_KEY] + if not entry.options: # If the config entry doesn't already have any options set, set defaults: entry_updates["options"] = {CONF_SHOW_ON_MAP: True} - if config_entry.data.get(CONF_INTEGRATION_TYPE) not in [ + if entry.data.get(CONF_INTEGRATION_TYPE) not in [ INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, ]: # If the config entry data doesn't contain an integration type that we know # about, infer it from the data we have: - entry_updates["data"] = {**config_entry.data} - if CONF_CITY in config_entry.data: + entry_updates["data"] = {**entry.data} + if CONF_CITY in entry.data: entry_updates["data"][ CONF_INTEGRATION_TYPE ] = INTEGRATION_TYPE_GEOGRAPHY_NAME @@ -160,51 +168,52 @@ def _standardize_geography_config_entry(hass, config_entry): if not entry_updates: return - hass.config_entries.async_update_entry(config_entry, **entry_updates) + hass.config_entries.async_update_entry(entry, **entry_updates) @callback -def _standardize_node_pro_config_entry(hass, config_entry): +def _standardize_node_pro_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Node/Pro config entries have appropriate properties.""" - entry_updates = {} + entry_updates: dict[str, Any] = {} - if CONF_INTEGRATION_TYPE not in config_entry.data: + if CONF_INTEGRATION_TYPE not in entry.data: # If the config entry data doesn't contain the integration type, add it: entry_updates["data"] = { - **config_entry.data, + **entry.data, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO, } if not entry_updates: return - hass.config_entries.async_update_entry(config_entry, **entry_updates) + hass.config_entries.async_update_entry(entry, **entry_updates) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AirVisual as config entry.""" - if CONF_API_KEY in config_entry.data: - _standardize_geography_config_entry(hass, config_entry) + if CONF_API_KEY in entry.data: + _standardize_geography_config_entry(hass, entry) websession = aiohttp_client.async_get_clientsession(hass) - cloud_api = CloudAPI(config_entry.data[CONF_API_KEY], session=websession) + cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession) - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" - if CONF_CITY in config_entry.data: + if CONF_CITY in entry.data: api_coro = cloud_api.air_quality.city( - config_entry.data[CONF_CITY], - config_entry.data[CONF_STATE], - config_entry.data[CONF_COUNTRY], + entry.data[CONF_CITY], + entry.data[CONF_STATE], + entry.data[CONF_COUNTRY], ) else: api_coro = cloud_api.air_quality.nearest_city( - config_entry.data[CONF_LATITUDE], - config_entry.data[CONF_LONGITUDE], + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], ) try: - return await api_coro + data = await api_coro + return cast(Dict[str, Any], data) except (InvalidKeyError, KeyExpiredError) as ex: raise ConfigEntryAuthFailed from ex except AirVisualError as err: @@ -213,7 +222,7 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, LOGGER, - name=async_get_geography_id(config_entry.data), + name=async_get_geography_id(entry.data), # We give a placeholder update interval in order to create the coordinator; # then, below, we use the coordinator's presence (along with any other # coordinators using the same API key) to calculate an actual, leveled @@ -223,19 +232,31 @@ async def async_update_data(): ) # Only geography-based entries have options: - hass.data[DOMAIN][DATA_LISTENER][ - config_entry.entry_id - ] = config_entry.add_update_listener(async_reload_entry) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) else: - _standardize_node_pro_config_entry(hass, config_entry) + # Remove outdated air_quality entities from the entity registry if they exist: + ent_reg = entity_registry.async_get(hass) + for entity_entry in [ + e + for e in ent_reg.entities.values() + if e.config_entry_id == entry.entry_id + and e.entity_id.startswith("air_quality") + ]: + LOGGER.debug( + 'Removing deprecated air_quality entity: "%s"', entity_entry.entity_id + ) + ent_reg.async_remove(entity_entry.entity_id) + + _standardize_node_pro_config_entry(hass, entry) - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" try: async with NodeSamba( - config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD] + entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD] ) as node: - return await node.async_get_latest_measurements() + data = await node.async_get_latest_measurements() + return cast(Dict[str, Any], data) except NodeProError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err @@ -248,41 +269,39 @@ async def async_update_data(): ) await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} # Reassess the interval between 2 server requests - if CONF_API_KEY in config_entry.data: - async_sync_geo_coordinator_update_intervals( - hass, config_entry.data[CONF_API_KEY] - ) + if CONF_API_KEY in entry.data: + async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY]) - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate an old config entry.""" - version = config_entry.version + version = entry.version LOGGER.debug("Migrating from version %s", version) # 1 -> 2: One geography per config entry if version == 1: - version = config_entry.version = 2 + version = entry.version = 2 # Update the config entry to only include the first geography (there is always # guaranteed to be at least one): - geographies = list(config_entry.data[CONF_GEOGRAPHIES]) + geographies = list(entry.data[CONF_GEOGRAPHIES]) first_geography = geographies.pop(0) first_id = async_get_geography_id(first_geography) hass.config_entries.async_update_entry( - config_entry, + entry, unique_id=first_id, title=f"Cloud API ({first_id})", - data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **first_geography}, + data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography}, ) # For any geographies that remain, create a new config entry for each one: @@ -295,7 +314,7 @@ async def async_migrate_entry(hass, config_entry): hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **geography}, + data={CONF_API_KEY: entry.data[CONF_API_KEY], **geography}, ) ) @@ -304,62 +323,46 @@ async def async_migrate_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an AirVisual config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][DATA_COORDINATOR].pop(config_entry.entry_id) - remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) - remove_listener() - - if CONF_API_KEY in config_entry.data: + hass.data[DOMAIN].pop(entry.entry_id) + if CONF_API_KEY in entry.data: # Re-calculate the update interval period for any remaining consumers of # this API key: - async_sync_geo_coordinator_update_intervals( - hass, config_entry.data[CONF_API_KEY] - ) + async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY]) return unload_ok -async def async_reload_entry(hass, config_entry): +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) class AirVisualEntity(CoordinatorEntity): """Define a generic AirVisual entity.""" - def __init__(self, coordinator): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + description: EntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._icon = None - self._unit = None - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - async def async_added_to_hass(self): + + self._attr_extra_state_attributes = {} + self._entry = entry + self.entity_description = description + + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" self.update_from_latest_data() self.async_write_ha_state() @@ -369,6 +372,6 @@ def update(): self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/airvisual/air_quality.py b/homeassistant/components/airvisual/air_quality.py deleted file mode 100644 index 047367fa67c68..0000000000000 --- a/homeassistant/components/airvisual/air_quality.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Support for AirVisual Node/Pro units.""" -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -from homeassistant.core import callback - -from . import AirVisualEntity -from .const import ( - CONF_INTEGRATION_TYPE, - DATA_COORDINATOR, - DOMAIN, - INTEGRATION_TYPE_NODE_PRO, -) - -ATTR_HUMIDITY = "humidity" -ATTR_SENSOR_LIFE = "{0}_sensor_life" -ATTR_VOC = "voc" - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up AirVisual air quality entities based on a config entry.""" - # Geography-based AirVisual integrations don't utilize this platform: - if config_entry.data[CONF_INTEGRATION_TYPE] != INTEGRATION_TYPE_NODE_PRO: - return - - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] - - async_add_entities([AirVisualNodeProSensor(coordinator)], True) - - -class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity): - """Define a sensor for a AirVisual Node/Pro.""" - - def __init__(self, airvisual): - """Initialize.""" - super().__init__(airvisual) - - self._icon = "mdi:chemical-weapon" - self._unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - - @property - def air_quality_index(self): - """Return the Air Quality Index (AQI).""" - if self.coordinator.data["settings"]["is_aqi_usa"]: - return self.coordinator.data["measurements"]["aqi_us"] - return self.coordinator.data["measurements"]["aqi_cn"] - - @property - def available(self): - """Return True if entity is available.""" - return bool(self.coordinator.data) - - @property - def carbon_dioxide(self): - """Return the CO2 (carbon dioxide) level.""" - return self.coordinator.data["measurements"].get("co2") - - @property - def device_info(self): - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self.coordinator.data["serial_number"])}, - "name": self.coordinator.data["settings"]["node_name"], - "manufacturer": "AirVisual", - "model": f'{self.coordinator.data["status"]["model"]}', - "sw_version": ( - f'Version {self.coordinator.data["status"]["system_version"]}' - f'{self.coordinator.data["status"]["app_version"]}' - ), - } - - @property - def name(self): - """Return the name.""" - node_name = self.coordinator.data["settings"]["node_name"] - return f"{node_name} Node/Pro: Air Quality" - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return self.coordinator.data["measurements"].get("pm2_5") - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self.coordinator.data["measurements"].get("pm1_0") - - @property - def particulate_matter_0_1(self): - """Return the particulate matter 0.1 level.""" - return self.coordinator.data["measurements"].get("pm0_1") - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self.coordinator.data["serial_number"] - - @callback - def update_from_latest_data(self): - """Update the entity from the latest data.""" - self._attrs.update( - { - ATTR_VOC: self.coordinator.data["measurements"].get("voc"), - **{ - ATTR_SENSOR_LIFE.format(pollutant): lifespan - for pollutant, lifespan in self.coordinator.data["status"][ - "sensor_life" - ].items() - }, - } - ) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index ef7873a31b10c..85ee4bf6ae5f2 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -1,4 +1,6 @@ """Define a config flow manager for AirVisual.""" +from __future__ import annotations + import asyncio from pyairvisual import CloudAPI, NodeSamba @@ -11,6 +13,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import ( CONF_API_KEY, CONF_IP_ADDRESS, @@ -21,6 +24,7 @@ CONF_STATE, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from . import async_get_geography_id @@ -64,13 +68,13 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" - self._entry_data_for_reauth = None - self._geo_id = None + self._entry_data_for_reauth: dict[str, str] = {} + self._geo_id: str | None = None @property - def geography_coords_schema(self): + def geography_coords_schema(self) -> vol.Schema: """Return the data schema for the cloud API.""" return API_KEY_DATA_SCHEMA.extend( { @@ -83,8 +87,11 @@ def geography_coords_schema(self): } ) - async def _async_finish_geography(self, user_input, integration_type): + async def _async_finish_geography( + self, user_input: dict[str, str], integration_type: str + ) -> FlowResult: """Validate a Cloud API key.""" + errors = {} websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) @@ -111,30 +118,26 @@ async def _async_finish_geography(self, user_input, integration_type): try: await coro except InvalidKeyError: - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={CONF_API_KEY: "invalid_api_key"}, - ) + errors[CONF_API_KEY] = "invalid_api_key" except NotFoundError: - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={CONF_CITY: "location_not_found"}, - ) + errors[CONF_CITY] = "location_not_found" except AirVisualError as err: LOGGER.error(err) - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={"base": "unknown"}, - ) + errors["base"] = "unknown" valid_keys.add(user_input[CONF_API_KEY]) + if errors: + return self.async_show_form( + step_id=error_step, data_schema=error_schema, errors=errors + ) + existing_entry = await self.async_set_unique_id(self._geo_id) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) return self.async_abort(reason="reauth_successful") return self.async_create_entry( @@ -142,25 +145,29 @@ async def _async_finish_geography(self, user_input, integration_type): data={**user_input, CONF_INTEGRATION_TYPE: integration_type}, ) - async def _async_init_geography(self, user_input, integration_type): + async def _async_init_geography( + self, user_input: dict[str, str], integration_type: str + ) -> FlowResult: """Handle the initialization of the integration via the cloud API.""" self._geo_id = async_get_geography_id(user_input) await self._async_set_unique_id(self._geo_id) self._abort_if_unique_id_configured() return await self._async_finish_geography(user_input, integration_type) - async def _async_set_unique_id(self, unique_id): + async def _async_set_unique_id(self, unique_id: str) -> None: """Set the unique ID of the config flow and abort if it already exists.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Define the config flow to handle options.""" return AirVisualOptionsFlowHandler(config_entry) - async def async_step_geography_by_coords(self, user_input=None): + async def async_step_geography_by_coords( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the cloud API based on latitude/longitude.""" if not user_input: return self.async_show_form( @@ -171,7 +178,9 @@ async def async_step_geography_by_coords(self, user_input=None): user_input, INTEGRATION_TYPE_GEOGRAPHY_COORDS ) - async def async_step_geography_by_name(self, user_input=None): + async def async_step_geography_by_name( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the cloud API based on city/state/country.""" if not user_input: return self.async_show_form( @@ -182,7 +191,9 @@ async def async_step_geography_by_name(self, user_input=None): user_input, INTEGRATION_TYPE_GEOGRAPHY_NAME ) - async def async_step_node_pro(self, user_input=None): + async def async_step_node_pro( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the integration with a Node/Pro.""" if not user_input: return self.async_show_form(step_id="node_pro", data_schema=NODE_PRO_SCHEMA) @@ -208,26 +219,30 @@ async def async_step_node_pro(self, user_input=None): data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: """Handle configuration by re-auth.""" self._entry_data_for_reauth = data self._geo_id = async_get_geography_id(data) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle re-auth completion.""" if not user_input: return self.async_show_form( step_id="reauth_confirm", data_schema=API_KEY_DATA_SCHEMA ) - conf = {CONF_API_KEY: user_input[CONF_API_KEY], **self._entry_data_for_reauth} + conf = {**self._entry_data_for_reauth, CONF_API_KEY: user_input[CONF_API_KEY]} return await self._async_finish_geography( conf, self._entry_data_for_reauth[CONF_INTEGRATION_TYPE] ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form( @@ -244,11 +259,13 @@ async def async_step_user(self, user_input=None): class AirVisualOptionsFlowHandler(config_entries.OptionsFlow): """Handle an AirVisual options flow.""" - def __init__(self, config_entry): + def __init__(self, entry: ConfigEntry) -> None: """Initialize.""" - self.config_entry = config_entry + self.entry = entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -259,7 +276,7 @@ async def async_step_init(self, user_input=None): { vol.Required( CONF_SHOW_ON_MAP, - default=self.config_entry.options.get(CONF_SHOW_ON_MAP), + default=self.entry.options.get(CONF_SHOW_ON_MAP), ): bool } ), diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index b94218f6c1334..5d6a221dbbe0b 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -3,7 +3,7 @@ "name": "AirVisual", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airvisual", - "requirements": ["pyairvisual==5.0.8"], + "requirements": ["pyairvisual==5.0.9"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 1febcec68f4be..13b8711271651 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,5 +1,13 @@ """Support for AirVisual air quality sensors.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -11,13 +19,13 @@ CONF_LONGITUDE, CONF_SHOW_ON_MAP, CONF_STATE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AirVisualEntity from .const import ( @@ -36,104 +44,163 @@ ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_REGION = "region" -SENSOR_KIND_LEVEL = "air_pollution_level" +DEVICE_CLASS_POLLUTANT_LABEL = "airvisual__pollutant_label" +DEVICE_CLASS_POLLUTANT_LEVEL = "airvisual__pollutant_level" + SENSOR_KIND_AQI = "air_quality_index" -SENSOR_KIND_POLLUTANT = "main_pollutant" SENSOR_KIND_BATTERY_LEVEL = "battery_level" +SENSOR_KIND_CO2 = "carbon_dioxide" SENSOR_KIND_HUMIDITY = "humidity" +SENSOR_KIND_LEVEL = "air_pollution_level" +SENSOR_KIND_PM_0_1 = "particulate_matter_0_1" +SENSOR_KIND_PM_1_0 = "particulate_matter_1_0" +SENSOR_KIND_PM_2_5 = "particulate_matter_2_5" +SENSOR_KIND_POLLUTANT = "main_pollutant" +SENSOR_KIND_SENSOR_LIFE = "sensor_life" SENSOR_KIND_TEMPERATURE = "temperature" - -GEOGRAPHY_SENSORS = [ - (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), - (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), - (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), -] +SENSOR_KIND_VOC = "voc" + +GEOGRAPHY_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_KIND_LEVEL, + name="Air Pollution Level", + device_class=DEVICE_CLASS_POLLUTANT_LEVEL, + icon="mdi:gauge", + ), + SensorEntityDescription( + key=SENSOR_KIND_AQI, + name="Air Quality Index", + device_class=SensorDeviceClass.AQI, + native_unit_of_measurement="AQI", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_POLLUTANT, + name="Main Pollutant", + device_class=DEVICE_CLASS_POLLUTANT_LABEL, + icon="mdi:chemical-weapon", + ), +) GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} -NODE_PRO_SENSORS = [ - (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, PERCENTAGE), - (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, PERCENTAGE), - (SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS), -] - - -@callback -def async_get_pollutant_label(symbol): - """Get a pollutant's label based on its symbol.""" - if symbol == "co": - return "Carbon Monoxide" - if symbol == "n2": - return "Nitrogen Dioxide" - if symbol == "o3": - return "Ozone" - if symbol == "p1": - return "PM10" - if symbol == "p2": - return "PM2.5" - if symbol == "s2": - return "Sulfur Dioxide" - return symbol - - -@callback -def async_get_pollutant_level_info(value): - """Return a verbal pollutant level (and associated icon) for a numeric value.""" - if 0 <= value <= 50: - return ("Good", "mdi:emoticon-excited") - if 51 <= value <= 100: - return ("Moderate", "mdi:emoticon-happy") - if 101 <= value <= 150: - return ("Unhealthy for sensitive groups", "mdi:emoticon-neutral") - if 151 <= value <= 200: - return ("Unhealthy", "mdi:emoticon-sad") - if 201 <= value <= 300: - return ("Very Unhealthy", "mdi:emoticon-dead") - return ("Hazardous", "mdi:biohazard") - - -@callback -def async_get_pollutant_unit(symbol): - """Get a pollutant's unit based on its symbol.""" - if symbol == "co": - return CONCENTRATION_PARTS_PER_MILLION - if symbol == "n2": - return CONCENTRATION_PARTS_PER_BILLION - if symbol == "o3": - return CONCENTRATION_PARTS_PER_BILLION - if symbol == "p1": - return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - if symbol == "p2": - return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - if symbol == "s2": - return CONCENTRATION_PARTS_PER_BILLION - return None - +NODE_PRO_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_KIND_AQI, + name="Air Quality Index", + device_class=SensorDeviceClass.AQI, + native_unit_of_measurement="AQI", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_BATTERY_LEVEL, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=SENSOR_KIND_CO2, + name="C02", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_HUMIDITY, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=SENSOR_KIND_PM_0_1, + name="PM 0.1", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_PM_1_0, + name="PM 1.0", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_PM_2_5, + name="PM 2.5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_KIND_VOC, + name="VOC", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), +) -async def async_setup_entry(hass, config_entry, async_add_entities): +STATE_POLLUTANT_LABEL_CO = "co" +STATE_POLLUTANT_LABEL_N2 = "n2" +STATE_POLLUTANT_LABEL_O3 = "o3" +STATE_POLLUTANT_LABEL_P1 = "p1" +STATE_POLLUTANT_LABEL_P2 = "p2" +STATE_POLLUTANT_LABEL_S2 = "s2" + +STATE_POLLUTANT_LEVEL_GOOD = "good" +STATE_POLLUTANT_LEVEL_MODERATE = "moderate" +STATE_POLLUTANT_LEVEL_UNHEALTHY_SENSITIVE = "unhealthy_sensitive" +STATE_POLLUTANT_LEVEL_UNHEALTHY = "unhealthy" +STATE_POLLUTANT_LEVEL_VERY_UNHEALTHY = "very_unhealthy" +STATE_POLLUTANT_LEVEL_HAZARDOUS = "hazardous" + +POLLUTANT_LEVELS = { + (0, 50): (STATE_POLLUTANT_LEVEL_GOOD, "mdi:emoticon-excited"), + (51, 100): (STATE_POLLUTANT_LEVEL_MODERATE, "mdi:emoticon-happy"), + (101, 150): (STATE_POLLUTANT_LEVEL_UNHEALTHY_SENSITIVE, "mdi:emoticon-neutral"), + (151, 200): (STATE_POLLUTANT_LEVEL_UNHEALTHY, "mdi:emoticon-sad"), + (201, 300): (STATE_POLLUTANT_LEVEL_VERY_UNHEALTHY, "mdi:emoticon-dead"), + (301, 1000): (STATE_POLLUTANT_LEVEL_HAZARDOUS, "mdi:biohazard"), +} + +POLLUTANT_UNITS = { + "co": CONCENTRATION_PARTS_PER_MILLION, + "n2": CONCENTRATION_PARTS_PER_BILLION, + "o3": CONCENTRATION_PARTS_PER_BILLION, + "p1": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "p2": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "s2": CONCENTRATION_PARTS_PER_BILLION, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up AirVisual sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - if config_entry.data[CONF_INTEGRATION_TYPE] in [ + sensors: list[AirVisualGeographySensor | AirVisualNodeProSensor] + if entry.data[CONF_INTEGRATION_TYPE] in ( INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, - ]: + ): sensors = [ - AirVisualGeographySensor( - coordinator, - config_entry, - kind, - name, - icon, - unit, - locale, - ) + AirVisualGeographySensor(coordinator, entry, description, locale) for locale in GEOGRAPHY_SENSOR_LOCALES - for kind, name, icon, unit in GEOGRAPHY_SENSORS + for description in GEOGRAPHY_SENSOR_DESCRIPTIONS ] else: sensors = [ - AirVisualNodeProSensor(coordinator, kind, name, device_class, unit) - for kind, name, device_class, unit in NODE_PRO_SENSORS + AirVisualNodeProSensor(coordinator, entry, description) + for description in NODE_PRO_SENSOR_DESCRIPTIONS ] async_add_entities(sensors, True) @@ -142,70 +209,56 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AirVisualGeographySensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to geography data via the Cloud API.""" - def __init__(self, coordinator, config_entry, kind, name, icon, unit, locale): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + description: SensorEntityDescription, + locale: str, + ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, entry, description) - self._attrs.update( + self._attr_extra_state_attributes.update( { - ATTR_CITY: config_entry.data.get(CONF_CITY), - ATTR_STATE: config_entry.data.get(CONF_STATE), - ATTR_COUNTRY: config_entry.data.get(CONF_COUNTRY), + ATTR_CITY: entry.data.get(CONF_CITY), + ATTR_STATE: entry.data.get(CONF_STATE), + ATTR_COUNTRY: entry.data.get(CONF_COUNTRY), } ) - self._config_entry = config_entry - self._icon = icon - self._kind = kind + self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {description.name}" + self._attr_unique_id = f"{entry.unique_id}_{locale}_{description.key}" self._locale = locale - self._name = name - self._state = None - self._unit = unit @property - def available(self): - """Return True if entity is available.""" - try: - return self.coordinator.last_update_success and bool( - self.coordinator.data["current"]["pollution"] - ) - except KeyError: - return False - - @property - def name(self): - """Return the name.""" - return f"{GEOGRAPHY_SENSOR_LOCALES[self._locale]} {self._name}" - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._config_entry.unique_id}_{self._locale}_{self._kind}" + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data["current"]["pollution"] @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" try: data = self.coordinator.data["current"]["pollution"] except KeyError: return - if self._kind == SENSOR_KIND_LEVEL: + if self.entity_description.key == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] - self._state, self._icon = async_get_pollutant_level_info(aqi) - elif self._kind == SENSOR_KIND_AQI: - self._state = data[f"aqi{self._locale}"] - elif self._kind == SENSOR_KIND_POLLUTANT: + [(self._attr_native_value, self._attr_icon)] = [ + (name, icon) + for (floor, ceiling), (name, icon) in POLLUTANT_LEVELS.items() + if floor <= aqi <= ceiling + ] + elif self.entity_description.key == SENSOR_KIND_AQI: + self._attr_native_value = data[f"aqi{self._locale}"] + elif self.entity_description.key == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] - self._state = async_get_pollutant_label(symbol) - self._attrs.update( + self._attr_native_value = symbol + self._attr_extra_state_attributes.update( { ATTR_POLLUTANT_SYMBOL: symbol, - ATTR_POLLUTANT_UNIT: async_get_pollutant_unit(symbol), + ATTR_POLLUTANT_UNIT: POLLUTANT_UNITS[symbol], } ) @@ -216,81 +269,87 @@ def update_from_latest_data(self): # # We use any coordinates in the config entry and, in the case of a geography by # name, we fall back to the latitude longitude provided in the coordinator data: - latitude = self._config_entry.data.get( + latitude = self._entry.data.get( CONF_LATITUDE, self.coordinator.data["location"]["coordinates"][1], ) - longitude = self._config_entry.data.get( + longitude = self._entry.data.get( CONF_LONGITUDE, self.coordinator.data["location"]["coordinates"][0], ) - if self._config_entry.options[CONF_SHOW_ON_MAP]: - self._attrs[ATTR_LATITUDE] = latitude - self._attrs[ATTR_LONGITUDE] = longitude - self._attrs.pop("lati", None) - self._attrs.pop("long", None) + if self._entry.options[CONF_SHOW_ON_MAP]: + self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude + self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude + self._attr_extra_state_attributes.pop("lati", None) + self._attr_extra_state_attributes.pop("long", None) else: - self._attrs["lati"] = latitude - self._attrs["long"] = longitude - self._attrs.pop(ATTR_LATITUDE, None) - self._attrs.pop(ATTR_LONGITUDE, None) + self._attr_extra_state_attributes["lati"] = latitude + self._attr_extra_state_attributes["long"] = longitude + self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None) + self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None) class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to a Node/Pro unit.""" - def __init__(self, coordinator, kind, name, device_class, unit): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + description: SensorEntityDescription, + ) -> None: """Initialize.""" - super().__init__(coordinator) - - self._device_class = device_class - self._kind = kind - self._name = name - self._state = None - self._unit = unit + super().__init__(coordinator, entry, description) - @property - def device_class(self): - """Return the device class.""" - return self._device_class + self._attr_name = ( + f"{coordinator.data['settings']['node_name']} Node/Pro: {description.name}" + ) + self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self.coordinator.data["serial_number"])}, - "name": self.coordinator.data["settings"]["node_name"], - "manufacturer": "AirVisual", - "model": f'{self.coordinator.data["status"]["model"]}', - "sw_version": ( + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data["serial_number"])}, + manufacturer="AirVisual", + model=f'{self.coordinator.data["status"]["model"]}', + name=self.coordinator.data["settings"]["node_name"], + sw_version=( f'Version {self.coordinator.data["status"]["system_version"]}' f'{self.coordinator.data["status"]["app_version"]}' ), - } - - @property - def name(self): - """Return the name.""" - node_name = self.coordinator.data["settings"]["node_name"] - return f"{node_name} Node/Pro: {self._name}" - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.coordinator.data['serial_number']}_{self._kind}" + ) @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - if self._kind == SENSOR_KIND_BATTERY_LEVEL: - self._state = self.coordinator.data["status"]["battery"] - elif self._kind == SENSOR_KIND_HUMIDITY: - self._state = self.coordinator.data["measurements"].get("humidity") - elif self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinator.data["measurements"].get("temperature_C") + if self.entity_description.key == SENSOR_KIND_AQI: + if self.coordinator.data["settings"]["is_aqi_usa"]: + self._attr_native_value = self.coordinator.data["measurements"][ + "aqi_us" + ] + else: + self._attr_native_value = self.coordinator.data["measurements"][ + "aqi_cn" + ] + elif self.entity_description.key == SENSOR_KIND_BATTERY_LEVEL: + self._attr_native_value = self.coordinator.data["status"]["battery"] + elif self.entity_description.key == SENSOR_KIND_CO2: + self._attr_native_value = self.coordinator.data["measurements"].get("co2") + elif self.entity_description.key == SENSOR_KIND_HUMIDITY: + self._attr_native_value = self.coordinator.data["measurements"].get( + "humidity" + ) + elif self.entity_description.key == SENSOR_KIND_PM_0_1: + self._attr_native_value = self.coordinator.data["measurements"].get("pm0_1") + elif self.entity_description.key == SENSOR_KIND_PM_1_0: + self._attr_native_value = self.coordinator.data["measurements"].get("pm1_0") + elif self.entity_description.key == SENSOR_KIND_PM_2_5: + self._attr_native_value = self.coordinator.data["measurements"].get("pm2_5") + elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: + self._attr_native_value = self.coordinator.data["measurements"].get( + "temperature_C" + ) + elif self.entity_description.key == SENSOR_KIND_VOC: + self._attr_native_value = self.coordinator.data["measurements"].get("voc") diff --git a/homeassistant/components/airvisual/strings.sensor.json b/homeassistant/components/airvisual/strings.sensor.json new file mode 100644 index 0000000000000..583ddaf4f3b32 --- /dev/null +++ b/homeassistant/components/airvisual/strings.sensor.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Carbon Monoxide", + "n2": "Nitrogen Dioxide", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Sulfur Dioxide" + }, + "airvisual__pollutant_level": { + "good": "Good", + "moderate": "Moderate", + "unhealthy": "Unhealthy", + "unhealthy_sensitive": "Unhealthy for sensitive groups", + "very_unhealthy": "Very unhealthy", + "hazardous": "Hazardous" + } + } +} diff --git a/homeassistant/components/airvisual/translations/bg.json b/homeassistant/components/airvisual/translations/bg.json index 7e463418576e5..114a1547549a1 100644 --- a/homeassistant/components/airvisual/translations/bg.json +++ b/homeassistant/components/airvisual/translations/bg.json @@ -1,11 +1,36 @@ { "config": { + "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "general_error": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430", + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" + }, "step": { + "geography_by_coords": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, "geography_by_name": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "city": "\u0413\u0440\u0430\u0434", "country": "\u0421\u0442\u0440\u0430\u043d\u0430" } + }, + "node_pro": { + "data": { + "ip_address": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, + "reauth_confirm": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } } } } diff --git a/homeassistant/components/airvisual/translations/ca.json b/homeassistant/components/airvisual/translations/ca.json index 29df3dc7ca272..0440189cdb985 100644 --- a/homeassistant/components/airvisual/translations/ca.json +++ b/homeassistant/components/airvisual/translations/ca.json @@ -11,15 +11,6 @@ "location_not_found": "No s'ha trobat la ubicaci\u00f3" }, "step": { - "geography": { - "data": { - "api_key": "[%key::common::config_flow::data::api_key%]", - "latitude": "Latitud", - "longitude": "Longitud" - }, - "description": "Utilitza l'API d'AirVisual per monitoritzar una ubicaci\u00f3 geogr\u00e0fica.", - "title": "Configura una ubicaci\u00f3 geogr\u00e0fica" - }, "geography_by_coords": { "data": { "api_key": "Clau API", @@ -54,11 +45,6 @@ "title": "Re-autenticaci\u00f3 amb AirVisual" }, "user": { - "data": { - "cloud_api": "Ubicaci\u00f3 geogr\u00e0fica", - "node_pro": "AirVisual Node Pro", - "type": "Tipus d'integraci\u00f3" - }, "description": "Tria quin tipus de dades d'AirVisual vols monitoritzar.", "title": "Configura AirVisual" } diff --git a/homeassistant/components/airvisual/translations/cs.json b/homeassistant/components/airvisual/translations/cs.json index 75720b0f30b7d..4fd193e6ddc1f 100644 --- a/homeassistant/components/airvisual/translations/cs.json +++ b/homeassistant/components/airvisual/translations/cs.json @@ -10,13 +10,6 @@ "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API" }, "step": { - "geography": { - "data": { - "api_key": "Kl\u00ed\u010d API", - "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", - "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" - } - }, "geography_by_coords": { "data": { "api_key": "Kl\u00ed\u010d API", @@ -45,11 +38,6 @@ "title": "Znovu ov\u011b\u0159it AirVisual" }, "user": { - "data": { - "cloud_api": "Geografick\u00e1 poloha", - "node_pro": "AirVisual Node Pro", - "type": "Typ integrace" - }, "description": "Vyberte, jak\u00fd typ dat AirVisual chcete sledovat.", "title": "Nastaven\u00ed AirVisual" } diff --git a/homeassistant/components/airvisual/translations/de.json b/homeassistant/components/airvisual/translations/de.json index 6e2a5f60c6fed..c6d00ea137504 100644 --- a/homeassistant/components/airvisual/translations/de.json +++ b/homeassistant/components/airvisual/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Diese Koordinaten oder Node/Pro ID sind bereits registriert.", + "already_configured": "Diese Node/Pro ID oder Standort ist bereits konfiguriert.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { @@ -11,15 +11,6 @@ "location_not_found": "Standort nicht gefunden" }, "step": { - "geography": { - "data": { - "api_key": "API-Schl\u00fcssel", - "latitude": "Breitengrad", - "longitude": "L\u00e4ngengrad" - }, - "description": "Verwende die AirVisual Cloud API, um einen geografischen Standort zu \u00fcberwachen.", - "title": "Konfigurieren Sie eine Geografie" - }, "geography_by_coords": { "data": { "api_key": "API-Schl\u00fcssel", @@ -44,23 +35,18 @@ "ip_address": "Host", "password": "Passwort" }, - "description": "\u00dcberwachen Sie eine pers\u00f6nliche AirVisual-Einheit. Das Passwort kann von der Benutzeroberfl\u00e4che des Ger\u00e4ts abgerufen werden.", - "title": "Konfigurieren Sie einen AirVisual Node/Pro" + "description": "\u00dcberwache eine pers\u00f6nliche AirVisual-Einheit. Das Passwort kann von der Benutzeroberfl\u00e4che des Ger\u00e4ts abgerufen werden.", + "title": "Konfiguriere einen AirVisual Node/Pro" }, "reauth_confirm": { "data": { - "api_key": "API-Key" + "api_key": "API-Schl\u00fcssel" }, "title": "AirVisual erneut authentifizieren" }, "user": { - "data": { - "cloud_api": "Geografische Position", - "node_pro": "AirVisual Node Pro", - "type": "Integrationstyp" - }, - "description": "W\u00e4hlen Sie aus, welche Art von AirVisual-Daten Sie \u00fcberwachen m\u00f6chten.", - "title": "Konfigurieren Sie AirVisual" + "description": "W\u00e4hle aus, welche Art von AirVisual-Daten du \u00fcberwachen m\u00f6chtest.", + "title": "Konfiguriere AirVisual" } } }, @@ -68,9 +54,9 @@ "step": { "init": { "data": { - "show_on_map": "Zeigen Sie die \u00fcberwachte Geografie auf der Karte an" + "show_on_map": "Zeige die \u00fcberwachte Geografie auf der Karte an" }, - "title": "Konfigurieren Sie AirVisual" + "title": "Konfiguriere AirVisual" } } } diff --git a/homeassistant/components/airvisual/translations/en.json b/homeassistant/components/airvisual/translations/en.json index 64eb11f902c8a..1e3cb59a5204f 100644 --- a/homeassistant/components/airvisual/translations/en.json +++ b/homeassistant/components/airvisual/translations/en.json @@ -11,15 +11,6 @@ "location_not_found": "Location not found" }, "step": { - "geography": { - "data": { - "api_key": "API Key", - "latitude": "Latitude", - "longitude": "Longitude" - }, - "description": "Use the AirVisual cloud API to monitor a geographical location.", - "title": "Configure a Geography" - }, "geography_by_coords": { "data": { "api_key": "API Key", @@ -54,11 +45,6 @@ "title": "Re-authenticate AirVisual" }, "user": { - "data": { - "cloud_api": "Geographical Location", - "node_pro": "AirVisual Node Pro", - "type": "Integration Type" - }, "description": "Pick what type of AirVisual data you want to monitor.", "title": "Configure AirVisual" } diff --git a/homeassistant/components/airvisual/translations/es-419.json b/homeassistant/components/airvisual/translations/es-419.json index 0cc07d27f171c..6e26be959f9f5 100644 --- a/homeassistant/components/airvisual/translations/es-419.json +++ b/homeassistant/components/airvisual/translations/es-419.json @@ -9,19 +9,19 @@ "location_not_found": "Ubicaci\u00f3n no encontrada" }, "step": { - "geography": { - "data": { - "api_key": "Clave API", - "latitude": "Latitud", - "longitude": "Longitud" - }, - "description": "Use la API de AirVisual para monitorear una ubicaci\u00f3n geogr\u00e1fica.", - "title": "Configurar una geograf\u00eda" - }, "geography_by_coords": { "description": "Utilice la API en la nube de AirVisual para monitorear una latitud / longitud.", "title": "Configurar una geograf\u00eda" }, + "geography_by_name": { + "data": { + "city": "Ciudad", + "country": "Pa\u00eds", + "state": "estado" + }, + "description": "Utilice la API en la nube de AirVisual para monitorear una ciudad/estado/pa\u00eds.", + "title": "Configurar una geograf\u00eda" + }, "node_pro": { "data": { "ip_address": "Direcci\u00f3n IP/nombre de host de la unidad", @@ -30,12 +30,10 @@ "description": "Monitoree una unidad AirVisual personal. La contrase\u00f1a se puede recuperar de la interfaz de usuario de la unidad.", "title": "Configurar un AirVisual Node/Pro" }, + "reauth_confirm": { + "title": "Vuelva a autenticar AirVisual" + }, "user": { - "data": { - "cloud_api": "Localizaci\u00f3n geogr\u00e1fica", - "node_pro": "AirVisual Node Pro", - "type": "Tipo de integraci\u00f3n" - }, "description": "Monitoree la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.", "title": "Configurar AirVisual" } diff --git a/homeassistant/components/airvisual/translations/es.json b/homeassistant/components/airvisual/translations/es.json index 53768f679be68..6e7ea6e69034c 100644 --- a/homeassistant/components/airvisual/translations/es.json +++ b/homeassistant/components/airvisual/translations/es.json @@ -11,15 +11,6 @@ "location_not_found": "Ubicaci\u00f3n no encontrada" }, "step": { - "geography": { - "data": { - "api_key": "Clave API", - "latitude": "Latitud", - "longitude": "Longitud" - }, - "description": "Utilizar la API en la nube de AirVisual para monitorizar una ubicaci\u00f3n geogr\u00e1fica.", - "title": "Configurar una Geograf\u00eda" - }, "geography_by_coords": { "data": { "api_key": "Clave API", @@ -54,11 +45,6 @@ "title": "Volver a autenticar AirVisual" }, "user": { - "data": { - "cloud_api": "Ubicaci\u00f3n Geogr\u00e1fica", - "node_pro": "AirVisual Node Pro", - "type": "Tipo de Integraci\u00f3n" - }, "description": "Elige qu\u00e9 tipo de datos de AirVisual quieres monitorizar.", "title": "Configurar AirVisual" } diff --git a/homeassistant/components/airvisual/translations/et.json b/homeassistant/components/airvisual/translations/et.json index 0fae2bcc57bb6..45490bb63febb 100644 --- a/homeassistant/components/airvisual/translations/et.json +++ b/homeassistant/components/airvisual/translations/et.json @@ -11,15 +11,6 @@ "location_not_found": "Asukohta ei leitud" }, "step": { - "geography": { - "data": { - "api_key": "API v\u00f5ti", - "latitude": "Laiuskraad", - "longitude": "Pikkuskraad" - }, - "description": "Kasuta AirVisual pilve API-t geograafilise asukoha j\u00e4lgimiseks.", - "title": "Seadista Geography" - }, "geography_by_coords": { "data": { "api_key": "API v\u00f5ti", @@ -54,11 +45,6 @@ "title": "Taastuvasta AirVisual" }, "user": { - "data": { - "cloud_api": "Geograafiline asukoht", - "node_pro": "", - "type": "Sidumise t\u00fc\u00fcp" - }, "description": "Vali millist t\u00fc\u00fcpi AirVisuali andmeid soovid j\u00e4lgida.", "title": "Seadista AirVisual" } diff --git a/homeassistant/components/airvisual/translations/fi.json b/homeassistant/components/airvisual/translations/fi.json index b193b59de2610..044d7688551a7 100644 --- a/homeassistant/components/airvisual/translations/fi.json +++ b/homeassistant/components/airvisual/translations/fi.json @@ -4,24 +4,10 @@ "general_error": "Tapahtui tuntematon virhe." }, "step": { - "geography": { - "data": { - "api_key": "API-avain", - "latitude": "Leveysaste", - "longitude": "Pituusaste" - } - }, "node_pro": { "data": { "password": "Salasana" } - }, - "user": { - "data": { - "cloud_api": "Maantieteellinen sijainti", - "node_pro": "AirVisual Node Pro", - "type": "Integrointityyppi" - } } } } diff --git a/homeassistant/components/airvisual/translations/fr.json b/homeassistant/components/airvisual/translations/fr.json index 62c144e075dde..4817a720225d6 100644 --- a/homeassistant/components/airvisual/translations/fr.json +++ b/homeassistant/components/airvisual/translations/fr.json @@ -11,18 +11,9 @@ "location_not_found": "Emplacement introuvable" }, "step": { - "geography": { - "data": { - "api_key": "Cl\u00e9 API", - "latitude": "Latitude", - "longitude": "Longitude" - }, - "description": "Utilisez l'API cloud AirVisual pour surveiller une position g\u00e9ographique.", - "title": "Configurer une g\u00e9ographie" - }, "geography_by_coords": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude" }, @@ -31,7 +22,7 @@ }, "geography_by_name": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "city": "Ville", "country": "Pays", "state": "Etat" @@ -54,11 +45,6 @@ "title": "R\u00e9-authentifier AirVisual" }, "user": { - "data": { - "cloud_api": "Localisation g\u00e9ographique", - "node_pro": "AirVisual Node Pro", - "type": "Type d'int\u00e9gration" - }, "description": "Choisissez le type de donn\u00e9es AirVisual que vous souhaitez surveiller.", "title": "Configurer AirVisual" } diff --git a/homeassistant/components/airvisual/translations/he.json b/homeassistant/components/airvisual/translations/he.json index e32efda96dcb6..6d5684220aacf 100644 --- a/homeassistant/components/airvisual/translations/he.json +++ b/homeassistant/components/airvisual/translations/he.json @@ -1,18 +1,46 @@ { "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, "error": { - "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9 \u05e1\u05d5\u05e4\u05e7" + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "general_error": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "location_not_found": "\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0" }, "step": { - "geography": { + "geography_by_coords": { "data": { "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" } }, + "geography_by_name": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + }, "node_pro": { "data": { + "ip_address": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "description": "\u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d9\u05d7\u05d9\u05d3\u05ea AirVisual \u05d0\u05d9\u05e9\u05d9\u05ea. \u05e0\u05d9\u05ea\u05df \u05dc\u05d0\u05d7\u05d6\u05e8 \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05de\u05de\u05e9\u05e7 \u05d4\u05de\u05e9\u05ea\u05de\u05e9 \u05e9\u05dc \u05d4\u05d9\u05d7\u05d9\u05d3\u05d4." + }, + "reauth_confirm": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u05d4\u05e6\u05d2 \u05d2\u05d9\u05d0\u05d5\u05d2\u05e8\u05e4\u05d9\u05d4 \u05de\u05e0\u05d5\u05d8\u05e8\u05ea \u05d1\u05de\u05e4\u05d4" } } } diff --git a/homeassistant/components/airvisual/translations/hi.json b/homeassistant/components/airvisual/translations/hi.json index bb21909ede010..ee03f27ccc0fd 100644 --- a/homeassistant/components/airvisual/translations/hi.json +++ b/homeassistant/components/airvisual/translations/hi.json @@ -4,14 +4,6 @@ "general_error": "\u0915\u094b\u0908 \u0905\u091c\u094d\u091e\u093e\u0924 \u0924\u094d\u0930\u0941\u091f\u093f \u0925\u0940\u0964" }, "step": { - "geography": { - "data": { - "latitude": "\u0905\u0915\u094d\u0937\u093e\u0902\u0936", - "longitude": "\u0926\u0947\u0936\u093e\u0928\u094d\u0924\u0930" - }, - "description": "\u092d\u094c\u0917\u094b\u0932\u093f\u0915 \u0938\u094d\u0925\u093f\u0924\u093f \u0915\u0940 \u0928\u093f\u0917\u0930\u093e\u0928\u0940 \u0915\u0947 \u0932\u093f\u090f \u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0915\u094d\u0932\u093e\u0909\u0921 \u090f\u092a\u0940\u0906\u0908 \u0915\u093e \u0909\u092a\u092f\u094b\u0917 \u0915\u0930\u0947\u0902\u0964", - "title": "\u092d\u0942\u0917\u094b\u0932 \u0915\u0949\u0928\u094d\u092b\u093c\u093f\u0917\u0930 \u0915\u0930\u0947\u0902" - }, "node_pro": { "data": { "ip_address": "\u0907\u0915\u093e\u0908 \u0915\u0947 \u0906\u0908\u092a\u0940 \u092a\u0924\u0947/\u0939\u094b\u0938\u094d\u091f\u0928\u093e\u092e", @@ -19,13 +11,6 @@ }, "description": "\u090f\u0915 \u0935\u094d\u092f\u0915\u094d\u0924\u093f\u0917\u0924 \u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0907\u0915\u093e\u0908 \u0915\u0940 \u0928\u093f\u0917\u0930\u093e\u0928\u0940 \u0915\u0930\u0947\u0902\u0964 \u092a\u093e\u0938\u0935\u0930\u094d\u0921 \u092f\u0942\u0928\u093f\u091f \u0915\u0947 \u092f\u0942\u0906\u0908 \u0938\u0947 \u092a\u094d\u0930\u093e\u092a\u094d\u0924 \u0915\u093f\u092f\u093e \u091c\u093e \u0938\u0915\u0924\u093e \u0939\u0948\u0964", "title": "\u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0928\u094b\u0921 \u092a\u094d\u0930\u094b" - }, - "user": { - "data": { - "cloud_api": "\u092d\u094c\u0917\u094b\u0932\u093f\u0915 \u0938\u094d\u0925\u093f\u0924\u093f", - "node_pro": "\u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0928\u094b\u0921 \u092a\u094d\u0930\u094b", - "type": "\u090f\u0915\u0940\u0915\u0930\u0923 \u092a\u094d\u0930\u0915\u093e\u0930" - } } } } diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json index 89584cd128b7b..681f32ca3bc1a 100644 --- a/homeassistant/components/airvisual/translations/hu.json +++ b/homeassistant/components/airvisual/translations/hu.json @@ -2,50 +2,61 @@ "config": { "abort": { "already_configured": "A hely m\u00e1r konfigur\u00e1lva van vagy a Node/Pro azonos\u00edt\u00f3 m\u00e1r regisztr\u00e1lva van.", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "general_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs" + "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", + "location_not_found": "A hely nem tal\u00e1lhat\u00f3" }, "step": { - "geography": { - "data": { - "api_key": "API kulcs", - "latitude": "Sz\u00e9less\u00e9g", - "longitude": "Hossz\u00fas\u00e1g" - } - }, "geography_by_coords": { "data": { "api_key": "API kulcs", "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" - } + }, + "description": "Haszn\u00e1lja az AirVisual felh\u0151 API-t a sz\u00e9less\u00e9g / hossz\u00fas\u00e1g figyel\u00e9s\u00e9hez.", + "title": "Konfigur\u00e1lja a geogr\u00e1fi\u00e1t" }, "geography_by_name": { "data": { "api_key": "API kulcs", "city": "V\u00e1ros", - "country": "Orsz\u00e1g" - } + "country": "Orsz\u00e1g", + "state": "\u00e1llapot" + }, + "description": "Haszn\u00e1lja az AirVisual felh\u0151 API-t egy v\u00e1ros / \u00e1llam / orsz\u00e1g figyel\u00e9s\u00e9hez.", + "title": "Konfigur\u00e1lja a geogr\u00e1fi\u00e1t" }, "node_pro": { "data": { - "ip_address": "Hoszt", + "ip_address": "C\u00edm", "password": "Jelsz\u00f3" - } + }, + "description": "Szem\u00e9lyes AirVisual egys\u00e9g figyel\u00e9se. A jelsz\u00f3 lek\u00e9rhet\u0151 a k\u00e9sz\u00fcl\u00e9k felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9r\u0151l.", + "title": "AirVisual Node/Pro konfigur\u00e1l\u00e1sa" }, "reauth_confirm": { "data": { "api_key": "API kulcs" - } + }, + "title": "Az AirVisual \u00fajb\u00f3li hiteles\u00edt\u00e9se" }, "user": { + "description": "V\u00e1lassza ki, hogy milyen t\u00edpus\u00fa AirVisual adatokat szeretne figyelni.", + "title": "Az AirVisual konfigur\u00e1l\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { "data": { - "type": "Integr\u00e1ci\u00f3 t\u00edpusa" - } + "show_on_map": "A megfigyelt f\u00f6ldrajz megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen" + }, + "title": "Az AirVisual konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/airvisual/translations/id.json b/homeassistant/components/airvisual/translations/id.json index 3c689338d9f3e..6fcd6eb54106e 100644 --- a/homeassistant/components/airvisual/translations/id.json +++ b/homeassistant/components/airvisual/translations/id.json @@ -11,15 +11,6 @@ "location_not_found": "Lokasi tidak ditemukan" }, "step": { - "geography": { - "data": { - "api_key": "Kunci API", - "latitude": "Lintang", - "longitude": "Bujur" - }, - "description": "Gunakan API cloud AirVisual untuk memantau lokasi geografis.", - "title": "Konfigurasikan Lokasi Geografi" - }, "geography_by_coords": { "data": { "api_key": "Kunci API", @@ -54,11 +45,6 @@ "title": "Autentikasi Ulang AirVisual" }, "user": { - "data": { - "cloud_api": "Lokasi Geografis", - "node_pro": "AirVisual Node Pro", - "type": "Jenis Integrasi" - }, "description": "Pilih jenis data AirVisual yang ingin dipantau.", "title": "Konfigurasikan AirVisual" } diff --git a/homeassistant/components/airvisual/translations/it.json b/homeassistant/components/airvisual/translations/it.json index 3ce45ff1342d0..34605fdcaf714 100644 --- a/homeassistant/components/airvisual/translations/it.json +++ b/homeassistant/components/airvisual/translations/it.json @@ -11,15 +11,6 @@ "location_not_found": "Posizione non trovata" }, "step": { - "geography": { - "data": { - "api_key": "Chiave API", - "latitude": "Latitudine", - "longitude": "Logitudine" - }, - "description": "Utilizzare l'API di AirVisual cloud per monitorare una posizione geografica.", - "title": "Configurare una Geografia" - }, "geography_by_coords": { "data": { "api_key": "Chiave API", @@ -27,7 +18,7 @@ "longitude": "Logitudine" }, "description": "Usa l'API cloud di AirVisual per monitorare una latitudine/longitudine.", - "title": "Configurare un'area geografica" + "title": "Configura un'area geografica" }, "geography_by_name": { "data": { @@ -37,29 +28,24 @@ "state": "Stato" }, "description": "Usa l'API cloud di AirVisual per monitorare una citt\u00e0/stato/paese.", - "title": "Configurare un'area geografica" + "title": "Configura un'area geografica" }, "node_pro": { "data": { "ip_address": "Host", "password": "Password" }, - "description": "Monitorare un'unit\u00e0 AirVisual personale. La password pu\u00f2 essere recuperata dall'interfaccia utente dell'unit\u00e0.", - "title": "Configurare un AirVisual Node/Pro" + "description": "Monitora un'unit\u00e0 AirVisual personale. La password pu\u00f2 essere recuperata dall'interfaccia utente dell'unit\u00e0.", + "title": "Configura un AirVisual Node/Pro" }, "reauth_confirm": { "data": { "api_key": "Chiave API" }, - "title": "Riautenticare AirVisual" + "title": "Autentica nuovamente AirVisual" }, "user": { - "data": { - "cloud_api": "Posizione geografica", - "node_pro": "AirVisual Node Pro", - "type": "Tipo di integrazione" - }, - "description": "Scegliere il tipo di dati AirVisual che si desidera monitorare.", + "description": "Scegli il tipo di dati AirVisual che desideri monitorare.", "title": "Configura AirVisual" } } @@ -70,7 +56,7 @@ "data": { "show_on_map": "Mostra l'area geografica monitorata sulla mappa" }, - "title": "Configurare AirVisual" + "title": "Configura AirVisual" } } } diff --git a/homeassistant/components/airvisual/translations/ja.json b/homeassistant/components/airvisual/translations/ja.json new file mode 100644 index 0000000000000..eafcdc7378da6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/ja.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u307e\u305f\u306f\u3001Node/Pro ID\u306f\u65e2\u306b\u767b\u9332\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "general_error": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "location_not_found": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "geography_by_coords": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + }, + "description": "AirVisual cloud API\u3092\u4f7f\u7528\u3057\u3066\u3001\u7def\u5ea6/\u7d4c\u5ea6\u3092\u76e3\u8996\u3057\u307e\u3059\u3002", + "title": "Geography\u306e\u8a2d\u5b9a" + }, + "geography_by_name": { + "data": { + "api_key": "API\u30ad\u30fc", + "city": "\u90fd\u5e02", + "country": "\u56fd", + "state": "\u5dde" + }, + "description": "AirVisual cloud API\u3092\u4f7f\u7528\u3057\u3066\u3001\u90fd\u5e02/\u5dde/\u56fd\u3092\u76e3\u8996\u3057\u307e\u3059\u3002", + "title": "Geography\u306e\u8a2d\u5b9a" + }, + "node_pro": { + "data": { + "ip_address": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u500b\u4eba\u306eAirVisual\u30e6\u30cb\u30c3\u30c8\u3092\u76e3\u8996\u3057\u307e\u3059\u3002\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u3001\u672c\u4f53\u306eUI\u304b\u3089\u53d6\u5f97\u3067\u304d\u307e\u3059\u3002", + "title": "AirVisual Node/Pro\u306e\u8a2d\u5b9a" + }, + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "title": "AirVisual\u3092\u518d\u8a8d\u8a3c" + }, + "user": { + "description": "\u76e3\u8996\u3057\u305f\u3044\u3001AirVisual\u306e\u30c7\u30fc\u30bf\u306e\u7a2e\u985e\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "AirVisual\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u76e3\u8996\u5bfe\u8c61\u306e\u5730\u7406\u3092\u5730\u56f3\u306b\u8868\u793a" + }, + "title": "AirVisual\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/ko.json b/homeassistant/components/airvisual/translations/ko.json index 3ab3dbcf2867d..ddee51dcb3e5b 100644 --- a/homeassistant/components/airvisual/translations/ko.json +++ b/homeassistant/components/airvisual/translations/ko.json @@ -11,15 +11,6 @@ "location_not_found": "\uc704\uce58\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." }, "step": { - "geography": { - "data": { - "api_key": "API \ud0a4", - "latitude": "\uc704\ub3c4", - "longitude": "\uacbd\ub3c4" - }, - "description": "AirVisual \ud074\ub77c\uc6b0\ub4dc API \ub97c \uc0ac\uc6a9\ud558\uc5ec \uc9c0\ub9ac\uc801 \uc704\uce58\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4.", - "title": "\uc9c0\ub9ac\uc801 \uc704\uce58 \uad6c\uc131\ud558\uae30" - }, "geography_by_coords": { "data": { "api_key": "API \ud0a4", @@ -54,11 +45,6 @@ "title": "AirVisual \uc7ac\uc778\uc99d\ud558\uae30" }, "user": { - "data": { - "cloud_api": "\uc9c0\ub9ac\uc801 \uc704\uce58", - "node_pro": "AirVisual Node Pro", - "type": "\uc5f0\ub3d9 \uc720\ud615" - }, "description": "\ubaa8\ub2c8\ud130\ub9c1\ud560 AirVisual \ub370\uc774\ud130 \uc720\ud615\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", "title": "AirVisual \uad6c\uc131\ud558\uae30" } diff --git a/homeassistant/components/airvisual/translations/lb.json b/homeassistant/components/airvisual/translations/lb.json index 5a4fb2c07f222..12906b45277ed 100644 --- a/homeassistant/components/airvisual/translations/lb.json +++ b/homeassistant/components/airvisual/translations/lb.json @@ -11,15 +11,6 @@ "location_not_found": "Standuert net fonnt." }, "step": { - "geography": { - "data": { - "api_key": "API Schl\u00ebssel", - "latitude": "Breedegrad", - "longitude": "L\u00e4ngegrad" - }, - "description": "Benotz Airvisual cloud API fir eng geografescher Lag z'iwwerwaachen.", - "title": "Geografie ariichten" - }, "geography_by_name": { "data": { "city": "Stad", @@ -42,11 +33,6 @@ "title": "AirVisual re-authentifiz\u00e9ieren" }, "user": { - "data": { - "cloud_api": "Geografesche Standuert", - "node_pro": "Airvisual Node Pro", - "type": "Typ vun der Integratioun" - }, "description": "Typ vun Airvisual Donn\u00e9\u00eb fir d'Iwwerwachung auswielen.", "title": "AirVisual konfigur\u00e9ieren" } diff --git a/homeassistant/components/airvisual/translations/nl.json b/homeassistant/components/airvisual/translations/nl.json index ed81d6568edff..ddbcc6e6009e9 100644 --- a/homeassistant/components/airvisual/translations/nl.json +++ b/homeassistant/components/airvisual/translations/nl.json @@ -11,15 +11,6 @@ "location_not_found": "Locatie niet gevonden" }, "step": { - "geography": { - "data": { - "api_key": "API-sleutel", - "latitude": "Breedtegraad", - "longitude": "Lengtegraad" - }, - "description": "Gebruik de AirVisual cloud API om een geografische locatie te bewaken.", - "title": "Configureer een geografie" - }, "geography_by_coords": { "data": { "api_key": "API-sleutel", @@ -54,11 +45,6 @@ "title": "Verifieer AirVisual opnieuw" }, "user": { - "data": { - "cloud_api": "Geografische ligging", - "node_pro": "AirVisual Node Pro", - "type": "Integratietype" - }, "description": "Kies welk type AirVisual-gegevens u wilt bewaken.", "title": "Configureer AirVisual" } diff --git a/homeassistant/components/airvisual/translations/no.json b/homeassistant/components/airvisual/translations/no.json index 7c5b033365238..d4ca80d480505 100644 --- a/homeassistant/components/airvisual/translations/no.json +++ b/homeassistant/components/airvisual/translations/no.json @@ -11,15 +11,6 @@ "location_not_found": "Stedet ble ikke funnet" }, "step": { - "geography": { - "data": { - "api_key": "API-n\u00f8kkel", - "latitude": "Breddegrad", - "longitude": "Lengdegrad" - }, - "description": "Bruk AirVisual cloud API til \u00e5 overv\u00e5ke en geografisk plassering.", - "title": "Konfigurer en Geography" - }, "geography_by_coords": { "data": { "api_key": "API-n\u00f8kkel", @@ -54,11 +45,6 @@ "title": "Godkjenne integrering p\u00e5 nytt" }, "user": { - "data": { - "cloud_api": "Geografisk plassering", - "node_pro": "", - "type": "Integrasjonstype" - }, "description": "Velg hvilken type AirVisual-data du vil overv\u00e5ke.", "title": "Konfigurer AirVisual" } diff --git a/homeassistant/components/airvisual/translations/pl.json b/homeassistant/components/airvisual/translations/pl.json index 5590a95164117..26883f514cd0a 100644 --- a/homeassistant/components/airvisual/translations/pl.json +++ b/homeassistant/components/airvisual/translations/pl.json @@ -11,15 +11,6 @@ "location_not_found": "Nie znaleziono lokalizacji" }, "step": { - "geography": { - "data": { - "api_key": "Klucz API", - "latitude": "Szeroko\u015b\u0107 geograficzna", - "longitude": "D\u0142ugo\u015b\u0107 geograficzna" - }, - "description": "U\u017cyj interfejsu API chmury AirVisual do monitorowania lokalizacji geograficznej.", - "title": "Konfiguracja Geography" - }, "geography_by_coords": { "data": { "api_key": "Klucz API", @@ -54,11 +45,6 @@ "title": "Ponownie uwierzytelnij integracj\u0119" }, "user": { - "data": { - "cloud_api": "Lokalizacja geograficzna", - "node_pro": "AirVisual Node Pro", - "type": "Typ integracji" - }, "description": "Wybierz, kt\u00f3re dane AirVisual chcesz monitorowa\u0107.", "title": "Konfiguracja AirVisual" } diff --git a/homeassistant/components/airvisual/translations/pt-BR.json b/homeassistant/components/airvisual/translations/pt-BR.json index 9f78c46b5e026..733411f2465f9 100644 --- a/homeassistant/components/airvisual/translations/pt-BR.json +++ b/homeassistant/components/airvisual/translations/pt-BR.json @@ -5,21 +5,10 @@ "invalid_api_key": "Chave de API fornecida \u00e9 inv\u00e1lida." }, "step": { - "geography": { - "data": { - "latitude": "Latitude", - "longitude": "Longitude" - } - }, "node_pro": { "data": { "password": "Senha" } - }, - "user": { - "data": { - "type": "Tipo de Integra\u00e7\u00e3o" - } } } } diff --git a/homeassistant/components/airvisual/translations/pt.json b/homeassistant/components/airvisual/translations/pt.json index d6732cdddcfae..cc1c500946d2a 100644 --- a/homeassistant/components/airvisual/translations/pt.json +++ b/homeassistant/components/airvisual/translations/pt.json @@ -10,13 +10,6 @@ "invalid_api_key": "Chave de API inv\u00e1lida" }, "step": { - "geography": { - "data": { - "api_key": "API Key", - "latitude": "Latitude", - "longitude": "Longitude" - } - }, "node_pro": { "data": { "ip_address": "Servidor", diff --git a/homeassistant/components/airvisual/translations/ru.json b/homeassistant/components/airvisual/translations/ru.json index bc648c84bfe72..f774ec76aaf66 100644 --- a/homeassistant/components/airvisual/translations/ru.json +++ b/homeassistant/components/airvisual/translations/ru.json @@ -11,22 +11,13 @@ "location_not_found": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e." }, "step": { - "geography": { - "data": { - "api_key": "\u041a\u043b\u044e\u0447 API", - "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", - "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" - }, - "description": "\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e API AirVisual.", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" - }, "geography_by_coords": { "data": { "api_key": "\u041a\u043b\u044e\u0447 API", "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" }, - "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0448\u0438\u0440\u043e\u0442\u044b/\u0434\u043e\u043b\u0433\u043e\u0442\u044b.", + "description": "\u0414\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u043f\u043e \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0430\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, "geography_by_name": { @@ -34,9 +25,9 @@ "api_key": "\u041a\u043b\u044e\u0447 API", "city": "\u0413\u043e\u0440\u043e\u0434", "country": "\u0421\u0442\u0440\u0430\u043d\u0430", - "state": "\u0448\u0442\u0430\u0442" + "state": "\u0428\u0442\u0430\u0442" }, - "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b.", + "description": "\u0414\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, "node_pro": { @@ -54,11 +45,6 @@ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b\u044f" }, "user": { - "data": { - "cloud_api": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", - "node_pro": "AirVisual Node Pro", - "type": "\u0422\u0438\u043f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438" - }, "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u0434\u0430\u043d\u043d\u044b\u0445 AirVisual, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c.", "title": "AirVisual" } diff --git a/homeassistant/components/airvisual/translations/sensor.bg.json b/homeassistant/components/airvisual/translations/sensor.bg.json new file mode 100644 index 0000000000000..428050a2427df --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.bg.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u0412\u044a\u0433\u043b\u0435\u0440\u043e\u0434\u0435\u043d \u043e\u043a\u0438\u0441", + "n2": "\u0410\u0437\u043e\u0442\u0435\u043d \u0434\u0438\u043e\u043a\u0441\u0438\u0434", + "o3": "\u041e\u0437\u043e\u043d", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u0421\u0435\u0440\u0435\u043d \u0434\u0438\u043e\u043a\u0441\u0438\u0434" + }, + "airvisual__pollutant_level": { + "good": "\u0414\u043e\u0431\u0440\u043e", + "hazardous": "\u041e\u043f\u0430\u0441\u043d\u043e", + "moderate": "\u0423\u043c\u0435\u0440\u0435\u043d\u043e", + "unhealthy": "\u041d\u0435\u0437\u0434\u0440\u0430\u0432\u043e\u0441\u043b\u043e\u0432\u043d\u043e", + "unhealthy_sensitive": "\u041d\u0435\u0437\u0434\u0440\u0430\u0432\u043e\u0441\u043b\u043e\u0432\u043d\u043e \u0437\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u0438 \u0433\u0440\u0443\u043f\u0438", + "very_unhealthy": "\u041c\u043d\u043e\u0433\u043e \u043d\u0435\u0437\u0434\u0440\u0430\u0432\u043e\u0441\u043b\u043e\u0432\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.ca.json b/homeassistant/components/airvisual/translations/sensor.ca.json new file mode 100644 index 0000000000000..236dca64d4e1e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ca.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f2xid de carboni", + "n2": "Di\u00f2xid de nitrogen", + "o3": "Oz\u00f3", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f2xid de sofre" + }, + "airvisual__pollutant_level": { + "good": "Bo", + "hazardous": "Perill\u00f3s", + "moderate": "Moderat", + "unhealthy": "Poc saludable", + "unhealthy_sensitive": "Poc saludable per a grups sensibles", + "very_unhealthy": "Molt poc saludable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.cs.json b/homeassistant/components/airvisual/translations/sensor.cs.json new file mode 100644 index 0000000000000..44c834c7df65c --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.cs.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Oxid uhelnat\u00fd", + "n2": "Oxid dusi\u010dit\u00fd", + "o3": "Oz\u00f3n", + "p1": "PM10", + "p2": "PM2,5", + "s2": "Oxid si\u0159i\u010dit\u00fd" + }, + "airvisual__pollutant_level": { + "good": "Dobr\u00e9", + "hazardous": "Riskantn\u00ed", + "moderate": "M\u00edrn\u00e9", + "unhealthy": "Nezdrav\u00e9", + "unhealthy_sensitive": "Nezdrav\u00e9 pro citliv\u00e9 skupiny", + "very_unhealthy": "Velmi nezdrav\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.de.json b/homeassistant/components/airvisual/translations/sensor.de.json new file mode 100644 index 0000000000000..d6aeab515bd12 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.de.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Kohlenmonoxid", + "n2": "Stickstoffdioxid", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2,5", + "s2": "Schwefeldioxid" + }, + "airvisual__pollutant_level": { + "good": "Gut", + "hazardous": "Gef\u00e4hrlich", + "moderate": "M\u00e4\u00dfig", + "unhealthy": "Ungesund", + "unhealthy_sensitive": "Ungesund f\u00fcr sensible Gruppen", + "very_unhealthy": "Sehr ungesund" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.en.json b/homeassistant/components/airvisual/translations/sensor.en.json new file mode 100644 index 0000000000000..314cf34562a42 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.en.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Carbon Monoxide", + "n2": "Nitrogen Dioxide", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Sulfur Dioxide" + }, + "airvisual__pollutant_level": { + "good": "Good", + "hazardous": "Hazardous", + "moderate": "Moderate", + "unhealthy": "Unhealthy", + "unhealthy_sensitive": "Unhealthy for sensitive groups", + "very_unhealthy": "Very unhealthy" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.es-419.json b/homeassistant/components/airvisual/translations/sensor.es-419.json new file mode 100644 index 0000000000000..7af0e1465aa2f --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.es-419.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f3xido de carbono", + "n2": "Dioxido de nitrogeno", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f3xido de azufre" + }, + "airvisual__pollutant_level": { + "good": "Bueno", + "hazardous": "Peligroso", + "moderate": "Moderado", + "unhealthy": "Insalubre", + "unhealthy_sensitive": "Insalubre para grupos sensibles", + "very_unhealthy": "Muy insalubre" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.es.json b/homeassistant/components/airvisual/translations/sensor.es.json new file mode 100644 index 0000000000000..113c17246ed0e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.es.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f3xido de carbono", + "n2": "Di\u00f3xido de nitr\u00f3geno", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f3xido de azufre" + }, + "airvisual__pollutant_level": { + "good": "Bueno", + "hazardous": "Da\u00f1ino", + "moderate": "Moderado", + "unhealthy": "Insalubre", + "unhealthy_sensitive": "Insalubre para grupos sensibles", + "very_unhealthy": "Muy poco saludable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.et.json b/homeassistant/components/airvisual/translations/sensor.et.json new file mode 100644 index 0000000000000..14f3d82c11d15 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.et.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Vingugaas", + "n2": "L\u00e4mmastikdioksiid", + "o3": "Osoon", + "p1": "PM10 osakesed", + "p2": "PM2.5 osakesed", + "s2": "V\u00e4\u00e4veldioksiid" + }, + "airvisual__pollutant_level": { + "good": "Hea", + "hazardous": "Ohtlik", + "moderate": "M\u00f5\u00f5dukas", + "unhealthy": "Ebatervislik", + "unhealthy_sensitive": "Ebatervislik riskir\u00fchmale", + "very_unhealthy": "V\u00e4ga ebatervislik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.fr.json b/homeassistant/components/airvisual/translations/sensor.fr.json new file mode 100644 index 0000000000000..3050d6fb158ae --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.fr.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Monoxyde de carbone", + "n2": "Dioxyde d'azote", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Dioxyde de soufre" + }, + "airvisual__pollutant_level": { + "good": "Bon", + "hazardous": "Hasardeux", + "moderate": "Mod\u00e9rer", + "unhealthy": "Malsain", + "unhealthy_sensitive": "Malsain pour les groupes sensibles", + "very_unhealthy": "Tr\u00e8s malsain" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.he.json b/homeassistant/components/airvisual/translations/sensor.he.json new file mode 100644 index 0000000000000..28ac8c5c3e429 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.he.json @@ -0,0 +1,8 @@ +{ + "state": { + "airvisual__pollutant_level": { + "good": "\u05d8\u05d5\u05d1", + "unhealthy": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.hu.json b/homeassistant/components/airvisual/translations/sensor.hu.json new file mode 100644 index 0000000000000..93fbb2ce5104e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.hu.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Sz\u00e9n-monoxid", + "n2": "Nitrog\u00e9n-dioxid", + "o3": "\u00d3zon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "K\u00e9n-dioxid" + }, + "airvisual__pollutant_level": { + "good": "J\u00f3", + "hazardous": "Vesz\u00e9lyes", + "moderate": "M\u00e9rs\u00e9kelt", + "unhealthy": "Eg\u00e9szs\u00e9gtelen", + "unhealthy_sensitive": "Eg\u00e9szs\u00e9gtelen az \u00e9rz\u00e9keny csoportok sz\u00e1m\u00e1ra", + "very_unhealthy": "Nagyon eg\u00e9szs\u00e9gtelen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.id.json b/homeassistant/components/airvisual/translations/sensor.id.json new file mode 100644 index 0000000000000..ad6c9c64b3da3 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.id.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Karbon monoksida", + "n2": "Nitrogen dioksida", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Sulfur Dioksida" + }, + "airvisual__pollutant_level": { + "good": "Bagus", + "hazardous": "Berbahaya", + "moderate": "Sedang", + "unhealthy": "Tidak sehat", + "unhealthy_sensitive": "Tidak sehat untuk kelompok sensitif", + "very_unhealthy": "Sangat tidak sehat" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.it.json b/homeassistant/components/airvisual/translations/sensor.it.json new file mode 100644 index 0000000000000..7fb8b98215c17 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.it.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Monossido di carbonio", + "n2": "Anidride nitrosa", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Anidride solforosa" + }, + "airvisual__pollutant_level": { + "good": "Buono", + "hazardous": "Pericoloso", + "moderate": "Moderato", + "unhealthy": "Malsano", + "unhealthy_sensitive": "Malsano per gruppi sensibili", + "very_unhealthy": "Molto malsano" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.ja.json b/homeassistant/components/airvisual/translations/sensor.ja.json new file mode 100644 index 0000000000000..91bd016b0ac9c --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ja.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u4e00\u9178\u5316\u70ad\u7d20", + "n2": "\u4e8c\u9178\u5316\u7a92\u7d20", + "o3": "\u30aa\u30be\u30f3", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u4e8c\u9178\u5316\u786b\u9ec4" + }, + "airvisual__pollutant_level": { + "good": "\u826f\u597d", + "hazardous": "\u5371\u967a", + "moderate": "\u9069\u5ea6", + "unhealthy": "\u4e0d\u5065\u5eb7", + "unhealthy_sensitive": "\u654f\u611f\u306a\u30b0\u30eb\u30fc\u30d7\u306b\u3068\u3063\u3066\u306f\u4e0d\u5065\u5eb7", + "very_unhealthy": "\u3068\u3066\u3082\u4e0d\u5065\u5eb7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.nl.json b/homeassistant/components/airvisual/translations/sensor.nl.json new file mode 100644 index 0000000000000..72f07853e49a6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.nl.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Koolmonoxide", + "n2": "Stikstofdioxide", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Zwaveldioxide" + }, + "airvisual__pollutant_level": { + "good": "Goed", + "hazardous": "Gevaarlijk", + "moderate": "Matig", + "unhealthy": "Ongezond", + "unhealthy_sensitive": "Ongezond voor gevoelige groepen", + "very_unhealthy": "Heel ongezond" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.no.json b/homeassistant/components/airvisual/translations/sensor.no.json new file mode 100644 index 0000000000000..cf142ad9f1a4a --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.no.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Karbonmonoksid", + "n2": "Nitrogendioksid", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Svoveldioksid" + }, + "airvisual__pollutant_level": { + "good": "Bra", + "hazardous": "Farlig", + "moderate": "Moderat", + "unhealthy": "Usunt", + "unhealthy_sensitive": "Usunt for sensitive grupper", + "very_unhealthy": "Veldig usunt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.pl.json b/homeassistant/components/airvisual/translations/sensor.pl.json new file mode 100644 index 0000000000000..3ac9e2c2c2863 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.pl.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "tlenek w\u0119gla", + "n2": "dwutlenek azotu", + "o3": "ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "dwutlenek siarki" + }, + "airvisual__pollutant_level": { + "good": "dobry", + "hazardous": "niebezpieczny", + "moderate": "umiarkowany", + "unhealthy": "niezdrowy", + "unhealthy_sensitive": "niezdrowy dla grup wra\u017cliwych", + "very_unhealthy": "bardzo niezdrowy" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.ru.json b/homeassistant/components/airvisual/translations/sensor.ru.json new file mode 100644 index 0000000000000..d75bcc4ee9e13 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ru.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u0423\u0433\u0430\u0440\u043d\u044b\u0439 \u0433\u0430\u0437", + "n2": "\u0414\u0438\u043e\u043a\u0441\u0438\u0434 \u0430\u0437\u043e\u0442\u0430", + "o3": "\u041e\u0437\u043e\u043d", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u0414\u0438\u043e\u043a\u0441\u0438\u0434 \u0441\u0435\u0440\u044b" + }, + "airvisual__pollutant_level": { + "good": "\u0425\u043e\u0440\u043e\u0448\u043e", + "hazardous": "\u041e\u043f\u0430\u0441\u043d\u043e", + "moderate": "\u0421\u0440\u0435\u0434\u043d\u0435", + "unhealthy": "\u0412\u0440\u0435\u0434\u043d\u043e", + "unhealthy_sensitive": "\u0412\u0440\u0435\u0434\u043d\u043e \u0434\u043b\u044f \u0443\u044f\u0437\u0432\u0438\u043c\u044b\u0445 \u0433\u0440\u0443\u043f\u043f", + "very_unhealthy": "\u041e\u0447\u0435\u043d\u044c \u0432\u0440\u0435\u0434\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.tr.json b/homeassistant/components/airvisual/translations/sensor.tr.json new file mode 100644 index 0000000000000..47b8e7402b021 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.tr.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Karbonmonoksit", + "n2": "Nitrojen dioksit", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "K\u00fck\u00fcrt dioksit" + }, + "airvisual__pollutant_level": { + "good": "\u0130yi", + "hazardous": "Tehlikeli", + "moderate": "Il\u0131ml\u0131", + "unhealthy": "Sa\u011fl\u0131ks\u0131z", + "unhealthy_sensitive": "Hassas gruplar i\u00e7in sa\u011fl\u0131ks\u0131z", + "very_unhealthy": "\u00c7ok sa\u011fl\u0131ks\u0131z" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.zh-Hans.json b/homeassistant/components/airvisual/translations/sensor.zh-Hans.json new file mode 100644 index 0000000000000..8c56f25246ef1 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.zh-Hans.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u4e00\u6c27\u5316\u78b3", + "n2": "\u4e8c\u6c27\u5316\u6c2e", + "o3": "\u81ed\u6c27", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u4e8c\u6c27\u5316\u786b" + }, + "airvisual__pollutant_level": { + "good": "\u826f\u597d", + "hazardous": "\u5371\u5bb3\u5065\u5eb7", + "moderate": "\u4e2d\u7b49", + "unhealthy": "\u4e0d\u5229\u4e8e\u5065\u5eb7", + "unhealthy_sensitive": "\u4e0d\u5229\u4e8e\u654f\u611f\u4eba\u7fa4", + "very_unhealthy": "\u975e\u5e38\u4e0d\u5229\u4e8e\u5065\u5eb7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.zh-Hant.json b/homeassistant/components/airvisual/translations/sensor.zh-Hant.json new file mode 100644 index 0000000000000..cedd3e33ae638 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.zh-Hant.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u4e00\u6c27\u5316\u78b3", + "n2": "\u4e8c\u6c27\u5316\u6c2e", + "o3": "\u81ed\u6c27", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u4e8c\u6c27\u5316\u786b" + }, + "airvisual__pollutant_level": { + "good": "\u826f\u597d", + "hazardous": "\u5371\u96aa", + "moderate": "\u4e2d\u7b49", + "unhealthy": "\u4e0d\u5065\u5eb7", + "unhealthy_sensitive": "\u5c0d\u654f\u611f\u65cf\u7fa4\u4e0d\u5065\u5eb7", + "very_unhealthy": "\u975e\u5e38\u4e0d\u5065\u5eb7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sl.json b/homeassistant/components/airvisual/translations/sl.json index 201b696a8cef8..fc611a1589e16 100644 --- a/homeassistant/components/airvisual/translations/sl.json +++ b/homeassistant/components/airvisual/translations/sl.json @@ -8,15 +8,6 @@ "invalid_api_key": "Vpisan neveljaven API klju\u010d" }, "step": { - "geography": { - "data": { - "api_key": "Klju\u010d API", - "latitude": "Zemljepisna \u0161irina", - "longitude": "Zemljepisna dol\u017eina" - }, - "description": "Uporabite API oblaka AirVisual za spremljanje geografske lokacije.", - "title": "Konfigurirajte lokacijo" - }, "node_pro": { "data": { "ip_address": "IP naslov/ime gostitelja enote", @@ -26,11 +17,6 @@ "title": "Konfigurirajte AirVisual Node/Pro" }, "user": { - "data": { - "cloud_api": "Geografska lokacija", - "node_pro": "AirVisual Node Pro", - "type": "Vrsta integracije" - }, "description": "Spremljajte kakovost zraka na zemljepisni lokaciji.", "title": "Nastavite AirVisual" } diff --git a/homeassistant/components/airvisual/translations/sv.json b/homeassistant/components/airvisual/translations/sv.json index 9faebc9e9608c..6a33c0393d9d7 100644 --- a/homeassistant/components/airvisual/translations/sv.json +++ b/homeassistant/components/airvisual/translations/sv.json @@ -12,10 +12,6 @@ } }, "user": { - "data": { - "cloud_api": "Geografisk Plats", - "type": "Integrationstyp" - }, "title": "Konfigurera AirVisual" } } diff --git a/homeassistant/components/airvisual/translations/tr.json b/homeassistant/components/airvisual/translations/tr.json index 3d20c8ea9fc83..bcfe6825372b1 100644 --- a/homeassistant/components/airvisual/translations/tr.json +++ b/homeassistant/components/airvisual/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f veya Node/Pro Kimli\u011fi zaten kay\u0131tl\u0131.", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { @@ -10,13 +11,6 @@ "location_not_found": "Konum bulunamad\u0131" }, "step": { - "geography": { - "data": { - "api_key": "API Anahtar\u0131", - "latitude": "Enlem", - "longitude": "Boylam" - } - }, "geography_by_coords": { "data": { "api_key": "API Anahtar\u0131", @@ -33,25 +27,35 @@ "country": "\u00dclke", "state": "durum" }, + "description": "Bir \u015fehri/eyalet/\u00fclkeyi izlemek i\u00e7in AirVisual bulut API'sini kullan\u0131n.", "title": "Bir Co\u011frafyay\u0131 Yap\u0131land\u0131rma" }, "node_pro": { "data": { - "ip_address": "Ana Bilgisayar", + "ip_address": "Sunucu", "password": "Parola" }, - "description": "Ki\u015fisel bir AirVisual \u00fcnitesini izleyin. Parola, \u00fcnitenin kullan\u0131c\u0131 aray\u00fcz\u00fcnden al\u0131nabilir." + "description": "Ki\u015fisel bir AirVisual \u00fcnitesini izleyin. Parola, \u00fcnitenin kullan\u0131c\u0131 aray\u00fcz\u00fcnden al\u0131nabilir.", + "title": "Bir AirVisual Node/Pro'yu yap\u0131land\u0131r\u0131n" }, "reauth_confirm": { "data": { "api_key": "API Anahtar\u0131" - } + }, + "title": "AirVisual'\u0131 yeniden do\u011frulay\u0131n" + }, + "user": { + "description": "Ne t\u00fcr AirVisual verilerini izlemek istedi\u011finizi se\u00e7in.", + "title": "AirVisual'\u0131 yap\u0131land\u0131r\u0131n" } } }, "options": { "step": { "init": { + "data": { + "show_on_map": "\u0130zlenen co\u011frafyay\u0131 haritada g\u00f6ster" + }, "title": "AirVisual'\u0131 yap\u0131land\u0131r\u0131n" } } diff --git a/homeassistant/components/airvisual/translations/uk.json b/homeassistant/components/airvisual/translations/uk.json index d99c58de7c07a..4a4ea6c8b90d3 100644 --- a/homeassistant/components/airvisual/translations/uk.json +++ b/homeassistant/components/airvisual/translations/uk.json @@ -10,15 +10,6 @@ "invalid_api_key": "\u0425\u0438\u0431\u043d\u0438\u0439 \u043a\u043b\u044e\u0447 API" }, "step": { - "geography": { - "data": { - "api_key": "\u041a\u043b\u044e\u0447 API", - "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", - "longitude": "\u0414\u043e\u0432\u0433\u043e\u0442\u0430" - }, - "description": "\u041c\u043e\u043d\u0456\u0442\u043e\u0440\u0438\u043d\u0433 \u043c\u0456\u0441\u0446\u0435\u0437\u043d\u0430\u0445\u043e\u0434\u0436\u0435\u043d\u043d\u044f \u0437\u0430 \u0434\u043e\u043f\u043e\u043c\u043e\u0433\u043e\u044e \u0445\u043c\u0430\u0440\u043d\u043e\u0433\u043e API AirVisual.", - "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u043c\u0456\u0441\u0446\u0435\u0437\u043d\u0430\u0445\u043e\u0434\u0436\u0435\u043d\u043d\u044f" - }, "node_pro": { "data": { "ip_address": "\u0425\u043e\u0441\u0442", @@ -34,11 +25,6 @@ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f \u043f\u0440\u043e\u0444\u0456\u043b\u044e" }, "user": { - "data": { - "cloud_api": "\u041c\u0456\u0441\u0446\u0435\u0437\u043d\u0430\u0445\u043e\u0434\u0436\u0435\u043d\u043d\u044f", - "node_pro": "AirVisual Node Pro", - "type": "\u0422\u0438\u043f \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457" - }, "description": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u0442\u0438\u043f \u0434\u0430\u043d\u0438\u0445 AirVisual, \u044f\u043a\u0438\u0439 \u0412\u0438 \u0445\u043e\u0447\u0435\u0442\u0435 \u0432\u0456\u0434\u0441\u0442\u0435\u0436\u0443\u0432\u0430\u0442\u0438.", "title": "AirVisual" } diff --git a/homeassistant/components/airvisual/translations/vi.json b/homeassistant/components/airvisual/translations/vi.json deleted file mode 100644 index 6246d8997da46..0000000000000 --- a/homeassistant/components/airvisual/translations/vi.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "type": "Lo\u1ea1i t\u00edch h\u1ee3p" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/zh-Hant.json b/homeassistant/components/airvisual/translations/zh-Hant.json index 3767d41b519e1..fed34b3346bec 100644 --- a/homeassistant/components/airvisual/translations/zh-Hant.json +++ b/homeassistant/components/airvisual/translations/zh-Hant.json @@ -7,22 +7,13 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", "general_error": "\u672a\u9810\u671f\u932f\u8aa4", - "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "invalid_api_key": "API \u91d1\u9470\u7121\u6548", "location_not_found": "\u627e\u4e0d\u5230\u5730\u9ede" }, "step": { - "geography": { - "data": { - "api_key": "API \u5bc6\u9470", - "latitude": "\u7def\u5ea6", - "longitude": "\u7d93\u5ea6" - }, - "description": "\u4f7f\u7528 AirVisual \u96f2\u7aef API \u4ee5\u76e3\u63a7\u5730\u7406\u5ea7\u6a19\u3002", - "title": "\u8a2d\u5b9a\u5730\u7406\u5ea7\u6a19" - }, "geography_by_coords": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6" }, @@ -31,7 +22,7 @@ }, "geography_by_name": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "city": "\u57ce\u5e02", "country": "\u570b\u5bb6", "state": "\u5dde" @@ -49,16 +40,11 @@ }, "reauth_confirm": { "data": { - "api_key": "API \u5bc6\u9470" + "api_key": "API \u91d1\u9470" }, "title": "\u91cd\u65b0\u8a8d\u8b49 AirVisual" }, "user": { - "data": { - "cloud_api": "\u5730\u7406\u5ea7\u6a19", - "node_pro": "AirVisual Node Pro", - "type": "\u6574\u5408\u985e\u578b" - }, "description": "\u9078\u64c7\u6240\u8981\u76e3\u63a7\u7684 AirVisual \u8cc7\u6599\u985e\u578b\u3002", "title": "\u8a2d\u5b9a AirVisual" } diff --git a/homeassistant/components/aladdin_connect/const.py b/homeassistant/components/aladdin_connect/const.py new file mode 100644 index 0000000000000..7bfea738cef12 --- /dev/null +++ b/homeassistant/components/aladdin_connect/const.py @@ -0,0 +1,19 @@ +"""Platform for the Aladdin Connect cover component.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.cover import SUPPORT_CLOSE, SUPPORT_OPEN +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING + +NOTIFICATION_ID: Final = "aladdin_notification" +NOTIFICATION_TITLE: Final = "Aladdin Connect Cover Setup" + +STATES_MAP: Final[dict[str, str]] = { + "open": STATE_OPEN, + "opening": STATE_OPENING, + "closed": STATE_CLOSED, + "closing": STATE_CLOSING, +} + +SUPPORTED_FEATURES: Final = SUPPORT_OPEN | SUPPORT_CLOSE diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 8b61d29b78a93..f05b27eea8d18 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,13 +1,15 @@ """Platform for the Aladdin Connect cover component.""" +from __future__ import annotations + import logging +from typing import Any, Final from aladdin_connect import AladdinConnectClient import voluptuous as vol from homeassistant.components.cover import ( - PLATFORM_SCHEMA, - SUPPORT_CLOSE, - SUPPORT_OPEN, + PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, + CoverDeviceClass, CoverEntity, ) from homeassistant.const import ( @@ -15,41 +17,42 @@ CONF_USERNAME, STATE_CLOSED, STATE_CLOSING, - STATE_OPEN, STATE_OPENING, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -NOTIFICATION_ID = "aladdin_notification" -NOTIFICATION_TITLE = "Aladdin Connect Cover Setup" +from .const import NOTIFICATION_ID, NOTIFICATION_TITLE, STATES_MAP, SUPPORTED_FEATURES +from .model import DoorDevice -STATES_MAP = { - "open": STATE_OPEN, - "opening": STATE_OPENING, - "closed": STATE_CLOSED, - "closing": STATE_CLOSING, -} +_LOGGER: Final = logging.getLogger(__name__) -SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Aladdin Connect platform.""" - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] + username: str = config[CONF_USERNAME] + password: str = config[CONF_PASSWORD] acc = AladdinConnectClient(username, password) try: if not acc.login(): raise ValueError("Username or Password is incorrect") - add_entities(AladdinDevice(acc, door) for door in acc.get_doors()) + add_entities( + (AladdinDevice(acc, door) for door in acc.get_doors()), + update_before_add=True, + ) except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( @@ -62,60 +65,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AladdinDevice(CoverEntity): """Representation of Aladdin Connect cover.""" - def __init__(self, acc, device): + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features = SUPPORTED_FEATURES + + def __init__(self, acc: AladdinConnectClient, device: DoorDevice) -> None: """Initialize the cover.""" self._acc = acc self._device_id = device["device_id"] self._number = device["door_number"] - self._name = device["name"] - self._status = STATES_MAP.get(device["status"]) - - @property - def device_class(self): - """Define this cover as a garage door.""" - return "garage" - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_FEATURES - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._device_id}-{self._number}" - - @property - def name(self): - """Return the name of the garage door.""" - return self._name - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._status == STATE_OPENING - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._status == STATE_CLOSING - - @property - def is_closed(self): - """Return None if status is unknown, True if closed, else False.""" - if self._status is None: - return None - return self._status == STATE_CLOSED - - def close_cover(self, **kwargs): + self._attr_name = device["name"] + self._attr_unique_id = f"{self._device_id}-{self._number}" + + def close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" self._acc.close_door(self._device_id, self._number) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" self._acc.open_door(self._device_id, self._number) - def update(self): + def update(self) -> None: """Update status of cover.""" - acc_status = self._acc.get_door_status(self._device_id, self._number) - self._status = STATES_MAP.get(acc_status) + status = STATES_MAP.get( + self._acc.get_door_status(self._device_id, self._number) + ) + self._attr_is_opening = status == STATE_OPENING + self._attr_is_closing = status == STATE_CLOSING + self._attr_is_closed = None if status is None else status == STATE_CLOSED diff --git a/homeassistant/components/aladdin_connect/model.py b/homeassistant/components/aladdin_connect/model.py new file mode 100644 index 0000000000000..4248f3504fe07 --- /dev/null +++ b/homeassistant/components/aladdin_connect/model.py @@ -0,0 +1,13 @@ +"""Models for Aladdin connect cover platform.""" +from __future__ import annotations + +from typing import TypedDict + + +class DoorDevice(TypedDict): + """Aladdin door device.""" + + device_id: str + door_number: int + name: str + status: str diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 7d9e47fbcbecd..082327fdec815 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,11 +1,14 @@ """Component to interface with an alarm control panel.""" -from abc import abstractmethod +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging -from typing import final +from typing import Any, Final, final import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, @@ -13,41 +16,46 @@ SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import ( # noqa: F401 - PLATFORM_SCHEMA, - PLATFORM_SCHEMA_BASE, - make_entity_service_schema, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import make_entity_service_schema +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType from .const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -DOMAIN = "alarm_control_panel" -SCAN_INTERVAL = timedelta(seconds=30) -ATTR_CHANGED_BY = "changed_by" -FORMAT_TEXT = "text" -FORMAT_NUMBER = "number" -ATTR_CODE_ARM_REQUIRED = "code_arm_required" +DOMAIN: Final = "alarm_control_panel" +SCAN_INTERVAL: Final = timedelta(seconds=30) +ATTR_CHANGED_BY: Final = "changed_by" +FORMAT_TEXT: Final = "text" +FORMAT_NUMBER: Final = "number" +ATTR_CODE_ARM_REQUIRED: Final = "code_arm_required" -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" + +ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( + {vol.Optional(ATTR_CODE): cv.string} +) -ALARM_SERVICE_SCHEMA = make_entity_service_schema({vol.Optional(ATTR_CODE): cv.string}) +PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA +PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for sensors.""" component = hass.data[DOMAIN] = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL @@ -76,6 +84,12 @@ async def async_setup(hass, config): "async_alarm_arm_night", [SUPPORT_ALARM_ARM_NIGHT], ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_VACATION, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_vacation", + [SUPPORT_ALARM_ARM_VACATION], + ) component.async_register_entity_service( SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA, @@ -92,105 +106,114 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class AlarmControlPanelEntityDescription(EntityDescription): + """A class that describes alarm control panel entities.""" class AlarmControlPanelEntity(Entity): """An abstract class for alarm control entities.""" + entity_description: AlarmControlPanelEntityDescription + _attr_changed_by: str | None = None + _attr_code_arm_required: bool = True + _attr_code_format: str | None = None + _attr_supported_features: int + @property - def code_format(self): + def code_format(self) -> str | None: """Regex for code format or None if no code is required.""" - return None + return self._attr_code_format @property - def changed_by(self): + def changed_by(self) -> str | None: """Last change triggered by.""" - return None + return self._attr_changed_by @property - def code_arm_required(self): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" - return True + return self._attr_code_arm_required - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" raise NotImplementedError() - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self.hass.async_add_executor_job(self.alarm_disarm, code) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" raise NotImplementedError() - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.hass.async_add_executor_job(self.alarm_arm_home, code) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" raise NotImplementedError() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.hass.async_add_executor_job(self.alarm_arm_away, code) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" raise NotImplementedError() - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.hass.async_add_executor_job(self.alarm_arm_night, code) - def alarm_trigger(self, code=None): + def alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm vacation command.""" + raise NotImplementedError() + + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm vacation command.""" + await self.hass.async_add_executor_job(self.alarm_arm_vacation, code) + + def alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" raise NotImplementedError() - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" await self.hass.async_add_executor_job(self.alarm_trigger, code) - def alarm_arm_custom_bypass(self, code=None): + def alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" raise NotImplementedError() - async def async_alarm_arm_custom_bypass(self, code=None): + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" await self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) @property - @abstractmethod def supported_features(self) -> int: """Return the list of supported features.""" + return self._attr_supported_features @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" return { ATTR_CODE_FORMAT: self.code_format, ATTR_CHANGED_BY: self.changed_by, ATTR_CODE_ARM_REQUIRED: self.code_arm_required, } - - -class AlarmControlPanel(AlarmControlPanelEntity): - """An abstract class for alarm control entities (for backwards compatibility).""" - - def __init_subclass__(cls, **kwargs): - """Print deprecation warning.""" - super().__init_subclass__(**kwargs) - _LOGGER.warning( - "AlarmControlPanel is deprecated, modify %s to extend AlarmControlPanelEntity", - cls.__name__, - ) diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index 2844cb286ab99..f3688a279584a 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,14 +1,18 @@ """Provides the constants needed for component.""" -SUPPORT_ALARM_ARM_HOME = 1 -SUPPORT_ALARM_ARM_AWAY = 2 -SUPPORT_ALARM_ARM_NIGHT = 4 -SUPPORT_ALARM_TRIGGER = 8 -SUPPORT_ALARM_ARM_CUSTOM_BYPASS = 16 +from typing import Final -CONDITION_TRIGGERED = "is_triggered" -CONDITION_DISARMED = "is_disarmed" -CONDITION_ARMED_HOME = "is_armed_home" -CONDITION_ARMED_AWAY = "is_armed_away" -CONDITION_ARMED_NIGHT = "is_armed_night" -CONDITION_ARMED_CUSTOM_BYPASS = "is_armed_custom_bypass" +SUPPORT_ALARM_ARM_HOME: Final = 1 +SUPPORT_ALARM_ARM_AWAY: Final = 2 +SUPPORT_ALARM_ARM_NIGHT: Final = 4 +SUPPORT_ALARM_TRIGGER: Final = 8 +SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = 16 +SUPPORT_ALARM_ARM_VACATION: Final = 32 + +CONDITION_TRIGGERED: Final = "is_triggered" +CONDITION_DISARMED: Final = "is_disarmed" +CONDITION_ARMED_HOME: Final = "is_armed_home" +CONDITION_ARMED_AWAY: Final = "is_armed_away" +CONDITION_ARMED_NIGHT: Final = "is_armed_night" +CONDITION_ARMED_VACATION: Final = "is_armed_vacation" +CONDITION_ARMED_CUSTOM_BYPASS: Final = "is_armed_custom_bypass" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index 9a55998e92914..c37bddafcd33a 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -1,12 +1,13 @@ """Provides device automations for Alarm control panel.""" from __future__ import annotations +from typing import Final + import voluptuous as vol from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_CODE, CONF_DEVICE_ID, CONF_DOMAIN, @@ -15,24 +16,35 @@ SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.typing import ConfigType from . import ATTR_CODE_ARM_REQUIRED, DOMAIN from .const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) -ACTION_TYPES = {"arm_away", "arm_home", "arm_night", "disarm", "trigger"} +ACTION_TYPES: Final[set[str]] = { + "arm_away", + "arm_home", + "arm_night", + "arm_vacation", + "disarm", + "trigger", +} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +ACTION_SCHEMA: Final = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), @@ -41,7 +53,9 @@ ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Alarm control panel devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] @@ -51,65 +65,32 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) - - # We need a state or else we can't populate the HVAC and preset modes. - if state is None: - continue + supported_features = get_supported_features(hass, entry.entity_id) - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } # Add actions for each entity that belongs to this integration if supported_features & SUPPORT_ALARM_ARM_AWAY: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "arm_away", - } - ) + actions.append({**base_action, CONF_TYPE: "arm_away"}) if supported_features & SUPPORT_ALARM_ARM_HOME: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "arm_home", - } - ) + actions.append({**base_action, CONF_TYPE: "arm_home"}) if supported_features & SUPPORT_ALARM_ARM_NIGHT: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "arm_night", - } - ) - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "disarm", - } - ) + actions.append({**base_action, CONF_TYPE: "arm_night"}) + if supported_features & SUPPORT_ALARM_ARM_VACATION: + actions.append({**base_action, CONF_TYPE: "arm_vacation"}) + actions.append({**base_action, CONF_TYPE: "disarm"}) if supported_features & SUPPORT_ALARM_TRIGGER: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "trigger", - } - ) + actions.append({**base_action, CONF_TYPE: "trigger"}) return actions async def async_call_action_from_config( - hass: HomeAssistant, config: dict, variables: dict, context: Context | None + hass: HomeAssistant, config: ConfigType, variables: dict, context: Context | None ) -> None: """Execute a device action.""" service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} @@ -122,6 +103,8 @@ async def async_call_action_from_config( service = SERVICE_ALARM_ARM_HOME elif config[CONF_TYPE] == "arm_night": service = SERVICE_ALARM_ARM_NIGHT + elif config[CONF_TYPE] == "arm_vacation": + service = SERVICE_ALARM_ARM_VACATION elif config[CONF_TYPE] == "disarm": service = SERVICE_ALARM_DISARM elif config[CONF_TYPE] == "trigger": @@ -132,8 +115,12 @@ async def async_call_action_from_config( ) -async def async_get_action_capabilities(hass, config): +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" + # We need to refer to the state directly because ATTR_CODE_ARM_REQUIRED is not a + # capability attribute state = hass.states.get(config[CONF_ENTITY_ID]) code_required = state.attributes.get(ATTR_CODE_ARM_REQUIRED) if state else False diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 3817cf37b451f..a378cf262edfe 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -1,17 +1,12 @@ """Provide the device automations for Alarm control panel.""" from __future__ import annotations +from typing import Final + import voluptuous as vol -from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, -) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, @@ -21,12 +16,14 @@ STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN @@ -35,20 +32,27 @@ CONDITION_ARMED_CUSTOM_BYPASS, CONDITION_ARMED_HOME, CONDITION_ARMED_NIGHT, + CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, ) -CONDITION_TYPES = { +CONDITION_TYPES: Final[set[str]] = { CONDITION_TRIGGERED, CONDITION_DISARMED, CONDITION_ARMED_HOME, CONDITION_ARMED_AWAY, CONDITION_ARMED_NIGHT, + CONDITION_ARMED_VACATION, CONDITION_ARMED_CUSTOM_BYPASS, } -CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +CONDITION_SCHEMA: Final = DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), @@ -68,81 +72,41 @@ async def async_get_conditions( if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) - - # We need a state or else we can't populate the different armed conditions - if state is None: - continue - - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = get_supported_features(hass, entry.entity_id) # Add conditions for each entity that belongs to this integration + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + conditions += [ - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: CONDITION_DISARMED, - }, - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: CONDITION_TRIGGERED, - }, + {**base_condition, CONF_TYPE: CONDITION_DISARMED}, + {**base_condition, CONF_TYPE: CONDITION_TRIGGERED}, ] if supported_features & SUPPORT_ALARM_ARM_HOME: - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: CONDITION_ARMED_HOME, - } - ) + conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_HOME}) if supported_features & SUPPORT_ALARM_ARM_AWAY: - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: CONDITION_ARMED_AWAY, - } - ) + conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_AWAY}) if supported_features & SUPPORT_ALARM_ARM_NIGHT: - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: CONDITION_ARMED_NIGHT, - } - ) + conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_NIGHT}) + if supported_features & SUPPORT_ALARM_ARM_VACATION: + conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_VACATION}) if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS, - } + {**base_condition, CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS} ) return conditions +@callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == CONDITION_TRIGGERED: state = STATE_ALARM_TRIGGERED elif config[CONF_TYPE] == CONDITION_DISARMED: @@ -153,6 +117,8 @@ def async_condition_from_config( state = STATE_ALARM_ARMED_AWAY elif config[CONF_TYPE] == CONDITION_ARMED_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif config[CONF_TYPE] == CONDITION_ARMED_VACATION: + state = STATE_ALARM_ARMED_VACATION elif config[CONF_TYPE] == CONDITION_ARMED_CUSTOM_BYPASS: state = STATE_ALARM_ARMED_CUSTOM_BYPASS diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index b24716bb43e85..ce53596fc8dcc 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -1,18 +1,17 @@ """Provides device automations for Alarm control panel.""" from __future__ import annotations +from typing import Any, Final + import voluptuous as vol -from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, ) -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, @@ -22,20 +21,33 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType from . import DOMAIN +from .const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, +) -BASIC_TRIGGER_TYPES = {"triggered", "disarmed", "arming"} -TRIGGER_TYPES = BASIC_TRIGGER_TYPES | {"armed_home", "armed_away", "armed_night"} +BASIC_TRIGGER_TYPES: Final[set[str]] = {"triggered", "disarmed", "arming"} +TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { + "armed_home", + "armed_away", + "armed_night", + "armed_vacation", +} -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), @@ -44,23 +56,19 @@ ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Alarm control panel devices.""" registry = await entity_registry.async_get_registry(hass) - triggers = [] + triggers: list[dict[str, str]] = [] # Get all the integrations entities for this device for entry in entity_registry.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue - entity_state = hass.states.get(entry.entity_id) - - # We need a state or else we can't populate the HVAC and preset modes. - if entity_state is None: - continue - - supported_features = entity_state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = get_supported_features(hass, entry.entity_id) # Add triggers for each entity that belongs to this integration base_trigger = { @@ -98,11 +106,20 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: CONF_TYPE: "armed_night", } ) + if supported_features & SUPPORT_ALARM_ARM_VACATION: + triggers.append( + { + **base_trigger, + CONF_TYPE: "armed_vacation", + } + ) return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( @@ -115,7 +132,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "triggered": @@ -130,6 +147,8 @@ async def async_attach_trigger( to_state = STATE_ALARM_ARMED_AWAY elif config[CONF_TYPE] == "armed_night": to_state = STATE_ALARM_ARMED_NIGHT + elif config[CONF_TYPE] == "armed_vacation": + to_state = STATE_ALARM_ARMED_VACATION state_config = { state_trigger.CONF_PLATFORM: "state", @@ -138,7 +157,7 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/alarm_control_panel/group.py b/homeassistant/components/alarm_control_panel/group.py index 4bfb14868140a..dabe49069d5d1 100644 --- a/homeassistant/components/alarm_control_panel/group.py +++ b/homeassistant/components/alarm_control_panel/group.py @@ -7,6 +7,7 @@ STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_TRIGGERED, STATE_OFF, ) @@ -24,6 +25,7 @@ def async_describe_on_off_states( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_TRIGGERED, }, STATE_OFF, diff --git a/homeassistant/components/alarm_control_panel/reproduce_state.py b/homeassistant/components/alarm_control_panel/reproduce_state.py index e7e4c07b8ade2..ad992012c04ab 100644 --- a/homeassistant/components/alarm_control_panel/reproduce_state.py +++ b/homeassistant/components/alarm_control_panel/reproduce_state.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Iterable import logging -from typing import Any +from typing import Any, Final from homeassistant.const import ( ATTR_ENTITY_ID, @@ -12,12 +12,14 @@ SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -25,13 +27,14 @@ from . import DOMAIN -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -VALID_STATES = { +VALID_STATES: Final[set[str]] = { STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, } @@ -45,9 +48,7 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - cur_state = hass.states.get(state.entity_id) - - if cur_state is None: + if (cur_state := hass.states.get(state.entity_id)) is None: _LOGGER.warning("Unable to find entity %s", state.entity_id) return @@ -71,6 +72,8 @@ async def _async_reproduce_state( service = SERVICE_ALARM_ARM_HOME elif state.state == STATE_ALARM_ARMED_NIGHT: service = SERVICE_ALARM_ARM_NIGHT + elif state.state == STATE_ALARM_ARMED_VACATION: + service = SERVICE_ALARM_ARM_VACATION elif state.state == STATE_ALARM_DISARMED: service = SERVICE_ALARM_DISARM elif state.state == STATE_ALARM_TRIGGERED: diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index b18f1cfb78270..0bf3952c4ed34 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -4,6 +4,8 @@ alarm_disarm: name: Disarm description: Send the alarm the command for disarm. target: + entity: + domain: alarm_control_panel fields: code: name: Code @@ -16,11 +18,12 @@ alarm_arm_custom_bypass: name: Arm with custom bypass description: Send arm custom bypass command. target: + entity: + domain: alarm_control_panel fields: code: name: Code - description: - An optional code to arm custom bypass the alarm control panel with. + description: An optional code to arm custom bypass the alarm control panel with. example: "1234" selector: text: @@ -29,6 +32,8 @@ alarm_arm_home: name: Arm home description: Send the alarm the command for arm home. target: + entity: + domain: alarm_control_panel fields: code: name: Code @@ -41,6 +46,8 @@ alarm_arm_away: name: Arm away description: Send the alarm the command for arm away. target: + entity: + domain: alarm_control_panel fields: code: name: Code @@ -53,6 +60,8 @@ alarm_arm_night: name: Arm night description: Send the alarm the command for arm night. target: + entity: + domain: alarm_control_panel fields: code: name: Code @@ -61,10 +70,26 @@ alarm_arm_night: selector: text: +alarm_arm_vacation: + name: Arm vacation + description: Send the alarm the command for arm vacation. + target: + entity: + domain: alarm_control_panel + fields: + code: + name: Code + description: An optional code to arm vacation the alarm control panel with. + example: "1234" + selector: + text: + alarm_trigger: name: Trigger description: Send the alarm the command for trigger. target: + entity: + domain: alarm_control_panel fields: code: name: Code diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index de89d28082b90..5126f49d92b89 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -5,6 +5,7 @@ "arm_away": "Arm {entity_name} away", "arm_home": "Arm {entity_name} home", "arm_night": "Arm {entity_name} night", + "arm_vacation": "Arm {entity_name} vacation", "disarm": "Disarm {entity_name}", "trigger": "Trigger {entity_name}" }, @@ -13,14 +14,16 @@ "is_disarmed": "{entity_name} is disarmed", "is_armed_home": "{entity_name} is armed home", "is_armed_away": "{entity_name} is armed away", - "is_armed_night": "{entity_name} is armed night" + "is_armed_night": "{entity_name} is armed night", + "is_armed_vacation": "{entity_name} is armed vacation" }, "trigger_type": { "triggered": "{entity_name} triggered", "disarmed": "{entity_name} disarmed", "armed_home": "{entity_name} armed home", "armed_away": "{entity_name} armed away", - "armed_night": "{entity_name} armed night" + "armed_night": "{entity_name} armed night", + "armed_vacation": "{entity_name} armed vacation" } }, "state": { @@ -30,6 +33,7 @@ "armed_home": "Armed home", "armed_away": "Armed away", "armed_night": "Armed night", + "armed_vacation": "Armed vacation", "armed_custom_bypass": "Armed custom bypass", "pending": "Pending", "arming": "Arming", diff --git a/homeassistant/components/alarm_control_panel/translations/ar.json b/homeassistant/components/alarm_control_panel/translations/ar.json index 427b30eebbe54..66857391c280f 100644 --- a/homeassistant/components/alarm_control_panel/translations/ar.json +++ b/homeassistant/components/alarm_control_panel/translations/ar.json @@ -1,11 +1,29 @@ { + "device_automation": { + "action_type": { + "disarm": "\u0627\u0644\u063a\u064a \u062a\u0641\u0639\u064a\u0644 {entity_name}", + "trigger": "\u062a\u0634\u063a\u064a\u0644 {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0639\u064a\u062f\u0627", + "is_armed_home": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0627\u0644\u0645\u0646\u0632\u0644", + "is_disarmed": "{entity_name} \u0627\u0644\u063a\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644", + "is_triggered": "\u062a\u0645 \u062a\u0634\u063a\u064a\u0644 {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0639\u064a\u062f\u0627", + "armed_home": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0627\u0644\u0645\u0646\u0632\u0644", + "disarmed": "{entity_name} \u0627\u0644\u063a\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644" + } + }, "state": { "_": { - "armed": "\u0645\u0633\u0644\u062d", + "armed": "\u0645\u0641\u0639\u0651\u0644", "armed_away": "\u0645\u0641\u0639\u0651\u0644 \u0641\u064a \u0627\u0644\u062e\u0627\u0631\u062c", "armed_custom_bypass": "\u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u062a\u0641\u0639\u064a\u0644", "armed_home": "\u0645\u0641\u0639\u0651\u0644 \u0641\u064a \u0627\u0644\u0645\u0646\u0632\u0644", "armed_night": "\u0645\u0641\u0639\u0651\u0644 \u0644\u064a\u0644", + "armed_vacation": "\u0645\u0641\u0639\u0644 \u0628\u0648\u0636\u0639 \u0627\u0644\u0627\u062c\u0627\u0632\u0629", "arming": "\u062c\u0627\u0631\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644", "disarmed": "\u063a\u064a\u0631 \u0645\u0641\u0639\u0651\u0644", "disarming": "\u0625\u064a\u0642\u0627\u0641 \u0627\u0644\u0625\u0646\u0630\u0627\u0631", diff --git a/homeassistant/components/alarm_control_panel/translations/ca.json b/homeassistant/components/alarm_control_panel/translations/ca.json index dafef96b09038..d576b5b629a97 100644 --- a/homeassistant/components/alarm_control_panel/translations/ca.json +++ b/homeassistant/components/alarm_control_panel/translations/ca.json @@ -4,6 +4,7 @@ "arm_away": "Activa {entity_name} fora", "arm_home": "Activa {entity_name} a casa", "arm_night": "Activa {entity_name} nocturn", + "arm_vacation": "Activa {entity_name} en mode vacances", "disarm": "Desactiva {entity_name}", "trigger": "Dispara {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} est\u00e0 activada en mode 'a fora'", "is_armed_home": "{entity_name} est\u00e0 activada en mode 'a casa'", "is_armed_night": "{entity_name} est\u00e0 activada en mode 'nocturn'", + "is_armed_vacation": "{entity_name} activada en mode vacances", "is_disarmed": "{entity_name} est\u00e0 desactivada", "is_triggered": "{entity_name} est\u00e0 disparada" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} activada en mode 'a fora'", "armed_home": "{entity_name} activada en mode 'a casa'", "armed_night": "{entity_name} activada en mode 'nocturn'", + "armed_vacation": "{entity_name} s'activa en mode vacances", "disarmed": "{entity_name} desactivada", "triggered": "{entity_name} disparat/ada" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Activada, bypass personalitzat", "armed_home": "Activada, mode a casa", "armed_night": "Activada, mode nocturn", + "armed_vacation": "Activada, mode vacances", "arming": "Activant", "disarmed": "Desactivada", "disarming": "Desactivant", diff --git a/homeassistant/components/alarm_control_panel/translations/cs.json b/homeassistant/components/alarm_control_panel/translations/cs.json index 66786dfc0e2cb..7a831a2e2e614 100644 --- a/homeassistant/components/alarm_control_panel/translations/cs.json +++ b/homeassistant/components/alarm_control_panel/translations/cs.json @@ -4,6 +4,7 @@ "arm_away": "Aktivovat {entity_name} v re\u017eimu nep\u0159\u00edtomnost", "arm_home": "Aktivovat {entity_name} v re\u017eimu domov", "arm_night": "Aktivovat {entity_name} v no\u010dn\u00edm re\u017eimu", + "arm_vacation": "Aktivovat {entity_name} v re\u017eimu dovolen\u00e1", "disarm": "Odbezpe\u010dit {entity_name}", "trigger": "Spustit {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} je v re\u017eimu nep\u0159\u00edtomnost", "is_armed_home": "{entity_name} je v re\u017eimu domov", "is_armed_night": "{entity_name} je v no\u010dn\u00edm re\u017eimu", + "is_armed_vacation": "{entity_name} je v re\u017eimu dovolen\u00e1", "is_disarmed": "{entity_name} nen\u00ed zabezpe\u010den", "is_triggered": "{entity_name} je spu\u0161t\u011bn" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} v re\u017eimu nep\u0159\u00edtomnost", "armed_home": "{entity_name} v re\u017eimu domov", "armed_night": "{entity_name} v no\u010dn\u00edm re\u017eimu", + "armed_vacation": "{entity_name} v re\u017eimu dovolen\u00e1", "disarmed": "{entity_name} nezabezpe\u010den", "triggered": "{entity_name} spu\u0161t\u011bn" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Zabezpe\u010deno u\u017eivatelsk\u00fdm obejit\u00edm", "armed_home": "Re\u017eim domov", "armed_night": "No\u010dn\u00ed re\u017eim", + "armed_vacation": "V re\u017eimu dovolen\u00e1", "arming": "Zabezpe\u010dov\u00e1n\u00ed", "disarmed": "Nezabezpe\u010deno", "disarming": "Odbezpe\u010dov\u00e1n\u00ed", diff --git a/homeassistant/components/alarm_control_panel/translations/de.json b/homeassistant/components/alarm_control_panel/translations/de.json index a671c38893252..379ddfc041d31 100644 --- a/homeassistant/components/alarm_control_panel/translations/de.json +++ b/homeassistant/components/alarm_control_panel/translations/de.json @@ -4,6 +4,7 @@ "arm_away": "Aktiviere {entity_name} Unterwegs", "arm_home": "Aktiviere {entity_name} Zuhause", "arm_night": "Aktiviere {entity_name} Nacht-Modus", + "arm_vacation": "Aktiviere {entity_name} Urlaub", "disarm": "Deaktivere {entity_name}", "trigger": "Ausl\u00f6ser {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} ist aktiviert - Unterwegs", "is_armed_home": "{entity_name} ist aktiviert - Zuhause", "is_armed_night": "{entity_name} ist aktiviert - Nacht", + "is_armed_vacation": "{entity_name} ist aktiviert - Urlaub", "is_disarmed": "{entity_name} ist deaktiviert", "is_triggered": "{entity_name} wurde ausgel\u00f6st" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} Unterwegs", "armed_home": "{entity_name} Zuhause", "armed_night": "{entity_name} Nacht-Modus", + "armed_vacation": "{entity_name} Urlaub", "disarmed": "{entity_name} deaktiviert", "triggered": "{entity_name} ausgel\u00f6st" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Aktiv, benutzerdefiniert", "armed_home": "Aktiv, zu Hause", "armed_night": "Aktiv, Nacht", + "armed_vacation": "Aktiv, Urlaub", "arming": "Aktiviere", "disarmed": "Inaktiv", "disarming": "Deaktiviere", diff --git a/homeassistant/components/alarm_control_panel/translations/en.json b/homeassistant/components/alarm_control_panel/translations/en.json index b364d8504618c..c9e9541fc3098 100644 --- a/homeassistant/components/alarm_control_panel/translations/en.json +++ b/homeassistant/components/alarm_control_panel/translations/en.json @@ -4,6 +4,7 @@ "arm_away": "Arm {entity_name} away", "arm_home": "Arm {entity_name} home", "arm_night": "Arm {entity_name} night", + "arm_vacation": "Arm {entity_name} vacation", "disarm": "Disarm {entity_name}", "trigger": "Trigger {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} is armed away", "is_armed_home": "{entity_name} is armed home", "is_armed_night": "{entity_name} is armed night", + "is_armed_vacation": "{entity_name} is armed vacation", "is_disarmed": "{entity_name} is disarmed", "is_triggered": "{entity_name} is triggered" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} armed away", "armed_home": "{entity_name} armed home", "armed_night": "{entity_name} armed night", + "armed_vacation": "{entity_name} armed vacation", "disarmed": "{entity_name} disarmed", "triggered": "{entity_name} triggered" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armed custom bypass", "armed_home": "Armed home", "armed_night": "Armed night", + "armed_vacation": "Armed vacation", "arming": "Arming", "disarmed": "Disarmed", "disarming": "Disarming", diff --git a/homeassistant/components/alarm_control_panel/translations/es.json b/homeassistant/components/alarm_control_panel/translations/es.json index ab4e4a20cce09..a76c6bd5af9d8 100644 --- a/homeassistant/components/alarm_control_panel/translations/es.json +++ b/homeassistant/components/alarm_control_panel/translations/es.json @@ -4,6 +4,7 @@ "arm_away": "Armar {entity_name} exterior", "arm_home": "Armar {entity_name} modo casa", "arm_night": "Armar {entity_name} por la noche", + "arm_vacation": "Armar las vacaciones de {entity_name}", "disarm": "Desarmar {entity_name}", "trigger": "Lanzar {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} est\u00e1 armada ausente", "is_armed_home": "{entity_name} est\u00e1 armada en casa", "is_armed_night": "{entity_name} est\u00e1 armada noche", + "is_armed_vacation": "{entity_name} est\u00e1 armado de vacaciones", "is_disarmed": "{entity_name} est\u00e1 desarmada", "is_triggered": "{entity_name} est\u00e1 disparada" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} armada ausente", "armed_home": "{entity_name} armada en casa", "armed_night": "{entity_name} armada noche", + "armed_vacation": "Vacaciones armadas de {entity_name}", "disarmed": "{entity_name} desarmada", "triggered": "{entity_name} activado" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armada personalizada", "armed_home": "Armada en casa", "armed_night": "Armada noche", + "armed_vacation": "Vacaciones armadas", "arming": "Armando", "disarmed": "Desarmada", "disarming": "Desarmando", diff --git a/homeassistant/components/alarm_control_panel/translations/et.json b/homeassistant/components/alarm_control_panel/translations/et.json index cc4bb6f1ea327..1c3ddf0e1b26b 100644 --- a/homeassistant/components/alarm_control_panel/translations/et.json +++ b/homeassistant/components/alarm_control_panel/translations/et.json @@ -4,6 +4,7 @@ "arm_away": "Valvesta {entity_name}", "arm_home": "Valvesta {entity_name} kodus re\u017eiimis", "arm_night": "Valvesta {entity_name} \u00f6\u00f6re\u017eiimis", + "arm_vacation": "Valvesta {entity_name} puhkusere\u017eiimis", "disarm": "V\u00f5ta {entity_name} valvest maha", "trigger": "K\u00e4ivita {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} on valvestatud", "is_armed_home": "{entity_name} on valvestatud kodure\u017eiimis", "is_armed_night": "{entity_name} on valvestatud \u00f6\u00f6re\u017eiimis", + "is_armed_vacation": "{entity_name} on valvestatud puhkuse reziimis", "is_disarmed": "{entity_name} on valve alt maas", "is_triggered": "{entity_name} on h\u00e4iret andnud" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} valvestati", "armed_home": "{entity_name} valvestati kodure\u017eiimis", "armed_night": "{entity_name} valvestati \u00f6\u00f6re\u017eiimis", + "armed_vacation": "{entity_name} puhkuse re\u017eiim", "disarmed": "{entity_name} v\u00f5eti valvest maha", "triggered": "{entity_name} andis h\u00e4iret" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Valves, eranditega", "armed_home": "Valves kodus", "armed_night": "Valves \u00f6ine", + "armed_vacation": "Valvestatud puhkuse re\u017eiimis", "arming": "Valvestab", "disarmed": "Maas", "disarming": "Maas...", diff --git a/homeassistant/components/alarm_control_panel/translations/fr.json b/homeassistant/components/alarm_control_panel/translations/fr.json index c7e010e805e73..596c999d1053c 100644 --- a/homeassistant/components/alarm_control_panel/translations/fr.json +++ b/homeassistant/components/alarm_control_panel/translations/fr.json @@ -1,9 +1,10 @@ { "device_automation": { "action_type": { - "arm_away": "Armer {entity_name} en mode \"sortie\"", + "arm_away": "Armer {entity_name} mode sortie", "arm_home": "Armer {entity_name} en mode \"maison\"", "arm_night": "Armer {entity_name} en mode \"nuit\"", + "arm_vacation": "Armer {entity_name} vacances", "disarm": "D\u00e9sarmer {entity_name}", "trigger": "D\u00e9clencheur {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} est arm\u00e9", "is_armed_home": "{entity_name} est arm\u00e9 \u00e0 la maison", "is_armed_night": "{entity_name} est arm\u00e9 la nuit", + "is_armed_vacation": "{entity_name} est arm\u00e9 en mode vacances", "is_disarmed": "{entity_name} est d\u00e9sarm\u00e9", "is_triggered": "{entity_name} est d\u00e9clench\u00e9" }, @@ -18,6 +20,7 @@ "armed_away": "Armer {entity_name} en mode \"sortie\"", "armed_home": "Armer {entity_name} en mode \"maison\"", "armed_night": "Armer {entity_name} en mode \"nuit\"", + "armed_vacation": "{entity_name} vacances arm\u00e9es", "disarmed": "{entity_name} d\u00e9sarm\u00e9", "triggered": "{entity_name} d\u00e9clench\u00e9" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Arm\u00e9 avec exception personnalis\u00e9e", "armed_home": "Enclench\u00e9e (pr\u00e9sent)", "armed_night": "Enclench\u00e9 (nuit)", + "armed_vacation": "Arm\u00e9es vacances", "arming": "Activation", "disarmed": "D\u00e9sactiv\u00e9e", "disarming": "D\u00e9sactivation", diff --git a/homeassistant/components/alarm_control_panel/translations/he.json b/homeassistant/components/alarm_control_panel/translations/he.json index 544b23f5629fa..9710be7c3c2d6 100644 --- a/homeassistant/components/alarm_control_panel/translations/he.json +++ b/homeassistant/components/alarm_control_panel/translations/he.json @@ -1,16 +1,43 @@ { + "device_automation": { + "action_type": { + "arm_away": "\u05d3\u05e8\u05d9\u05db\u05ea {entity_name} \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "arm_home": "\u05d3\u05e8\u05d9\u05db\u05ea {entity_name} \u05d4\u05d1\u05d9\u05ea\u05d4", + "arm_night": "\u05d3\u05e8\u05d9\u05db\u05ea {entity_name} \u05dc\u05d9\u05dc\u05d4", + "arm_vacation": "\u05d3\u05e8\u05d9\u05db\u05ea {entity_name} \u05d7\u05d5\u05e4\u05e9\u05d4", + "disarm": "\u05e0\u05d9\u05d8\u05e8\u05d5\u05dc {entity_name}", + "trigger": "\u05d4\u05e4\u05e2\u05dc\u05ea {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} \u05d3\u05e8\u05d5\u05da \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "is_armed_home": "{entity_name} \u05d3\u05e8\u05d5\u05da \u05d1\u05d9\u05ea", + "is_armed_night": "{entity_name} \u05d3\u05e8\u05d5\u05da \u05dc\u05d9\u05dc\u05d4", + "is_armed_vacation": "{entity_name} \u05d1\u05d7\u05d5\u05e4\u05e9\u05d4 \u05d3\u05e8\u05d5\u05db\u05d4", + "is_disarmed": "{entity_name} \u05de\u05e0\u05d5\u05d8\u05e8\u05dc", + "is_triggered": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc" + }, + "trigger_type": { + "armed_away": "{entity_name} \u05d3\u05e8\u05d5\u05da \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "armed_home": "{entity_name} \u05d3\u05e8\u05d5\u05da \u05d1\u05d1\u05d9\u05ea", + "armed_night": "{entity_name} \u05d3\u05e8\u05d5\u05da \u05dc\u05d9\u05dc\u05d4", + "armed_vacation": "{entity_name} \u05d7\u05d5\u05e4\u05e9\u05d4 \u05d3\u05e8\u05d5\u05db\u05d4", + "disarmed": "{entity_name} \u05de\u05e0\u05d5\u05d8\u05e8\u05dc", + "triggered": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc" + } + }, "state": { "_": { "armed": "\u05d3\u05e8\u05d5\u05da", - "armed_away": "\u05d3\u05e8\u05d5\u05da \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "armed_away": "\u05d3\u05e8\u05d5\u05da - \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", "armed_custom_bypass": "\u05de\u05e2\u05e7\u05e3 \u05de\u05d5\u05ea\u05d0\u05dd \u05d0\u05d9\u05e9\u05d9\u05ea \u05d3\u05e8\u05d5\u05da", "armed_home": "\u05d4\u05d1\u05d9\u05ea \u05d3\u05e8\u05d5\u05da", - "armed_night": "\u05d3\u05e8\u05d5\u05da \u05dc\u05d9\u05dc\u05d4", + "armed_night": "\u05d3\u05e8\u05d5\u05da - \u05dc\u05d9\u05dc\u05d4", + "armed_vacation": "\u05d3\u05e8\u05d5\u05da - \u05d7\u05d5\u05e4\u05e9\u05d4", "arming": "\u05de\u05e4\u05e2\u05d9\u05dc", "disarmed": "\u05de\u05e0\u05d5\u05d8\u05e8\u05dc", "disarming": "\u05de\u05e0\u05d8\u05e8\u05dc", "pending": "\u05de\u05de\u05ea\u05d9\u05df", - "triggered": "\u05d4\u05d5\u05e4\u05e2\u05dc" + "triggered": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05dc\u05d5\u05d7 \u05d1\u05e7\u05e8\u05d4 \u05e9\u05dc \u05d0\u05d6\u05e2\u05e7\u05d4" diff --git a/homeassistant/components/alarm_control_panel/translations/hu.json b/homeassistant/components/alarm_control_panel/translations/hu.json index 81fa10311ef27..5eba25a9ec2e3 100644 --- a/homeassistant/components/alarm_control_panel/translations/hu.json +++ b/homeassistant/components/alarm_control_panel/translations/hu.json @@ -4,13 +4,23 @@ "arm_away": "{entity_name} \u00e9les\u00edt\u00e9se t\u00e1voz\u00f3 m\u00f3dban", "arm_home": "{entity_name} \u00e9les\u00edt\u00e9se otthon marad\u00f3 m\u00f3dban", "arm_night": "{entity_name} \u00e9les\u00edt\u00e9se \u00e9jszakai m\u00f3dban", + "arm_vacation": "\u00c9les\u00edtse az {entity_name} a nyaral\u00e1sra", "disarm": "{entity_name} hat\u00e1stalan\u00edt\u00e1sa", "trigger": "{entity_name} riaszt\u00e1si esem\u00e9ny ind\u00edt\u00e1sa" }, + "condition_type": { + "is_armed_away": "{entity_name} \u00e9les\u00edtve van", + "is_armed_home": "{entity_name} \u00e9les\u00edtett otthoni m\u00f3dban", + "is_armed_night": "{entity_name} \u00e9les\u00edtett \u00e9jszaka m\u00f3dban", + "is_armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edtve", + "is_disarmed": "{entity_name} hat\u00e1stalan\u00edtva", + "is_triggered": "{entity_name} aktiv\u00e1lva van" + }, "trigger_type": { "armed_away": "{entity_name} t\u00e1voz\u00f3 m\u00f3dban lett \u00e9les\u00edtve", "armed_home": "{entity_name} otthon marad\u00f3 m\u00f3dban lett \u00e9les\u00edtve", "armed_night": "{entity_name} \u00e9jszakai m\u00f3dban lett \u00e9les\u00edtve", + "armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edt\u00e9s", "disarmed": "{entity_name} hat\u00e1stalan\u00edtva lett", "triggered": "{entity_name} riaszt\u00e1sba ker\u00fclt" } @@ -22,6 +32,7 @@ "armed_custom_bypass": "\u00c9les\u00edtve \u00e1thidal\u00e1ssal", "armed_home": "\u00c9les\u00edtve otthon", "armed_night": "\u00c9les\u00edtve \u00e9jszaka", + "armed_vacation": "Vak\u00e1ci\u00f3 \u00e9les\u00edt\u00e9s", "arming": "\u00c9les\u00edt\u00e9s", "disarmed": "Hat\u00e1stalan\u00edtva", "disarming": "Hat\u00e1stalan\u00edt\u00e1s", diff --git a/homeassistant/components/alarm_control_panel/translations/id.json b/homeassistant/components/alarm_control_panel/translations/id.json index f1676ce8c7561..ee079ff743592 100644 --- a/homeassistant/components/alarm_control_panel/translations/id.json +++ b/homeassistant/components/alarm_control_panel/translations/id.json @@ -4,6 +4,7 @@ "arm_away": "Aktifkan {entity_name} untuk keluar", "arm_home": "Aktifkan {entity_name} untuk di rumah", "arm_night": "Aktifkan {entity_name} untuk malam", + "arm_vacation": "Aktifkan {entity_name} untuk liburan", "disarm": "Nonaktifkan {entity_name}", "trigger": "Picu {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} diaktifkan untuk keluar", "is_armed_home": "{entity_name} diaktifkan untuk di rumah", "is_armed_night": "{entity_name} diaktifkan untuk malam", + "is_armed_vacation": "{entity_name} diaktifkan untuk liburan", "is_disarmed": "{entity_name} dinonaktifkan", "is_triggered": "{entity_name} dipicu" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} diaktifkan untuk keluar", "armed_home": "{entity_name} diaktifkan untuk di rumah", "armed_night": "{entity_name} diaktifkan untuk malam", + "armed_vacation": "{entity_name} diaktifkan untuk liburan", "disarmed": "{entity_name} dinonaktifkan", "triggered": "{entity_name} dipicu" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Diaktifkan khusus", "armed_home": "Diaktifkan untuk di rumah", "armed_night": "Diaktifkan untuk malam", + "armed_vacation": "Diaktifkan untuk liburan", "arming": "Mengaktifkan", "disarmed": "Dinonaktifkan", "disarming": "Dinonaktifkan", diff --git a/homeassistant/components/alarm_control_panel/translations/it.json b/homeassistant/components/alarm_control_panel/translations/it.json index 1574f88541b20..ac07c28da620e 100644 --- a/homeassistant/components/alarm_control_panel/translations/it.json +++ b/homeassistant/components/alarm_control_panel/translations/it.json @@ -1,16 +1,18 @@ { "device_automation": { "action_type": { - "arm_away": "Armare {entity_name} uscito", - "arm_home": "Armare {entity_name} casa", - "arm_night": "Armare {entity_name} notte", - "disarm": "Disarmare {entity_name}", + "arm_away": "Attiva {entity_name} fuori casa", + "arm_home": "Attiva {entity_name} casa", + "arm_night": "Attiva {entity_name} notte", + "arm_vacation": "Attiva {entity_name} vacanza", + "disarm": "Disattiva {entity_name}", "trigger": "Attivazione {entity_name}" }, "condition_type": { "is_armed_away": "{entity_name} \u00e8 attivo in modalit\u00e0 fuori casa", "is_armed_home": "{entity_name} \u00e8 attivo in modalit\u00e0 a casa", "is_armed_night": "{entity_name} \u00e8 attivo in modalit\u00e0 notte", + "is_armed_vacation": "{entity_name} \u00e8 attivo in modalit\u00e0 vacanza", "is_disarmed": "{entity_name} \u00e8 disattivo", "is_triggered": "{entity_name} \u00e8 attivato" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} attivato in modalit\u00e0 fuori casa", "armed_home": "{entity_name} attivato in modalit\u00e0 a casa", "armed_night": "{entity_name} attivato in modalit\u00e0 notte", + "armed_vacation": "{entity_name} attivato in modalit\u00e0 vacanza", "disarmed": "{entity_name} disattivato", "triggered": "{entity_name} attivato" } @@ -29,12 +32,13 @@ "armed_custom_bypass": "Attivo con bypass personalizzato", "armed_home": "Attivo in casa", "armed_night": "Attivo Notte", - "arming": "In Attivazione", + "armed_vacation": "Attivo in vacanza", + "arming": "In attivazione", "disarmed": "Disattivo", - "disarming": "In Disattivazione", + "disarming": "In disattivazione", "pending": "In sospeso", "triggered": "Attivato" } }, - "title": "Pannello di Controllo degli Allarmi" + "title": "Pannello di controllo degli allarmi" } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ja.json b/homeassistant/components/alarm_control_panel/translations/ja.json index 3eceb75b5973d..875d24fd3fe2c 100644 --- a/homeassistant/components/alarm_control_panel/translations/ja.json +++ b/homeassistant/components/alarm_control_panel/translations/ja.json @@ -1,7 +1,44 @@ { + "device_automation": { + "action_type": { + "arm_away": "\u8b66\u6212 {entity_name} \u96e2\u5e2d(away)", + "arm_home": "\u8b66\u6212 {entity_name} \u5728\u5b85", + "arm_night": "\u8b66\u6212 {entity_name} \u591c", + "arm_vacation": "\u8b66\u6212 {entity_name} \u4f11\u6687", + "disarm": "\u89e3\u9664 {entity_name}", + "trigger": "\u30c8\u30ea\u30ac\u30fc {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} \u306f\u8b66\u6212 \u96e2\u5e2d(away)", + "is_armed_home": "{entity_name} \u306f\u8b66\u6212 \u5728\u5b85", + "is_armed_night": "{entity_name} \u306f\u8b66\u6212 \u591c", + "is_armed_vacation": "{entity_name} \u306f\u8b66\u6212 \u4f11\u6687", + "is_disarmed": "{entity_name} \u306f\u89e3\u9664", + "is_triggered": "{entity_name} \u304c\u30c8\u30ea\u30ac\u30fc\u3055\u308c\u307e\u3059" + }, + "trigger_type": { + "armed_away": "{entity_name} \u8b66\u6212 \u96e2\u5e2d(away)", + "armed_home": "{entity_name} \u8b66\u6212 \u5728\u5b85", + "armed_night": "{entity_name} \u8b66\u6212 \u591c", + "armed_vacation": "{entity_name} \u8b66\u6212 \u4f11\u6687", + "disarmed": "{entity_name} \u89e3\u9664", + "triggered": "{entity_name} \u304c\u30c8\u30ea\u30ac\u30fc\u3055\u308c\u307e\u3057\u305f" + } + }, "state": { "_": { + "armed": "\u8b66\u6212", + "armed_away": "\u8b66\u6212 \u96e2\u5e2d(away)", + "armed_custom_bypass": "\u8b66\u6212 \u30ab\u30b9\u30bf\u30e0 \u30d0\u30a4\u30d1\u30b9", + "armed_home": "\u8b66\u6212 \u5728\u5b85", + "armed_night": "\u8b66\u6212 \u591c", + "armed_vacation": "\u8b66\u6212 \u4f11\u6687", + "arming": "\u8b66\u6212\u4e2d", + "disarmed": "\u89e3\u9664", + "disarming": "\u89e3\u9664", + "pending": "\u4fdd\u7559\u4e2d", "triggered": "\u30c8\u30ea\u30ac\u30fc" } - } + }, + "title": "\u30a2\u30e9\u30fc\u30e0\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb" } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json index 0a0f33d6181f6..5527101589be2 100644 --- a/homeassistant/components/alarm_control_panel/translations/nl.json +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -1,40 +1,44 @@ { "device_automation": { "action_type": { - "arm_away": "Inschakelen {entity_name} afwezig", - "arm_home": "Inschakelen {entity_name} thuis", - "arm_night": "Inschakelen {entity_name} nacht", - "disarm": "Uitschakelen {entity_name}", - "trigger": "Trigger {entity_name}" + "arm_away": "Schakel {entity_name} in voor vertrek", + "arm_home": "Schakel {entity_name} in voor thuis", + "arm_night": "Schakel {entity_name} in voor 's nachts", + "arm_vacation": "Schakel {entity_name} in voor vakantie", + "disarm": "Schakel {entity_name} uit", + "trigger": "Laat {entity_name} afgaan" }, "condition_type": { - "is_armed_away": "{entity_name} afwezig ingeschakeld", - "is_armed_home": "{entity_name} thuis ingeschakeld", - "is_armed_night": "{entity_name} nachtstand ingeschakeld", + "is_armed_away": "{entity_name} ingeschakeld voor vertrek", + "is_armed_home": "{entity_name} ingeschakeld voor thuis", + "is_armed_night": "{entity_name} is ingeschakeld voor 's nachts", + "is_armed_vacation": "{entity_name} is ingeschakeld voor vakantie", "is_disarmed": "{entity_name} is uitgeschakeld", - "is_triggered": "{entity_name} wordt geactiveerd" + "is_triggered": "{entity_name} gaat af" }, "trigger_type": { - "armed_away": "{entity_name} afwezig ingeschakeld", - "armed_home": "{entity_name} thuis ingeschakeld", - "armed_night": "{entity_name} nachtstand ingeschakeld", + "armed_away": "{entity_name} ingeschakeld voor vertrek", + "armed_home": "{entity_name} ingeschakeld voor thuis", + "armed_night": "{entity_name} ingeschakeld voor 's nachts", + "armed_vacation": "{entity_name} schakelde in voor vakantie", "disarmed": "{entity_name} uitgeschakeld", - "triggered": "{entity_name} geactiveerd" + "triggered": "{entity_name} afgegaan" } }, "state": { "_": { "armed": "Ingeschakeld", - "armed_away": "Ingeschakeld afwezig", - "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", - "armed_home": "Ingeschakeld thuis", - "armed_night": "Ingeschakeld nacht", + "armed_away": "Ingeschakeld voor vertrek", + "armed_custom_bypass": "Ingeschakeld met overbrugging", + "armed_home": "Ingeschakeld voor thuis", + "armed_night": "Ingeschakeld voor 's nachts", + "armed_vacation": "Vakantie ingeschakeld", "arming": "Schakelt in", "disarmed": "Uitgeschakeld", "disarming": "Schakelt uit", "pending": "In wacht", - "triggered": "Geactiveerd" + "triggered": "Gaat af" } }, - "title": "Alarm bedieningspaneel" + "title": "Alarmbedieningspaneel" } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/no.json b/homeassistant/components/alarm_control_panel/translations/no.json index 465dd250086a2..ad8ed2c9c7428 100644 --- a/homeassistant/components/alarm_control_panel/translations/no.json +++ b/homeassistant/components/alarm_control_panel/translations/no.json @@ -4,6 +4,7 @@ "arm_away": "Aktiver {entity_name} borte", "arm_home": "Aktiver {entity_name} hjemme", "arm_night": "Aktiver {entity_name} natt", + "arm_vacation": "{entity_name} ferie", "disarm": "Deaktiver {entity_name}", "trigger": "Utl\u00f8ser {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} er aktivert borte", "is_armed_home": "{entity_name} er aktivert hjemme", "is_armed_night": "{entity_name} er aktivert natt", + "is_armed_vacation": "{entity_name} er armert ferie", "is_disarmed": "{entity_name} er deaktivert", "is_triggered": "{entity_name} er utl\u00f8st" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} aktivert borte", "armed_home": "{entity_name} aktivert hjemme", "armed_night": "{entity_name} aktivert natt", + "armed_vacation": "{entity_name} armert ferie", "disarmed": "{entity_name} deaktivert", "triggered": "{entity_name} utl\u00f8st" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armert tilpasset unntak", "armed_home": "Armert hjemme", "armed_night": "Armert natt", + "armed_vacation": "Armert ferie", "arming": "Armerer", "disarmed": "Avsl\u00e5tt", "disarming": "Disarmer", diff --git a/homeassistant/components/alarm_control_panel/translations/pl.json b/homeassistant/components/alarm_control_panel/translations/pl.json index 0fd3045d1df44..b65dbd592826e 100644 --- a/homeassistant/components/alarm_control_panel/translations/pl.json +++ b/homeassistant/components/alarm_control_panel/translations/pl.json @@ -4,6 +4,7 @@ "arm_away": "uzbr\u00f3j (poza domem) {entity_name}", "arm_home": "uzbr\u00f3j (w domu) {entity_name}", "arm_night": "uzbr\u00f3j (noc) {entity_name}", + "arm_vacation": "uzbr\u00f3j (tryb wakacyjny) {entity_name}", "disarm": "rozbr\u00f3j {entity_name}", "trigger": "wyzw\u00f3l {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "alarm {entity_name} jest uzbrojony (poza domem)", "is_armed_home": "alarm {entity_name} jest uzbrojony (w domu)", "is_armed_night": "alarm {entity_name} jest uzbrojony (noc)", + "is_armed_vacation": "alarm {entity_name} jest uzbrojony (tryb wakacyjny)", "is_disarmed": "alarm {entity_name} jest rozbrojony", "is_triggered": "alarm {entity_name} jest wyzwolony" }, @@ -18,6 +20,7 @@ "armed_away": "alarm {entity_name} zostanie uzbrojony (poza domem)", "armed_home": "alarm {entity_name} zostanie uzbrojony (w domu)", "armed_night": "alarm {entity_name} zostanie uzbrojony (noc)", + "armed_vacation": "alarm {entity_name} zostanie uzbrojony (tryb wakacyjny)", "disarmed": "alarm {entity_name} zostanie rozbrojony", "triggered": "alarm {entity_name} zostanie wyzwolony" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "uzbrojony (cz\u0119\u015bciowo)", "armed_home": "uzbrojony (w domu)", "armed_night": "uzbrojony (noc)", + "armed_vacation": "uzbrojony (tryb wakacyjny)", "arming": "uzbrajanie", "disarmed": "rozbrojony", "disarming": "rozbrajanie", diff --git a/homeassistant/components/alarm_control_panel/translations/ru.json b/homeassistant/components/alarm_control_panel/translations/ru.json index f390f017328f1..61e46b3db1f24 100644 --- a/homeassistant/components/alarm_control_panel/translations/ru.json +++ b/homeassistant/components/alarm_control_panel/translations/ru.json @@ -4,6 +4,7 @@ "arm_away": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "arm_home": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "arm_night": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "arm_vacation": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041e\u0442\u043f\u0443\u0441\u043a\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "disarm": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0445\u0440\u0430\u043d\u0443 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "trigger": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" }, @@ -11,6 +12,7 @@ "is_armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "is_armed_vacation": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041e\u0442\u043f\u0443\u0441\u043a\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" }, @@ -18,6 +20,7 @@ "armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "armed_vacation": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041e\u0442\u043f\u0443\u0441\u043a\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "\u041e\u0445\u0440\u0430\u043d\u0430 \u0441 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u043c\u0438", "armed_home": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u0434\u043e\u043c\u0430)", "armed_night": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u043d\u043e\u0447\u044c)", + "armed_vacation": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u043e\u0442\u043f\u0443\u0441\u043a)", "arming": "\u041f\u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0443", "disarmed": "\u0421\u043d\u044f\u0442\u043e \u0441 \u043e\u0445\u0440\u0430\u043d\u044b", "disarming": "\u0421\u043d\u044f\u0442\u0438\u0435 \u0441 \u043e\u0445\u0440\u0430\u043d\u044b", diff --git a/homeassistant/components/alarm_control_panel/translations/tr.json b/homeassistant/components/alarm_control_panel/translations/tr.json index cc50943043667..e07c4daba2938 100644 --- a/homeassistant/components/alarm_control_panel/translations/tr.json +++ b/homeassistant/components/alarm_control_panel/translations/tr.json @@ -1,23 +1,44 @@ { "device_automation": { + "action_type": { + "arm_away": "{entity_name} Uzakta Alarm", + "arm_home": "{entity_name} Evde Alarm", + "arm_night": "{entity_name} Gece Alarm", + "arm_vacation": "{entity_name} Alarm - Tatil Modu", + "disarm": "Devre d\u0131\u015f\u0131 {entity_name}", + "trigger": "Tetikle {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} D\u0131\u015farda Modu Aktif", + "is_armed_home": "{entity_name} Evde Modu Aktif", + "is_armed_night": "{entity_name} Gece Modu Aktif", + "is_armed_vacation": "{entity_name} Alarm a\u00e7\u0131k - Tatil Modu", + "is_disarmed": "{entity_name} Devre D\u0131\u015f\u0131", + "is_triggered": "{entity_name} tetiklendi" + }, "trigger_type": { - "disarmed": "{entity_name} b\u0131rak\u0131ld\u0131", + "armed_away": "{entity_name} D\u0131\u015farda Modu Aktif", + "armed_home": "{entity_name} Evde Modu Aktif", + "armed_night": "{entity_name} Gece Modu Aktif", + "armed_vacation": "{entity_name} Alarm Tatil Modunda", + "disarmed": "{entity_name} Devre D\u0131\u015f\u0131", "triggered": "{entity_name} tetiklendi" } }, "state": { "_": { - "armed": "Etkin", - "armed_away": "Etkin d\u0131\u015far\u0131da", - "armed_custom_bypass": "Alarm etkin \u00f6zel baypas", - "armed_home": "Etkin evde", - "armed_night": "Etkin gece", + "armed": "Aktif", + "armed_away": "D\u0131\u015farda Aktif", + "armed_custom_bypass": "\u00d6zel Mod Aktif", + "armed_home": "Evde Aktif", + "armed_night": "Gece Aktif", + "armed_vacation": "Alarm - Tatil Modu", "arming": "Alarm etkinle\u015fiyor", - "disarmed": "Etkisiz", + "disarmed": "Devre D\u0131\u015f\u0131", "disarming": "Alarm devre d\u0131\u015f\u0131", "pending": "Beklemede", "triggered": "Tetiklendi" } }, - "title": "Alarm kontrol paneli" + "title": "Alarm Kontrol Paneli" } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json index fa819e71b4921..e955d21afdb92 100644 --- a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json @@ -29,6 +29,7 @@ "armed_custom_bypass": "\u81ea\u5b9a\u4e49\u533a\u57df\u8b66\u6212", "armed_home": "\u5728\u5bb6\u8b66\u6212", "armed_night": "\u591c\u95f4\u8b66\u6212", + "armed_vacation": "\u5ea6\u5047\u8b66\u6212", "arming": "\u8b66\u6212\u4e2d", "disarmed": "\u8b66\u6212\u89e3\u9664", "disarming": "\u8b66\u6212\u89e3\u9664", diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hant.json b/homeassistant/components/alarm_control_panel/translations/zh-Hant.json index 2dac00f99902d..74d046c233d84 100644 --- a/homeassistant/components/alarm_control_panel/translations/zh-Hant.json +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hant.json @@ -4,6 +4,7 @@ "arm_away": "\u8a2d\u5b9a{entity_name}\u5916\u51fa\u6a21\u5f0f", "arm_home": "\u8a2d\u5b9a{entity_name}\u8fd4\u5bb6\u6a21\u5f0f", "arm_night": "\u8a2d\u5b9a{entity_name}\u591c\u9593\u6a21\u5f0f", + "arm_vacation": "\u8a2d\u5b9a{entity_name}\u5ea6\u5047\u6a21\u5f0f", "disarm": "\u89e3\u9664{entity_name}", "trigger": "\u89f8\u767c{entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", "is_armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", "is_armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "is_armed_vacation": "{entity_name}\u8a2d\u5b9a\u5ea6\u5047", "is_disarmed": "{entity_name}\u5df2\u89e3\u9664", "is_triggered": "{entity_name}\u5df2\u89f8\u767c" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", "armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", "armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "armed_vacation": "{entity_name}\u8a2d\u5b9a\u5ea6\u5047", "disarmed": "{entity_name}\u5df2\u89e3\u9664", "triggered": "{entity_name}\u5df2\u89f8\u767c" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "\u8b66\u6212\u6a21\u5f0f\u72c0\u614b", "armed_home": "\u5728\u5bb6\u8b66\u6212", "armed_night": "\u591c\u9593\u8b66\u6212", + "armed_vacation": "\u5ea6\u5047\u8b66\u6212", "arming": "\u8b66\u6212\u4e2d", "disarmed": "\u8b66\u6212\u89e3\u9664", "disarming": "\u89e3\u9664\u4e2d", diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index aff7dd8c5ba40..fd0b76a5c8a77 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -12,6 +12,7 @@ CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -35,7 +36,11 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["alarm_control_panel", "sensor", "binary_sensor"] +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.SENSOR, + Platform.BINARY_SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -129,7 +134,7 @@ def handle_rel_message(sender, message): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a AlarmDecoder entry.""" hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = False diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 47da48de66f9c..3a0b392323184 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -77,24 +77,18 @@ async def async_setup_entry( class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" + _attr_name = "Alarm Panel" + _attr_should_poll = False + _attr_code_format = FORMAT_NUMBER + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + def __init__(self, client, auto_bypass, code_arm_required, alt_night_mode): """Initialize the alarm panel.""" self._client = client - self._display = "" - self._name = "Alarm Panel" - self._state = None - self._ac_power = None - self._alarm_event_occurred = None - self._backlight_on = None - self._battery_low = None - self._check_zone = None - self._chime = None - self._entry_delay_off = None - self._programming_mode = None - self._ready = None - self._zone_bypassed = None self._auto_bypass = auto_bypass - self._code_arm_required = code_arm_required + self._attr_code_arm_required = code_arm_required self._alt_night_mode = alt_night_mode async def async_added_to_hass(self): @@ -108,75 +102,29 @@ async def async_added_to_hass(self): def _message_callback(self, message): """Handle received messages.""" if message.alarm_sounding or message.fire_alarm: - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED elif message.armed_away: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif message.armed_home and (message.entry_delay_off or message.perimeter_only): - self._state = STATE_ALARM_ARMED_NIGHT + self._attr_state = STATE_ALARM_ARMED_NIGHT elif message.armed_home: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME else: - self._state = STATE_ALARM_DISARMED - - self._ac_power = message.ac_power - self._alarm_event_occurred = message.alarm_event_occurred - self._backlight_on = message.backlight_on - self._battery_low = message.battery_low - self._check_zone = message.check_zone - self._chime = message.chime_on - self._entry_delay_off = message.entry_delay_off - self._programming_mode = message.programming_mode - self._ready = message.ready - self._zone_bypassed = message.zone_bypassed - - self.schedule_update_ha_state() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def code_format(self): - """Return one or more digits/characters.""" - return FORMAT_NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - "ac_power": self._ac_power, - "alarm_event_occurred": self._alarm_event_occurred, - "backlight_on": self._backlight_on, - "battery_low": self._battery_low, - "check_zone": self._check_zone, - "chime": self._chime, - "entry_delay_off": self._entry_delay_off, - "programming_mode": self._programming_mode, - "ready": self._ready, - "zone_bypassed": self._zone_bypassed, - "code_arm_required": self._code_arm_required, + self._attr_state = STATE_ALARM_DISARMED + + self._attr_extra_state_attributes = { + "ac_power": message.ac_power, + "alarm_event_occurred": message.alarm_event_occurred, + "backlight_on": message.backlight_on, + "battery_low": message.battery_low, + "check_zone": message.check_zone, + "chime": message.chime_on, + "entry_delay_off": message.entry_delay_off, + "programming_mode": message.programming_mode, + "ready": message.ready, + "zone_bypassed": message.zone_bypassed, } + self.schedule_update_ha_state() def alarm_disarm(self, code=None): """Send disarm command.""" @@ -187,7 +135,7 @@ def alarm_arm_away(self, code=None): """Send arm away command.""" self._client.arm_away( code=code, - code_arm_required=self._code_arm_required, + code_arm_required=self._attr_code_arm_required, auto_bypass=self._auto_bypass, ) @@ -195,7 +143,7 @@ def alarm_arm_home(self, code=None): """Send arm home command.""" self._client.arm_home( code=code, - code_arm_required=self._code_arm_required, + code_arm_required=self._attr_code_arm_required, auto_bypass=self._auto_bypass, ) @@ -203,7 +151,7 @@ def alarm_arm_night(self, code=None): """Send arm night command.""" self._client.arm_night( code=code, - code_arm_required=self._code_arm_required, + code_arm_required=self._attr_code_arm_required, alt_night_mode=self._alt_night_mode, auto_bypass=self._auto_bypass, ) diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 71bcc399e08c8..b5a2a4498c3c7 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -60,6 +60,8 @@ async def async_setup_entry( class AlarmDecoderBinarySensor(BinarySensorEntity): """Representation of an AlarmDecoder binary sensor.""" + _attr_should_poll = False + def __init__( self, zone_number, @@ -73,13 +75,15 @@ def __init__( """Initialize the binary_sensor.""" self._zone_number = int(zone_number) self._zone_type = zone_type - self._state = None - self._name = zone_name + self._attr_name = zone_name self._rfid = zone_rfid self._loop = zone_loop - self._rfstate = None self._relay_addr = relay_addr self._relay_chan = relay_chan + self._attr_device_class = zone_type + self._attr_extra_state_attributes = { + CONF_ZONE_NUMBER: self._zone_number, + } async def async_added_to_hass(self): """Register callbacks.""" @@ -107,59 +111,35 @@ async def async_added_to_hass(self): ) ) - @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attr = {CONF_ZONE_NUMBER: self._zone_number} - if self._rfid and self._rfstate is not None: - attr[ATTR_RF_BIT0] = bool(self._rfstate & 0x01) - attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02) - attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04) - attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08) - attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10) - attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20) - attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40) - attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80) - return attr - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state == 1 - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self._state = 1 + self._attr_is_on = True self.schedule_update_ha_state() def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or (int(zone) == self._zone_number and not self._loop): - self._state = 0 + self._attr_is_on = False self.schedule_update_ha_state() def _rfx_message_callback(self, message): """Update RF state.""" if self._rfid and message and message.serial_number == self._rfid: - self._rfstate = message.value + rfstate = message.value if self._loop: - self._state = 1 if message.loop[self._loop - 1] else 0 + self._attr_is_on = bool(message.loop[self._loop - 1]) + attr = {CONF_ZONE_NUMBER: self._zone_number} + if self._rfid and rfstate is not None: + attr[ATTR_RF_BIT0] = bool(rfstate & 0x01) + attr[ATTR_RF_LOW_BAT] = bool(rfstate & 0x02) + attr[ATTR_RF_SUPERVISED] = bool(rfstate & 0x04) + attr[ATTR_RF_BIT3] = bool(rfstate & 0x08) + attr[ATTR_RF_LOOP3] = bool(rfstate & 0x10) + attr[ATTR_RF_LOOP2] = bool(rfstate & 0x20) + attr[ATTR_RF_LOOP4] = bool(rfstate & 0x40) + attr[ATTR_RF_LOOP1] = bool(rfstate & 0x80) + self._attr_extra_state_attributes = attr self.schedule_update_ha_state() def _rel_message_callback(self, message): @@ -173,5 +153,5 @@ def _rel_message_callback(self, message): message.channel, message.value, ) - self._state = message.value + self._attr_is_on = bool(message.value) self.schedule_update_ha_state() diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 1c46f50f3cb0a..45d04feb3b215 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -140,7 +140,7 @@ def test_connection(): class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): """Handle AlarmDecoder options.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize AlarmDecoder options flow.""" self.arm_options = config_entry.options.get(OPTIONS_ARM, DEFAULT_ARM_OPTIONS) self.zone_options = config_entry.options.get( @@ -299,7 +299,7 @@ def _validate_zone_input(zone_input): errors["base"] = "relay_inclusive" # The following keys must be int - for key in [CONF_ZONE_NUMBER, CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN]: + for key in (CONF_ZONE_NUMBER, CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN): if key in zone_input: try: int(zone_input[key]) @@ -328,7 +328,7 @@ def _fix_input_types(zone_input): strings and then convert them to ints. """ - for key in [CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN]: + for key in (CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN): if key in zone_input: zone_input[key] = int(zone_input[key]) diff --git a/homeassistant/components/alarmdecoder/const.py b/homeassistant/components/alarmdecoder/const.py index f1bfb66f0d468..4aba16a9cf816 100644 --- a/homeassistant/components/alarmdecoder/const.py +++ b/homeassistant/components/alarmdecoder/const.py @@ -32,7 +32,7 @@ CONF_AUTO_BYPASS: DEFAULT_AUTO_BYPASS, CONF_CODE_ARM_REQUIRED: DEFAULT_CODE_ARM_REQUIRED, } -DEFAULT_ZONE_OPTIONS = {} +DEFAULT_ZONE_OPTIONS: dict = {} DOMAIN = "alarmdecoder" diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index fa2bcca389f82..a762d698545f1 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -2,7 +2,7 @@ "domain": "alarmdecoder", "name": "AlarmDecoder", "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", - "requirements": ["adext==0.4.1"], + "requirements": ["adext==0.4.2"], "codeowners": ["@ajschmidt8"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index e3c85cb589338..16471010ee9da 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -8,7 +8,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): +) -> bool: """Set up for AlarmDecoder sensor.""" entity = AlarmDecoderSensor() @@ -19,12 +19,9 @@ async def async_setup_entry( class AlarmDecoderSensor(SensorEntity): """Representation of an AlarmDecoder keypad.""" - def __init__(self): - """Initialize the alarm panel.""" - self._display = "" - self._state = None - self._icon = "mdi:alarm-check" - self._name = "Alarm Panel Display" + _attr_icon = "mdi:alarm-check" + _attr_name = "Alarm Panel Display" + _attr_should_poll = False async def async_added_to_hass(self): """Register callbacks.""" @@ -35,26 +32,6 @@ async def async_added_to_hass(self): ) def _message_callback(self, message): - if self._display != message.text: - self._display = message.text + if self._attr_native_value != message.text: + self._attr_native_value = message.text self.schedule_update_ha_state() - - @property - def icon(self): - """Return the icon if any.""" - return self._icon - - @property - def state(self): - """Return the overall state.""" - return self._display - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """No polling needed.""" - return False diff --git a/homeassistant/components/alarmdecoder/translations/ar.json b/homeassistant/components/alarmdecoder/translations/ar.json new file mode 100644 index 0000000000000..1f49f618afbb1 --- /dev/null +++ b/homeassistant/components/alarmdecoder/translations/ar.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "protocol": { + "data": { + "device_baudrate": "\u0645\u0639\u062f\u0644 \u0633\u0631\u0639\u0629 \u0627\u0644\u0628\u062b \u0644\u0644\u062c\u0647\u0627\u0632", + "device_path": "\u0645\u0633\u0627\u0631 \u0627\u0644\u062c\u0647\u0627\u0632" + }, + "title": "\u062a\u0643\u0648\u064a\u0646 \u0625\u0639\u062f\u0627\u062f\u0627\u062a \u0627\u0644\u0627\u062a\u0635\u0627\u0644" + }, + "user": { + "data": { + "protocol": "\u0628\u0631\u0648\u062a\u0648\u0643\u0648\u0644" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "edit_select": "\u062a\u0639\u062f\u064a\u0644" + }, + "description": "\u0645\u0627 \u0627\u0644\u0630\u064a \u062a\u0631\u064a\u062f \u062a\u0639\u062f\u064a\u0644\u0647\u061f", + "title": "\u062a\u0643\u0648\u064a\u0646 AlarmDecoder" + }, + "zone_details": { + "data": { + "zone_name": "\u0627\u0633\u0645 \u0627\u0644\u0645\u0646\u0637\u0642\u0629", + "zone_type": "\u0646\u0648\u0639 \u0627\u0644\u0645\u0646\u0637\u0642\u0629" + }, + "title": "\u062a\u0643\u0648\u064a\u0646 AlarmDecoder" + }, + "zone_select": { + "data": { + "zone_number": "\u0631\u0642\u0645 \u0627\u0644\u0645\u0646\u0637\u0642\u0629" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/bg.json b/homeassistant/components/alarmdecoder/translations/bg.json new file mode 100644 index 0000000000000..b918c0c771042 --- /dev/null +++ b/homeassistant/components/alarmdecoder/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "protocol": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + }, + "user": { + "data": { + "protocol": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/de.json b/homeassistant/components/alarmdecoder/translations/de.json index aea85f49a59e8..f324772c9099a 100644 --- a/homeassistant/components/alarmdecoder/translations/de.json +++ b/homeassistant/components/alarmdecoder/translations/de.json @@ -16,40 +16,58 @@ "device_path": "Ger\u00e4tepfad", "host": "Host", "port": "Port" - } + }, + "title": "Verbindungseinstellungen konfigurieren" }, "user": { "data": { "protocol": "Protokoll" - } + }, + "title": "W\u00e4hle das AlarmDecoder-Protokoll" } } }, "options": { + "error": { + "int": "Das Feld unten muss eine ganze Zahl sein.", + "loop_range": "RF Loop muss eine ganze Zahl zwischen 1 und 4 sein.", + "loop_rfid": "RF Loop kann nicht ohne RF Serial verwendet werden.", + "relay_inclusive": "Relaisadresse und Relaiskanal sind abh\u00e4ngig voneinander und m\u00fcssen zusammen aufgenommen werden." + }, "step": { "arm_settings": { "data": { - "alt_night_mode": "Alternativer Nachtmodus" - } + "alt_night_mode": "Alternativer Nachtmodus", + "auto_bypass": "Automatischer Bypass bei Scharfschaltung", + "code_arm_required": "Code f\u00fcr Scharfschaltung erforderlich" + }, + "title": "AlarmDecoder konfigurieren" }, "init": { "data": { "edit_select": "Bearbeiten" }, - "description": "Was m\u00f6chtest du bearbeiten?" + "description": "Was m\u00f6chtest du bearbeiten?", + "title": "AlarmDecoder konfigurieren" }, "zone_details": { "data": { + "zone_loop": "RF Loop", "zone_name": "Zonenname", "zone_relayaddr": "Relais-Adresse", + "zone_relaychan": "Relaiskanal", + "zone_rfid": "RF Serial", "zone_type": "Zonentyp" - } + }, + "description": "Gib Details f\u00fcr Zone {zone_number} ein. Um Zone {zone_number} zu l\u00f6schen, lass den Zonennamen leer.", + "title": "AlarmDecoder konfigurieren" }, "zone_select": { "data": { "zone_number": "Zonennummer" }, - "description": "Gib die die Zonennummer ein, die du hinzuf\u00fcgen, bearbeiten oder entfernen m\u00f6chtest." + "description": "Gib die die Zonennummer ein, die du hinzuf\u00fcgen, bearbeiten oder entfernen m\u00f6chtest.", + "title": "AlarmDecoder konfigurieren" } } } diff --git a/homeassistant/components/alarmdecoder/translations/es-419.json b/homeassistant/components/alarmdecoder/translations/es-419.json index 2152084ea5658..c4cfbdf82efea 100644 --- a/homeassistant/components/alarmdecoder/translations/es-419.json +++ b/homeassistant/components/alarmdecoder/translations/es-419.json @@ -20,6 +20,10 @@ } }, "options": { + "error": { + "int": "El campo siguiente debe ser un n\u00famero entero.", + "loop_range": "El bucle de RF debe ser un n\u00famero entero entre 1 y 4." + }, "step": { "arm_settings": { "data": { @@ -30,6 +34,17 @@ "data": { "edit_select": "Editar" } + }, + "zone_details": { + "data": { + "zone_name": "Nombre de zona", + "zone_rfid": "Serie RF" + } + }, + "zone_select": { + "data": { + "zone_number": "N\u00famero de zona" + } } } } diff --git a/homeassistant/components/alarmdecoder/translations/he.json b/homeassistant/components/alarmdecoder/translations/he.json new file mode 100644 index 0000000000000..db754768f486b --- /dev/null +++ b/homeassistant/components/alarmdecoder/translations/he.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "protocol": { + "data": { + "device_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + }, + "user": { + "data": { + "protocol": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc" + } + } + } + }, + "options": { + "error": { + "relay_inclusive": "\u05db\u05ea\u05d5\u05d1\u05ea \u05de\u05de\u05e1\u05e8 \u05d5\u05e2\u05e8\u05d5\u05e5 \u05de\u05de\u05e1\u05e8 \u05d4\u05dd \u05ea\u05dc\u05d5\u05d9\u05d9 \u05e7\u05d5\u05d3 \u05d5\u05d9\u05e9 \u05dc\u05db\u05dc\u05d5\u05dc \u05d0\u05d5\u05ea\u05dd \u05d9\u05d7\u05d3." + }, + "step": { + "init": { + "data": { + "edit_select": "\u05e2\u05e8\u05d9\u05db\u05d4" + } + }, + "zone_details": { + "data": { + "zone_relaychan": "\u05e2\u05e8\u05d5\u05e5 \u05de\u05de\u05e1\u05e8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/hu.json b/homeassistant/components/alarmdecoder/translations/hu.json index 8c80adcb3c0f5..3c9781672f485 100644 --- a/homeassistant/components/alarmdecoder/translations/hu.json +++ b/homeassistant/components/alarmdecoder/translations/hu.json @@ -12,28 +12,62 @@ "step": { "protocol": { "data": { - "host": "Hoszt", + "device_baudrate": "Eszk\u00f6z \u00e1tviteli sebess\u00e9ge", + "device_path": "Eszk\u00f6z el\u00e9r\u00e9si \u00fatja", + "host": "C\u00edm", "port": "Port" - } + }, + "title": "Konfigur\u00e1lja a csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sokat" }, "user": { "data": { "protocol": "Protokoll" - } + }, + "title": "V\u00e1lassza ki a AlarmDecoder protokollt" } } }, "options": { + "error": { + "int": "Az al\u00e1bbi mez\u0151nek eg\u00e9sz sz\u00e1mnak kell lennie.", + "loop_range": "Az RF hurok eg\u00e9sz sz\u00e1m\u00e1nak 1 \u00e9s 4 k\u00f6z\u00f6tt kell lennie.", + "loop_rfid": "Az RF hurok nem haszn\u00e1lhat\u00f3 RF sorozat n\u00e9lk\u00fcl.", + "relay_inclusive": "A rel\u00e9c\u00edm \u00e9s a rel\u00e9csatorna egym\u00e1st\u00f3l f\u00fcgg, \u00e9s egy\u00fctt kell felt\u00fcntetni." + }, "step": { + "arm_settings": { + "data": { + "alt_night_mode": "Alternat\u00edv \u00e9jszakai m\u00f3d", + "auto_bypass": "Automatikus egy\u00e9ni \u00e9les\u00edt\u00e9s", + "code_arm_required": "Az \u00e9les\u00edt\u00e9shez sz\u00fcks\u00e9ges k\u00f3d" + }, + "title": "Konfigur\u00e1lja az AlarmDecodert" + }, "init": { "data": { "edit_select": "Szerkeszt\u00e9s" - } + }, + "description": "Mit szeretn\u00e9l szerkeszteni?", + "title": "Konfigur\u00e1lja az AlarmDecodert" }, "zone_details": { "data": { - "zone_name": "Z\u00f3na neve" - } + "zone_loop": "RF hurok", + "zone_name": "Z\u00f3na neve", + "zone_relayaddr": "Rel\u00e9 c\u00edm", + "zone_relaychan": "Rel\u00e9 csatorna", + "zone_rfid": "RF soros", + "zone_type": "Z\u00f3na t\u00edpusa" + }, + "description": "Adja meg a {zone_number} z\u00f3na adatait. {zone_number} z\u00f3na t\u00f6rl\u00e9s\u00e9hez hagyja \u00fcresen a Z\u00f3na neve elemet.", + "title": "Konfigur\u00e1lja az AlarmDecodert" + }, + "zone_select": { + "data": { + "zone_number": "Z\u00f3na sz\u00e1ma" + }, + "description": "\u00cdrja be a hozz\u00e1adni, szerkeszteni vagy elt\u00e1vol\u00edtani k\u00edv\u00e1nt z\u00f3nasz\u00e1mot.", + "title": "Konfigur\u00e1lja az AlarmDecodert" } } } diff --git a/homeassistant/components/alarmdecoder/translations/it.json b/homeassistant/components/alarmdecoder/translations/it.json index 70be8d733fea3..83de29ca19068 100644 --- a/homeassistant/components/alarmdecoder/translations/it.json +++ b/homeassistant/components/alarmdecoder/translations/it.json @@ -17,13 +17,13 @@ "host": "Host", "port": "Porta" }, - "title": "Configurare le impostazioni di connessione" + "title": "Configura le impostazioni di connessione" }, "user": { "data": { "protocol": "Protocollo" }, - "title": "Scegliere il protocollo AlarmDecoder" + "title": "Scegli il protocollo AlarmDecoder" } } }, @@ -41,14 +41,14 @@ "auto_bypass": "Bypass automatico all'attivazione", "code_arm_required": "Codice richiesto per l'attivazione" }, - "title": "Configurare AlarmDecoder" + "title": "Configura AlarmDecoder" }, "init": { "data": { "edit_select": "Modifica" }, "description": "Cosa vorresti modificare?", - "title": "Configurare AlarmDecoder" + "title": "Configura AlarmDecoder" }, "zone_details": { "data": { @@ -59,15 +59,15 @@ "zone_rfid": "Seriale RF", "zone_type": "Tipo di zona" }, - "description": "Immettere i dettagli per la zona {zone_number}. Per eliminare la zona {zone_number}, lasciare vuoto il campo Nome zona.", - "title": "Configurare AlarmDecoder" + "description": "Immetti i dettagli per la zona {zone_number}. Per eliminare la zona {zone_number}, lascia vuoto il campo Nome zona.", + "title": "Configura AlarmDecoder" }, "zone_select": { "data": { "zone_number": "Numero di zona" }, - "description": "Immettere il numero di zona che si desidera aggiungere, modificare o rimuovere.", - "title": "Configurare AlarmDecoder" + "description": "Immettere il numero di zona che desideri aggiungere, modificare o rimuovere.", + "title": "Configura AlarmDecoder" } } } diff --git a/homeassistant/components/alarmdecoder/translations/ja.json b/homeassistant/components/alarmdecoder/translations/ja.json new file mode 100644 index 0000000000000..461c3bcd42d77 --- /dev/null +++ b/homeassistant/components/alarmdecoder/translations/ja.json @@ -0,0 +1,74 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "create_entry": { + "default": "AlarmDecoder\u3068\u306e\u63a5\u7d9a\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "protocol": { + "data": { + "device_baudrate": "\u30c7\u30d0\u30a4\u30b9\u306e\u30dc\u30fc\u30ec\u30fc\u30c8", + "device_path": "\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u63a5\u7d9a\u8a2d\u5b9a\u306e\u69cb\u6210" + }, + "user": { + "data": { + "protocol": "\u30d7\u30ed\u30c8\u30b3\u30eb" + }, + "title": "AlarmDecoder\u30d7\u30ed\u30c8\u30b3\u30eb\u3092\u9078\u629e" + } + } + }, + "options": { + "error": { + "int": "\u4ee5\u4e0b\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306f\u6574\u6570\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "loop_range": "RF\u30eb\u30fc\u30d7\u306f1\u304b\u30894\u307e\u3067\u306e\u6574\u6570\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002", + "loop_rfid": "RF\u30eb\u30fc\u30d7\u306fRF\u30b7\u30ea\u30a2\u30eb\u306a\u3057\u3067\u306f\u4f7f\u7528\u3067\u304d\u307e\u305b\u3093\u3002", + "relay_inclusive": "\u30ea\u30ec\u30fc\u30a2\u30c9\u30ec\u30b9\u3068\u30ea\u30ec\u30fc\u30c1\u30e3\u30cd\u30eb\u306f\u76f8\u4e92\u306b\u4f9d\u5b58\u3057\u3066\u3044\u308b\u305f\u3081\u3001\u4e00\u7dd2\u306b\u542b\u3081\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "arm_settings": { + "data": { + "alt_night_mode": "\u4ee3\u66ff\u30ca\u30a4\u30c8\u30e2\u30fc\u30c9", + "auto_bypass": "\u8b66\u6212\u306e\u30aa\u30fc\u30c8\u30d0\u30a4\u30d1\u30b9", + "code_arm_required": "\u8b66\u6212\u306b\u5fc5\u8981\u306a\u30b3\u30fc\u30c9" + }, + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + }, + "init": { + "data": { + "edit_select": "\u7de8\u96c6" + }, + "description": "\u4f55\u3092\u7de8\u96c6\u3057\u307e\u3059\u304b\uff1f", + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + }, + "zone_details": { + "data": { + "zone_loop": "RF\u30eb\u30fc\u30d7", + "zone_name": "\u30be\u30fc\u30f3\u540d", + "zone_relayaddr": "\u30ea\u30ec\u30fc\u30a2\u30c9\u30ec\u30b9", + "zone_relaychan": "\u30ea\u30ec\u30fc\u30c1\u30e3\u30cd\u30eb", + "zone_rfid": "RF\u30b7\u30ea\u30a2\u30eb", + "zone_type": "\u30be\u30fc\u30f3\u306e\u7a2e\u985e" + }, + "description": "{zone_number} \u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u307e\u3059\u3002 {zone_number} \u3092\u524a\u9664\u3059\u308b\u306b\u306f\u3001\u30be\u30fc\u30f3\u540d\u3092\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059\u3002", + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + }, + "zone_select": { + "data": { + "zone_number": "\u30be\u30fc\u30f3\u756a\u53f7" + }, + "description": "\u8ffd\u52a0\u3001\u7de8\u96c6\u3001\u307e\u305f\u306f\u524a\u9664\u3059\u308b\u30be\u30fc\u30f3\u756a\u53f7\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/nl.json b/homeassistant/components/alarmdecoder/translations/nl.json index 1ea9cb98b56a6..cbab651707a9d 100644 --- a/homeassistant/components/alarmdecoder/translations/nl.json +++ b/homeassistant/components/alarmdecoder/translations/nl.json @@ -32,7 +32,7 @@ "int": "Het onderstaande veld moet een geheel getal zijn.", "loop_range": "RF Lus moet een geheel getal zijn tussen 1 en 4.", "loop_rfid": "RF Lus kan niet worden gebruikt zonder RF Serieel.", - "relay_inclusive": "Het relais-adres en het relais-kanaal zijn codeafhankelijk en moeten samen worden opgenomen." + "relay_inclusive": "Het relaisadres en het relaiskanaal zijn onderling afhankelijk en moeten samen worden opgenomen." }, "step": { "arm_settings": { @@ -53,18 +53,18 @@ "zone_details": { "data": { "zone_loop": "RF Lus", - "zone_name": "Zone naam", - "zone_relayaddr": "Relais Adres", - "zone_relaychan": "Relais Kanaal", + "zone_name": "Zonenaam", + "zone_relayaddr": "Relaisadres", + "zone_relaychan": "Relaiskanaal", "zone_rfid": "RF Serieel", - "zone_type": "Zone Type" + "zone_type": "Zonetype" }, - "description": "Voer details in voor zone {zone_number}. Om zone {zone_number} te verwijderen, laat u Zone Name leeg.", + "description": "Voer details in voor zone {zone_number}. Om zone {zone_number} te verwijderen, laat u Zonenaam leeg.", "title": "Configureer AlarmDecoder" }, "zone_select": { "data": { - "zone_number": "Zone nummer" + "zone_number": "Zonenummer" }, "description": "Voer het zone nummer in dat u wilt toevoegen, bewerken of verwijderen.", "title": "Configureer AlarmDecoder" diff --git a/homeassistant/components/alarmdecoder/translations/tr.json b/homeassistant/components/alarmdecoder/translations/tr.json index 276b733b31fd5..244f962a9f6b8 100644 --- a/homeassistant/components/alarmdecoder/translations/tr.json +++ b/homeassistant/components/alarmdecoder/translations/tr.json @@ -3,44 +3,70 @@ "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, + "create_entry": { + "default": "AlarmDecoder'a ba\u015far\u0131yla ba\u011fland\u0131." + }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { "protocol": { "data": { - "host": "Ana Bilgisayar", + "device_baudrate": "Cihaz Baud H\u0131z\u0131", + "device_path": "Cihaz Yolu", + "host": "Sunucu", "port": "Port" - } + }, + "title": "Ba\u011flant\u0131 ayarlar\u0131n\u0131 yap\u0131land\u0131r\u0131n" + }, + "user": { + "data": { + "protocol": "Protokol" + }, + "title": "AlarmDecoder Protokol\u00fcn\u00fc Se\u00e7in" } } }, "options": { "error": { + "int": "A\u015fa\u011f\u0131daki alan bir tamsay\u0131 olmal\u0131d\u0131r.", + "loop_range": "RF D\u00f6ng\u00fcs\u00fc 1 ile 4 aras\u0131nda bir tam say\u0131 olmal\u0131d\u0131r.", + "loop_rfid": "RF D\u00f6ng\u00fcs\u00fc, RF Seri olmadan kullan\u0131lamaz.", "relay_inclusive": "R\u00f6le Adresi ve R\u00f6le Kanal\u0131 birbirine ba\u011fl\u0131d\u0131r ve birlikte eklenmelidir." }, "step": { "arm_settings": { "data": { - "alt_night_mode": "Alternatif Gece Modu" - } + "alt_night_mode": "Alternatif Gece Modu", + "auto_bypass": "Alarm a\u00e7\u0131kken otomatik Atlatma", + "code_arm_required": "Kurmak i\u00e7in Gerekli Kod" + }, + "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" }, "init": { "data": { "edit_select": "D\u00fczenle" - } + }, + "description": "Ne d\u00fczenlemek istersiniz?", + "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" }, "zone_details": { "data": { + "zone_loop": "RF D\u00f6ng\u00fcs\u00fc", "zone_name": "B\u00f6lge Ad\u0131", "zone_relayaddr": "R\u00f6le Adresi", - "zone_relaychan": "R\u00f6le Kanal\u0131" - } + "zone_relaychan": "R\u00f6le Kanal\u0131", + "zone_rfid": "RF Id", + "zone_type": "B\u00f6lge Tipi" + }, + "description": "{zone_number} b\u00f6lgesi i\u00e7in ayr\u0131nt\u0131lar\u0131 girin. {zone_number} b\u00f6lgesini silmek i\u00e7in B\u00f6lge Ad\u0131n\u0131 bo\u015f b\u0131rak\u0131n.", + "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" }, "zone_select": { "data": { "zone_number": "B\u00f6lge Numaras\u0131" }, + "description": "Eklemek, d\u00fczenlemek veya kald\u0131rmak istedi\u011finiz b\u00f6lge numaras\u0131n\u0131 girin.", "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" } } diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 73bea193394c2..e5ee7ee691146 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -48,7 +48,12 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, - vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), + vol.Required(CONF_REPEAT): vol.All( + cv.ensure_list, + [vol.Coerce(float)], + # Minimum delay is 1 second = 0.016 minutes + [vol.Range(min=0.016)], + ), vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, vol.Optional(CONF_ALERT_MESSAGE): cv.template, @@ -152,6 +157,8 @@ async def async_handle_alert_service(service_call): class Alert(ToggleEntity): """Representation of an alert.""" + _attr_should_poll = False + def __init__( self, hass, @@ -170,7 +177,7 @@ def __init__( ): """Initialize the alert.""" self.hass = hass - self._name = name + self._attr_name = name self._alert_state = state self._skip_first = skip_first self._data = data @@ -204,17 +211,7 @@ def __init__( ) @property - def name(self): - """Return the name of the alert.""" - return self._name - - @property - def should_poll(self): - """Home Assistant need not poll these entities.""" - return False - - @property - def state(self): + def state(self): # pylint: disable=overridden-final-method """Return the alert status.""" if self._firing: if self._ack: @@ -224,8 +221,7 @@ def state(self): async def watched_entity_change(self, ev): """Determine if the alert should start or stop.""" - to_state = ev.data.get("new_state") - if to_state is None: + if (to_state := ev.data.get("new_state")) is None: return _LOGGER.debug("Watched entity (%s) has changed", ev.data.get("entity_id")) if to_state.state == self._alert_state and not self._firing: @@ -235,7 +231,7 @@ async def watched_entity_change(self, ev): async def begin_alerting(self): """Begin the alert procedures.""" - _LOGGER.debug("Beginning Alert: %s", self._name) + _LOGGER.debug("Beginning Alert: %s", self._attr_name) self._ack = False self._firing = True self._next_delay = 0 @@ -249,7 +245,7 @@ async def begin_alerting(self): async def end_alerting(self): """End the alert procedures.""" - _LOGGER.debug("Ending Alert: %s", self._name) + _LOGGER.debug("Ending Alert: %s", self._attr_name) self._cancel() self._ack = False self._firing = False @@ -272,13 +268,13 @@ async def _notify(self, *args): return if not self._ack: - _LOGGER.info("Alerting: %s", self._name) + _LOGGER.info("Alerting: %s", self._attr_name) self._send_done_message = True if self._message_template is not None: message = self._message_template.async_render(parse_result=False) else: - message = self._name + message = self._attr_name await self._send_notification_message(message) await self._schedule_notify() @@ -314,13 +310,13 @@ async def _send_notification_message(self, message): async def async_turn_on(self, **kwargs): """Async Unacknowledge alert.""" - _LOGGER.debug("Reset Alert: %s", self._name) + _LOGGER.debug("Reset Alert: %s", self._attr_name) self._ack = False self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Async Acknowledge alert.""" - _LOGGER.debug("Acknowledged Alert: %s", self._name) + _LOGGER.debug("Acknowledged Alert: %s", self._attr_name) self._ack = True self.async_write_ha_state() diff --git a/homeassistant/components/alert/reproduce_state.py b/homeassistant/components/alert/reproduce_state.py index 9c8cbd19810f8..49658ab249531 100644 --- a/homeassistant/components/alert/reproduce_state.py +++ b/homeassistant/components/alert/reproduce_state.py @@ -30,9 +30,7 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - cur_state = hass.states.get(state.entity_id) - - if cur_state is None: + if (cur_state := hass.states.get(state.entity_id)) is None: _LOGGER.warning("Unable to find entity %s", state.entity_id) return diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml index 5800d642b9311..3242a9cedb4f0 100644 --- a/homeassistant/components/alert/services.yaml +++ b/homeassistant/components/alert/services.yaml @@ -2,13 +2,19 @@ toggle: name: Toggle description: Toggle alert's notifications. target: + entity: + domain: alert turn_off: name: Turn off description: Silence alert's notifications. target: + entity: + domain: alert turn_on: name: Turn on description: Reset alert's notifications. target: + entity: + domain: alert diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 433b292960279..d888b91a39e02 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -1,13 +1,14 @@ """Support for Alexa skill auth.""" import asyncio from datetime import timedelta +from http import HTTPStatus import json import logging import aiohttp import async_timeout -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, HTTP_OK +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.util import dt @@ -104,7 +105,7 @@ async def _async_request_new_token(self, lwa_params): try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await session.post( LWA_TOKEN_URI, headers=LWA_HEADERS, @@ -119,7 +120,7 @@ async def _async_request_new_token(self, lwa_params): _LOGGER.debug("LWA response header: %s", response.headers) _LOGGER.debug("LWA response status: %s", response.status) - if response.status != HTTP_OK: + if response.status != HTTPStatus.OK: _LOGGER.error("Error calling LWA to get auth token") return None diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 69acf95e20733..0182d2aa08518 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ SUPPORT_ALARM_ARM_NIGHT, ) import homeassistant.components.climate.const as climate +from homeassistant.components.lock import STATE_LOCKING, STATE_UNLOCKING import homeassistant.components.media_player.const as media_player from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, @@ -47,6 +48,7 @@ API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, DATE_FORMAT, + PRESET_MODE_NA, Inputs, ) from .errors import UnsupportedProperty @@ -73,7 +75,7 @@ class AlexaCapability: supported_locales = {"en-US"} - def __init__(self, entity: State, instance: str | None = None): + def __init__(self, entity: State, instance: str | None = None) -> None: """Initialize an Alexa capability.""" self.entity = entity self.instance = instance @@ -98,7 +100,7 @@ def properties_retrievable() -> bool: return False @staticmethod - def properties_non_controllable() -> bool: + def properties_non_controllable() -> bool | None: """Return True if non controllable.""" return None @@ -180,8 +182,7 @@ def serialize_discovery(self): """Serialize according to the Discovery API.""" result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} - instance = self.instance - if instance is not None: + if (instance := self.instance) is not None: result["instance"] = instance properties_supported = self.properties_supported() @@ -262,8 +263,7 @@ def serialize_properties(self): "timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT), "uncertaintyInMilliseconds": 0, } - instance = self.instance - if instance is not None: + if (instance := self.instance) is not None: result["instance"] = instance yield result @@ -390,6 +390,8 @@ def get_property(self, name): if self.entity.domain == climate.DOMAIN: is_on = self.entity.state != climate.HVAC_MODE_OFF + elif self.entity.domain == fan.DOMAIN: + is_on = self.entity.state == fan.STATE_ON elif self.entity.domain == vacuum.DOMAIN: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: @@ -446,9 +448,11 @@ def get_property(self, name): if name != "lockState": raise UnsupportedProperty(name) - if self.entity.state == STATE_LOCKED: + # If its unlocking its still locked and not unlocked yet + if self.entity.state in (STATE_UNLOCKING, STATE_LOCKED): return "LOCKED" - if self.entity.state == STATE_UNLOCKED: + # If its locking its still unlocked and not locked yet + if self.entity.state in (STATE_LOCKING, STATE_UNLOCKED): return "UNLOCKED" return "JAMMED" @@ -691,6 +695,7 @@ class AlexaSpeaker(AlexaCapability): "en-US", "es-ES", "es-MX", + "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", "ja-JP", } @@ -748,6 +753,7 @@ class AlexaStepSpeaker(AlexaCapability): "en-IN", "en-US", "es-ES", + "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", } @@ -1092,8 +1098,7 @@ def configuration(self): supported_modes = [] hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) for mode in hvac_modes: - thermostat_mode = API_THERMOSTAT_MODES.get(mode) - if thermostat_mode: + if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) preset_modes = self.entity.attributes.get(climate.ATTR_PRESET_MODES) @@ -1152,11 +1157,6 @@ def get_property(self, name): if name != "powerLevel": raise UnsupportedProperty(name) - if self.entity.domain == fan.DOMAIN: - return self.entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - - return None - class AlexaSecurityPanelController(AlexaCapability): """Implements Alexa.SecurityPanelController. @@ -1304,6 +1304,12 @@ def get_property(self, name): if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN): return f"{fan.ATTR_DIRECTION}.{mode}" + # Fan preset_mode + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None) + if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None): + return f"{fan.ATTR_PRESET_MODE}.{mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1342,6 +1348,24 @@ def capability_resources(self): ) return self._resource.serialize_capability_resources() + # Fan preset_mode + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + self._resource = AlexaModeResource( + [AlexaGlobalCatalog.SETTING_PRESET], False + ) + preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES, []) + for preset_mode in preset_modes: + self._resource.add_mode( + f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode] + ) + # Fans with a single preset_mode completely break Alexa discovery, add a + # fake preset (see issue #53832). + if len(preset_modes) == 1: + self._resource.add_mode( + f"{fan.ATTR_PRESET_MODE}.{PRESET_MODE_NA}", [PRESET_MODE_NA] + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( @@ -1465,16 +1489,6 @@ def get_property(self, name): if self.entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): return None - # Fan Speed - if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes.get(fan.ATTR_SPEED_LIST) - speed = self.entity.attributes.get(fan.ATTR_SPEED) - if speed_list is not None and speed is not None: - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index - # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) @@ -1483,6 +1497,13 @@ def get_property(self, name): if self.instance == f"{cover.DOMAIN}.tilt": return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) + # Fan speed percentage + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported and fan.SUPPORT_SET_SPEED: + return self.entity.attributes.get(fan.ATTR_PERCENTAGE) + return 100 if self.entity.state == fan.STATE_ON else 0 + # Input Number Value if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": return float(self.entity.state) @@ -1509,28 +1530,18 @@ def configuration(self): def capability_resources(self): """Return capabilityResources object.""" - # Fan Speed Resources - if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes[fan.ATTR_SPEED_LIST] - max_value = len(speed_list) - 1 + # Fan Speed Percentage Resources + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = self.entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) self._resource = AlexaPresetResource( - labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + labels=["Percentage", AlexaGlobalCatalog.SETTING_FAN_SPEED], min_value=0, - max_value=max_value, - precision=1, + max_value=100, + # precision must be a divider of 100 and must be an integer; set step + # size to 1 for a consistent behavior except for on/off fans + precision=1 if percentage_step else 100, + unit=AlexaGlobalCatalog.UNIT_PERCENT, ) - for index, speed in enumerate(speed_list): - labels = [] - if isinstance(speed, str): - labels.append(speed.replace("_", " ")) - if index == 1: - labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) - if index == max_value: - labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) - - if len(labels) > 0: - self._resource.add_preset(value=index, labels=labels) - return self._resource.serialize_capability_resources() # Cover Position Resources @@ -1643,6 +1654,20 @@ def semantics(self): ) return self._semantics.serialize_semantics() + # Fan Speed Percentage + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] + self._semantics = AlexaSemantics() + + self._semantics.add_action_to_directive( + lower_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + raise_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None @@ -1920,6 +1945,10 @@ def properties_supported(self): """ return [{"name": "mode"}] + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + def get_property(self, name): """Read and return a property.""" if name != "mode": diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index cc5c604dc8c55..739ce6be6a3b2 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -64,8 +64,7 @@ async def async_enable_proactive_mode(self): async def async_disable_proactive_mode(self): """Disable proactive mode.""" - unsub_func = await self._unsub_proactive_report - if unsub_func: + if unsub_func := await self._unsub_proactive_report: unsub_func() self._unsub_proactive_report = None diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index de8a4a6fdc430..0532c85dac18b 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -78,6 +78,9 @@ API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"} API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} +# AlexaModeController does not like a single mode for the fan preset, we add PRESET_MODE_NA if a fan has only one preset_mode +PRESET_MODE_NA = "-" + class Cause: """Possible causes for property changes. diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index c6ae05e9d6f8f..1ab24927bcb2c 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -9,12 +9,14 @@ alert, automation, binary_sensor, + button, camera, cover, fan, group, image_processing, input_boolean, + input_button, input_number, light, lock, @@ -40,6 +42,7 @@ ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import network +from homeassistant.helpers.entity import entity_sources from homeassistant.util.decorator import Registry from .capabilities import ( @@ -59,11 +62,9 @@ AlexaLockController, AlexaModeController, AlexaMotionSensor, - AlexaPercentageController, AlexaPlaybackController, AlexaPlaybackStateReporter, AlexaPowerController, - AlexaPowerLevelController, AlexaRangeController, AlexaSceneController, AlexaSecurityPanelController, @@ -254,7 +255,9 @@ class AlexaEntity: The API handlers should manipulate entities only through this interface. """ - def __init__(self, hass: HomeAssistant, config: AbstractConfig, entity: State): + def __init__( + self, hass: HomeAssistant, config: AbstractConfig, entity: State + ) -> None: """Initialize Alexa Entity.""" self.hass = hass self.config = config @@ -409,7 +412,7 @@ class SwitchCapabilities(AlexaEntity): def default_display_categories(self): """Return the display categories for this entity.""" device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) - if device_class == switch.DEVICE_CLASS_OUTLET: + if device_class == switch.SwitchDeviceClass.OUTLET: return [DisplayCategory.SMARTPLUG] return [DisplayCategory.SWITCH] @@ -423,6 +426,23 @@ def interfaces(self): ] +@ENTITY_ADAPTERS.register(button.DOMAIN) +@ENTITY_ADAPTERS.register(input_button.DOMAIN) +class ButtonCapabilities(AlexaEntity): + """Class to represent Button capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaSceneController(self.entity, supports_deactivation=False), + Alexa(self.hass), + ] + + @ENTITY_ADAPTERS.register(climate.DOMAIN) class ClimateCapabilities(AlexaEntity): """Class to represent Climate capabilities.""" @@ -452,20 +472,20 @@ class CoverCapabilities(AlexaEntity): def default_display_categories(self): """Return the display categories for this entity.""" device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) - if device_class in (cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_GATE): + if device_class in (cover.CoverDeviceClass.GARAGE, cover.CoverDeviceClass.GATE): return [DisplayCategory.GARAGE_DOOR] - if device_class == cover.DEVICE_CLASS_DOOR: + if device_class == cover.CoverDeviceClass.DOOR: return [DisplayCategory.DOOR] if device_class in ( - cover.DEVICE_CLASS_BLIND, - cover.DEVICE_CLASS_SHADE, - cover.DEVICE_CLASS_CURTAIN, + cover.CoverDeviceClass.BLIND, + cover.CoverDeviceClass.SHADE, + cover.CoverDeviceClass.CURTAIN, ): return [DisplayCategory.INTERIOR_BLIND] if device_class in ( - cover.DEVICE_CLASS_WINDOW, - cover.DEVICE_CLASS_AWNING, - cover.DEVICE_CLASS_SHUTTER, + cover.CoverDeviceClass.WINDOW, + cover.CoverDeviceClass.AWNING, + cover.CoverDeviceClass.SHUTTER, ): return [DisplayCategory.EXTERIOR_BLIND] @@ -474,7 +494,10 @@ def default_display_categories(self): def interfaces(self): """Yield the supported interfaces.""" device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) - if device_class not in (cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_GATE): + if device_class not in ( + cover.CoverDeviceClass.GARAGE, + cover.CoverDeviceClass.GATE, + ): yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -527,22 +550,32 @@ def default_display_categories(self): def interfaces(self): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - + force_range_controller = True supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & fan.SUPPORT_SET_SPEED: - yield AlexaPercentageController(self.entity) - yield AlexaPowerLevelController(self.entity) - yield AlexaRangeController( - self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}" - ) if supported & fan.SUPPORT_OSCILLATE: yield AlexaToggleController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) + force_range_controller = False + if supported & fan.SUPPORT_PRESET_MODE: + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" + ) + force_range_controller = False if supported & fan.SUPPORT_DIRECTION: yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" ) + force_range_controller = False + + # AlexaRangeController controls the Fan Speed Percentage. + # For fans which only support on/off, no controller is added. This makes the + # fan impossible to turn on or off through Alexa, most likely due to a bug in Alexa. + # As a workaround, we add a range controller which can only be set to 0% or 100%. + if force_range_controller or supported & fan.SUPPORT_SET_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}" + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.hass) @@ -572,7 +605,7 @@ class MediaPlayerCapabilities(AlexaEntity): def default_display_categories(self): """Return the display categories for this entity.""" device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) - if device_class == media_player.DEVICE_CLASS_SPEAKER: + if device_class == media_player.MediaPlayerDeviceClass.SPEAKER: return [DisplayCategory.SPEAKER] return [DisplayCategory.TV] @@ -613,8 +646,14 @@ def interfaces(self): if supported & media_player.const.SUPPORT_PLAY_MEDIA: yield AlexaChannelController(self.entity) - if supported & media_player.const.SUPPORT_SELECT_SOUND_MODE: - inputs = AlexaInputController.get_valid_inputs( + # AlexaEqualizerController is disabled for denonavr + # since it blocks alexa from discovering any devices. + domain = entity_sources(self.hass).get(self.entity_id, {}).get("domain") + if ( + supported & media_player.const.SUPPORT_SELECT_SOUND_MODE + and domain != "denonavr" + ): + inputs = AlexaEqualizerController.get_valid_inputs( self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST, []) ) if len(inputs) > 0: @@ -729,17 +768,20 @@ def get_type(self): """Return the type of binary sensor.""" attrs = self.entity.attributes if attrs.get(ATTR_DEVICE_CLASS) in ( - binary_sensor.DEVICE_CLASS_DOOR, - binary_sensor.DEVICE_CLASS_GARAGE_DOOR, - binary_sensor.DEVICE_CLASS_OPENING, - binary_sensor.DEVICE_CLASS_WINDOW, + binary_sensor.BinarySensorDeviceClass.DOOR, + binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, + binary_sensor.BinarySensorDeviceClass.OPENING, + binary_sensor.BinarySensorDeviceClass.WINDOW, ): return self.TYPE_CONTACT - if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_MOTION: + if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.BinarySensorDeviceClass.MOTION: return self.TYPE_MOTION - if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_PRESENCE: + if ( + attrs.get(ATTR_DEVICE_CLASS) + == binary_sensor.BinarySensorDeviceClass.PRESENCE + ): return self.TYPE_PRESENCE diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 29643bacc53e5..a6adc488f7528 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -1,4 +1,6 @@ """Alexa related errors.""" +from __future__ import annotations + from homeassistant.exceptions import HomeAssistantError from .const import API_TEMP_UNITS @@ -22,8 +24,8 @@ class AlexaError(Exception): A handler can raise subclasses of this to return an error to the request. """ - namespace = None - error_type = None + namespace: str | None = None + error_type: str | None = None def __init__(self, error_message, payload=None): """Initialize an alexa error.""" diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 50463810bbfb7..1521afcae5a27 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,11 +1,12 @@ """Support for Alexa skill service end point.""" import copy import hmac +from http import HTTPStatus import logging import uuid from homeassistant.components import http -from homeassistant.const import CONF_PASSWORD, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED +from homeassistant.const import CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers import template import homeassistant.util.dt as dt_util @@ -58,7 +59,7 @@ def get(self, request, briefing_id): if request.query.get(API_PASSWORD) is None: err = "No password provided for Alexa flash briefing: %s" _LOGGER.error(err, briefing_id) - return b"", HTTP_UNAUTHORIZED + return b"", HTTPStatus.UNAUTHORIZED if not hmac.compare_digest( request.query[API_PASSWORD].encode("utf-8"), @@ -66,12 +67,12 @@ def get(self, request, briefing_id): ): err = "Wrong password for Alexa flash briefing: %s" _LOGGER.error(err, briefing_id) - return b"", HTTP_UNAUTHORIZED + return b"", HTTPStatus.UNAUTHORIZED if not isinstance(self.flash_briefings.get(briefing_id), list): err = "No configured Alexa flash briefing was found for: %s" _LOGGER.error(err, briefing_id) - return b"", HTTP_NOT_FOUND + return b"", HTTPStatus.NOT_FOUND briefing = [] @@ -93,8 +94,7 @@ def get(self, request, briefing_id): else: output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) - uid = item.get(CONF_UID) - if uid is None: + if (uid := item.get(CONF_UID)) is None: uid = str(uuid.uuid4()) output[ATTR_UID] = uid diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index da0011f817adf..c0b0782f62ec6 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -4,10 +4,12 @@ from homeassistant import core as ha from homeassistant.components import ( + button, camera, cover, fan, group, + input_button, input_number, light, media_player, @@ -54,6 +56,8 @@ API_THERMOSTAT_MODES, API_THERMOSTAT_MODES_CUSTOM, API_THERMOSTAT_PRESETS, + DATE_FORMAT, + PRESET_MODE_NA, Cause, Inputs, ) @@ -62,7 +66,6 @@ AlexaInvalidDirectiveError, AlexaInvalidValueError, AlexaSecurityPanelAuthorizationRequired, - AlexaSecurityPanelUnauthorizedError, AlexaTempRangeError, AlexaUnsupportedThermostatModeError, AlexaVideoActionNotPermittedForContentError, @@ -116,13 +119,14 @@ async def async_api_accept_grant(hass, config, directive, context): 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: + if (domain := entity.domain) == group.DOMAIN: domain = ha.DOMAIN service = SERVICE_TURN_ON if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_ON elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START: @@ -157,6 +161,8 @@ async def async_api_turn_off(hass, config, directive, context): service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_OFF elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -309,9 +315,15 @@ async def async_api_activate(hass, config, directive, context): entity = directive.entity domain = entity.domain + service = SERVICE_TURN_ON + if domain == button.DOMAIN: + service = button.SERVICE_PRESS + elif domain == input_button.DOMAIN: + service = input_button.SERVICE_PRESS + await hass.services.async_call( domain, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: entity.entity_id}, blocking=False, context=context, @@ -319,7 +331,7 @@ async def async_api_activate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } return directive.response( @@ -343,7 +355,7 @@ async def async_api_deactivate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } return directive.response( @@ -826,48 +838,6 @@ async def async_api_reportstate(hass, config, directive, context): return directive.response(name="StateReport") -@HANDLERS.register(("Alexa.PowerLevelController", "SetPowerLevel")) -async def async_api_set_power_level(hass, config, directive, context): - """Process a SetPowerLevel request.""" - entity = directive.entity - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_PERCENTAGE - percentage = int(directive.payload["powerLevel"]) - data[fan.ATTR_PERCENTAGE] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - - return directive.response() - - -@HANDLERS.register(("Alexa.PowerLevelController", "AdjustPowerLevel")) -async def async_api_adjust_power_level(hass, config, directive, context): - """Process an AdjustPowerLevel request.""" - entity = directive.entity - percentage_delta = int(directive.payload["powerLevelDelta"]) - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_PERCENTAGE - current = entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - - # set percentage - percentage = min(100, max(0, percentage_delta + current)) - data[fan.ATTR_PERCENTAGE] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - - return directive.response() - - @HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) async def async_api_arm(hass, config, directive, context): """Process a Security Panel Arm request.""" @@ -927,11 +897,9 @@ async def async_api_disarm(hass, config, directive, context): if payload["authorization"]["type"] == "FOUR_DIGIT_PIN": data["code"] = value - if not await hass.services.async_call( + await hass.services.async_call( entity.domain, SERVICE_ALARM_DISARM, data, blocking=True, context=context - ): - msg = "Invalid Code" - raise AlexaSecurityPanelUnauthorizedError(msg) + ) response.add_context_property( { @@ -961,6 +929,18 @@ async def async_api_set_mode(hass, config, directive, context): service = fan.SERVICE_SET_DIRECTION data[fan.ATTR_DIRECTION] = direction + # Fan preset_mode + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + preset_mode = mode.split(".")[1] + if preset_mode != PRESET_MODE_NA and preset_mode in entity.attributes.get( + fan.ATTR_PRESET_MODES + ): + service = fan.SERVICE_SET_PRESET_MODE + data[fan.ATTR_PRESET_MODE] = preset_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Preset '{preset_mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] @@ -1084,24 +1064,8 @@ async def async_api_set_range(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] - # Fan Speed - if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - range_value = int(range_value) - service = fan.SERVICE_SET_SPEED - speed_list = entity.attributes[fan.ATTR_SPEED_LIST] - speed = next((v for i, v in enumerate(speed_list) if i == range_value), None) - - if not speed: - msg = "Entity does not support value" - raise AlexaInvalidValueError(msg) - - if speed == fan.SPEED_OFF: - service = fan.SERVICE_TURN_OFF - - data[fan.ATTR_SPEED] = speed - # Cover Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) if range_value == 0: service = cover.SERVICE_CLOSE_COVER @@ -1122,6 +1086,19 @@ async def async_api_set_range(hass, config, directive, context): service = cover.SERVICE_SET_COVER_TILT_POSITION data[cover.ATTR_TILT_POSITION] = range_value + # Fan Speed + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + range_value = int(range_value) + if range_value == 0: + service = fan.SERVICE_TURN_OFF + else: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported and fan.SUPPORT_SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value + else: + service = fan.SERVICE_TURN_ON + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_value = float(range_value) @@ -1177,33 +1154,11 @@ async def async_api_adjust_range(hass, config, directive, context): range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) response_value = 0 - # Fan Speed - if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - range_delta = int(range_delta) - service = fan.SERVICE_SET_SPEED - speed_list = entity.attributes[fan.ATTR_SPEED_LIST] - current_speed = entity.attributes[fan.ATTR_SPEED] - current_speed_index = next( - (i for i, v in enumerate(speed_list) if v == current_speed), 0 - ) - new_speed_index = min( - len(speed_list) - 1, max(0, current_speed_index + range_delta) - ) - speed = next( - (v for i, v in enumerate(speed_list) if i == new_speed_index), None - ) - - if speed == fan.SPEED_OFF: - service = fan.SERVICE_TURN_OFF - - data[fan.ATTR_SPEED] = response_value = speed - # Cover Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) service = SERVICE_SET_COVER_POSITION - current = entity.attributes.get(cover.ATTR_POSITION) - if not current: + if not (current := entity.attributes.get(cover.ATTR_POSITION)): msg = f"Unable to determine {entity.entity_id} current position" raise AlexaInvalidValueError(msg) position = response_value = min(100, max(0, range_delta + current)) @@ -1230,6 +1185,24 @@ async def async_api_adjust_range(hass, config, directive, context): else: data[cover.ATTR_TILT_POSITION] = tilt_position + # Fan speed percentage + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20 + range_delta = ( + int(range_delta * percentage_step) + if range_delta_default + else int(range_delta) + ) + service = fan.SERVICE_SET_PERCENTAGE + if not (current := entity.attributes.get(fan.ATTR_PERCENTAGE)): + msg = f"Unable to determine {entity.entity_id} current fan speed" + raise AlexaInvalidValueError(msg) + percentage = response_value = min(100, max(0, range_delta + current)) + if percentage: + data[fan.ATTR_PERCENTAGE] = percentage + else: + service = fan.SERVICE_TURN_OFF + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_delta = float(range_delta) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index f64031250e258..fede7d96810f2 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -120,9 +120,7 @@ async def async_handle_message(hass, message): req = message.get("request") req_type = req["type"] - handler = HANDLERS.get(req_type) - - if not handler: + if not (handler := HANDLERS.get(req_type)): raise UnknownRequest(f"Received unknown request {req_type}") return await handler(hass, message) diff --git a/homeassistant/components/alexa/logbook.py b/homeassistant/components/alexa/logbook.py index 153c7b7d61a37..65fb410c6017c 100644 --- a/homeassistant/components/alexa/logbook.py +++ b/homeassistant/components/alexa/logbook.py @@ -12,9 +12,8 @@ def async_describe_events(hass, async_describe_event): def async_describe_logbook_event(event): """Describe a logbook event.""" data = event.data - entity_id = data["request"].get("entity_id") - if entity_id: + if entity_id := data["request"].get("entity_id"): state = hass.states.get(entity_id) name = state.name if state else entity_id message = f"sent command {data['request']['namespace']}/{data['request']['name']} for {name}" diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 41738c824fbb9..237828987e86d 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -3,7 +3,8 @@ from homeassistant import core from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, ENTITY_CATEGORIES +from homeassistant.helpers import entity_registry as er from .auth import Auth from .config import AbstractConfig @@ -60,7 +61,15 @@ def user_identifier(self): def should_expose(self, entity_id): """If an entity should be exposed.""" - return self._config[CONF_FILTER](entity_id) + if not self._config[CONF_FILTER].empty_filter: + return self._config[CONF_FILTER](entity_id) + + entity_registry = er.async_get(self.hass) + if registry_entry := entity_registry.async_get(entity_id): + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES + else: + auxiliary_entity = False + return not auxiliary_entity @core.callback def async_invalidate_access_token(self): diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 712a08ac6b9ea..767bfa18224a0 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -2,18 +2,19 @@ from __future__ import annotations import asyncio +from http import HTTPStatus import json import logging import aiohttp import async_timeout -from homeassistant.const import HTTP_ACCEPTED, MATCH_ALL, STATE_ON +from homeassistant.const import MATCH_ALL, STATE_ON from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util -from .const import API_CHANGE, DOMAIN, Cause +from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .messages import AlexaResponse @@ -131,7 +132,7 @@ async def async_send_changereport_message( session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with async_timeout.timeout(DEFAULT_TIMEOUT): + async with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, @@ -148,7 +149,7 @@ async def async_send_changereport_message( _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) _LOGGER.debug("Received (%s): %s", response.status, response_text) - if response.status == HTTP_ACCEPTED: + if response.status == HTTPStatus.ACCEPTED: return response_json = json.loads(response_text) @@ -181,12 +182,13 @@ async def async_send_add_or_update_message(hass, config, entity_ids): endpoints = [] for entity_id in entity_ids: - domain = entity_id.split(".", 1)[0] + if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS: + continue - if domain not in ENTITY_ADAPTERS: + if (state := hass.states.get(entity_id)) is None: continue - alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id)) + alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state) endpoints.append(alexa_entity.serialize_discovery()) payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} @@ -252,7 +254,7 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): namespace="Alexa.DoorbellEventSource", payload={ "cause": {"type": Cause.PHYSICAL_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), }, ) @@ -262,7 +264,7 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with async_timeout.timeout(DEFAULT_TIMEOUT): + async with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, @@ -279,7 +281,7 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) _LOGGER.debug("Received (%s): %s", response.status, response_text) - if response.status == HTTP_ACCEPTED: + if response.status == HTTPStatus.ACCEPTED: return response_json = json.loads(response_text) diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py index 554a4aa47bcf8..0dd7a76d4c449 100644 --- a/homeassistant/components/almond/__init__.py +++ b/homeassistant/components/almond/__init__.py @@ -5,15 +5,16 @@ from datetime import timedelta import logging import time +from typing import Optional, cast from aiohttp import ClientError, ClientSession import async_timeout from pyalmond import AbstractAlmondWebAuth, AlmondLocalAuth, WebAlmondAPI import voluptuous as vol -from homeassistant import config_entries from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import conversation +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -94,14 +95,14 @@ async def async_setup(hass, config): hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, + context={"source": SOURCE_IMPORT}, data={"type": TYPE_LOCAL, "host": conf[CONF_HOST]}, ) ) return True -async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Almond config entry.""" websession = aiohttp_client.async_get_clientsession(hass) @@ -150,7 +151,7 @@ async def almond_hass_start(_event): async def _configure_almond_for_ha( - hass: HomeAssistant, entry: config_entries.ConfigEntry, api: WebAlmondAPI + hass: HomeAssistant, entry: ConfigEntry, api: WebAlmondAPI ): """Configure Almond to connect to HA.""" try: @@ -166,7 +167,7 @@ async def _configure_almond_for_ha( _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) - data = await store.async_load() + data = cast(Optional[dict], await store.async_load()) if data is None: data = {} @@ -176,7 +177,9 @@ async def _configure_almond_for_ha( user = await hass.auth.async_get_user(data["almond_user"]) if user is None: - user = await hass.auth.async_create_system_user("Almond", [GROUP_ID_ADMIN]) + user = await hass.auth.async_create_system_user( + "Almond", group_ids=[GROUP_ID_ADMIN] + ) data["almond_user"] = user.id await store.async_save(data) @@ -191,7 +194,7 @@ async def _configure_almond_for_ha( # Store token in Almond try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): await api.async_create_device( { "kind": "io.home-assistant", @@ -204,7 +207,7 @@ async def _configure_almond_for_ha( ) except (asyncio.TimeoutError, ClientError) as err: if isinstance(err, asyncio.TimeoutError): - msg = "Request timeout" + msg: str | ClientError = "Request timeout" else: msg = err _LOGGER.warning("Unable to configure Almond: %s", msg) @@ -231,7 +234,7 @@ def __init__( host: str, websession: ClientSession, oauth_session: config_entry_oauth2_flow.OAuth2Session, - ): + ) -> None: """Initialize Almond auth.""" super().__init__(host, websession) self._oauth_session = oauth_session @@ -248,8 +251,8 @@ class AlmondAgent(conversation.AbstractConversationAgent): """Almond conversation agent.""" def __init__( - self, hass: HomeAssistant, api: WebAlmondAPI, entry: config_entries.ConfigEntry - ): + self, hass: HomeAssistant, api: WebAlmondAPI, entry: ConfigEntry + ) -> None: """Initialize the agent.""" self.hass = hass self.api = api diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index d6084569ff737..ba6fcb6d83ce1 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -1,6 +1,9 @@ """Config flow to connect with Home Assistant.""" +from __future__ import annotations + import asyncio import logging +from typing import Any from aiohttp import ClientError import async_timeout @@ -9,6 +12,8 @@ from yarl import URL from homeassistant import config_entries, core, data_entry_flow +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import DOMAIN as ALMOND_DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 @@ -20,7 +25,7 @@ async def async_verify_local_connection(hass: core.HomeAssistant, host: str): api = WebAlmondAPI(AlmondLocalAuth(host, websession)) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await api.async_list_apps() return True @@ -64,7 +69,7 @@ async def async_step_auth(self, user_input=None): return result - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow. Ok to override if you want to fetch extra info or even add another step. @@ -73,7 +78,7 @@ async def async_oauth_create_entry(self, data: dict) -> dict: data["host"] = self.host return self.async_create_entry(title=self.flow_impl.name, data=data) - async def async_step_import(self, user_input: dict = None) -> dict: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import data.""" # Only allow 1 instance. if self._async_current_entries(): @@ -90,12 +95,12 @@ async def async_step_import(self, user_input: dict = None) -> dict: data={"type": TYPE_LOCAL, "host": user_input["host"]}, ) - async def async_step_hassio(self, discovery_info): + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Receive a Hass.io discovery.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - self.hassio_discovery = discovery_info + self.hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/almond/translations/bg.json b/homeassistant/components/almond/translations/bg.json index bb0c874517bde..81e1094b1abcb 100644 --- a/homeassistant/components/almond/translations/bg.json +++ b/homeassistant/components/almond/translations/bg.json @@ -2,7 +2,8 @@ "config": { "abort": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Almond \u0441\u044a\u0440\u0432\u044a\u0440\u0430.", - "missing_configuration": "\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430 \u043a\u0430\u043a \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Almond." + "missing_configuration": "\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430 \u043a\u0430\u043a \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Almond.", + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." }, "step": { "pick_implementation": { diff --git a/homeassistant/components/almond/translations/fr.json b/homeassistant/components/almond/translations/fr.json index 0e6a8e0be3f6c..a464e8b56e935 100644 --- a/homeassistant/components/almond/translations/fr.json +++ b/homeassistant/components/almond/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "cannot_connect": "Impossible de se connecter au serveur Almond", - "missing_configuration": "Veuillez consulter la documentation pour savoir comment configurer Almond.", + "cannot_connect": "\u00c9chec de connexion", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, diff --git a/homeassistant/components/almond/translations/he.json b/homeassistant/components/almond/translations/he.json new file mode 100644 index 0000000000000..6aa9dd1d75f69 --- /dev/null +++ b/homeassistant/components/almond/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", + "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "step": { + "pick_implementation": { + "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d0\u05d9\u05de\u05d5\u05ea" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/hu.json b/homeassistant/components/almond/translations/hu.json index 568cd7270de26..d75290b4fd10a 100644 --- a/homeassistant/components/almond/translations/hu.json +++ b/homeassistant/components/almond/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant alkalmaz\u00e1st az Almondhoz val\u00f3 csatlakoz\u00e1shoz, amelyet a Supervisor kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", - "title": "Almond a Supervisor kieg\u00e9sz\u00edt\u0151n kereszt\u00fcl" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot Almondhoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "title": "Almond - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" diff --git a/homeassistant/components/almond/translations/id.json b/homeassistant/components/almond/translations/id.json index 21a627132c472..8e4302220b58c 100644 --- a/homeassistant/components/almond/translations/id.json +++ b/homeassistant/components/almond/translations/id.json @@ -8,7 +8,7 @@ }, "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke Almond yang disediakan oleh add-on Supervisor {addon}?", + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke Almond yang disediakan oleh add-on: {addon}?", "title": "Almond melalui add-on Home Assistant" }, "pick_implementation": { diff --git a/homeassistant/components/almond/translations/ja.json b/homeassistant/components/almond/translations/ja.json new file mode 100644 index 0000000000000..4d51bbc2f1371 --- /dev/null +++ b/homeassistant/components/almond/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308b\u3001Almond\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306eAlmond" + }, + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/tr.json b/homeassistant/components/almond/translations/tr.json index dc270099fcd29..a0808fde8efc2 100644 --- a/homeassistant/components/almond/translations/tr.json +++ b/homeassistant/components/almond/translations/tr.json @@ -2,7 +2,18 @@ "config": { "abort": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", + "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan Almond'a ba\u011flanacak \u015fekilde yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla Almond" + }, + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + } } } } \ No newline at end of file diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 0788772a45b58..583485ca7032a 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -110,48 +110,27 @@ class AlphaVantageSensor(SensorEntity): def __init__(self, timeseries, symbol): """Initialize the sensor.""" self._symbol = symbol[CONF_SYMBOL] - self._name = symbol.get(CONF_NAME, self._symbol) + self._attr_name = symbol.get(CONF_NAME, self._symbol) self._timeseries = timeseries - self.values = None - self._unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) - self._icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - return self.values["1. open"] - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self.values is not None: - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_CLOSE: self.values["4. close"], - ATTR_HIGH: self.values["2. high"], - ATTR_LOW: self.values["3. low"], - } - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon + self._attr_native_unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) + self._attr_icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) def update(self): """Get the latest data and updates the states.""" _LOGGER.debug("Requesting new data for symbol %s", self._symbol) all_values, _ = self._timeseries.get_intraday(self._symbol) - self.values = next(iter(all_values.values())) + values = next(iter(all_values.values())) + self._attr_native_value = values["1. open"] + self._attr_extra_state_attributes = ( + { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_CLOSE: values["4. close"], + ATTR_HIGH: values["2. high"], + ATTR_LOW: values["3. low"], + } + if values is not None + else None + ) _LOGGER.debug("Received new values for symbol %s", self._symbol) @@ -163,43 +142,13 @@ def __init__(self, foreign_exchange, config): self._foreign_exchange = foreign_exchange self._from_currency = config[CONF_FROM] self._to_currency = config[CONF_TO] - if CONF_NAME in config: - self._name = config.get(CONF_NAME) - else: - self._name = f"{self._to_currency}/{self._from_currency}" - self._unit_of_measurement = self._to_currency - self._icon = ICONS.get(self._from_currency, "USD") - self.values = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - return round(float(self.values["5. Exchange Rate"]), 4) - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self.values is not None: - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - CONF_FROM: self._from_currency, - CONF_TO: self._to_currency, - } + self._attr_name = ( + config.get(CONF_NAME) + if CONF_NAME in config + else f"{self._to_currency}/{self._from_currency}" + ) + self._attr_icon = ICONS.get(self._from_currency, "USD") + self._attr_native_unit_of_measurement = self._to_currency def update(self): """Get the latest data and updates the states.""" @@ -208,9 +157,20 @@ def update(self): self._from_currency, self._to_currency, ) - self.values, _ = self._foreign_exchange.get_currency_exchange_rate( + values, _ = self._foreign_exchange.get_currency_exchange_rate( from_currency=self._from_currency, to_currency=self._to_currency ) + self._attr_native_value = round(float(values["5. Exchange Rate"]), 4) + self._attr_extra_state_attributes = ( + { + ATTR_ATTRIBUTION: ATTRIBUTION, + CONF_FROM: self._from_currency, + CONF_TO: self._to_currency, + } + if values is not None + else None + ) + _LOGGER.debug( "Received new data for forex %s - %s", self._from_currency, diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py new file mode 100644 index 0000000000000..91882dd386cf7 --- /dev/null +++ b/homeassistant/components/amazon_polly/const.py @@ -0,0 +1,132 @@ +"""Constants for the Amazon Polly text to speech service.""" +from __future__ import annotations + +from typing import Final + +CONF_REGION: Final = "region_name" +CONF_ACCESS_KEY_ID: Final = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY: Final = "aws_secret_access_key" + +DEFAULT_REGION: Final = "us-east-1" +SUPPORTED_REGIONS: Final[list[str]] = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "eu-west-1", + "eu-central-1", + "eu-west-2", + "eu-west-3", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-2", + "ap-northeast-1", + "ap-south-1", + "sa-east-1", +] + +CONF_ENGINE: Final = "engine" +CONF_VOICE: Final = "voice" +CONF_OUTPUT_FORMAT: Final = "output_format" +CONF_SAMPLE_RATE: Final = "sample_rate" +CONF_TEXT_TYPE: Final = "text_type" + +SUPPORTED_VOICES: Final[list[str]] = [ + "Olivia", # Female, Australian, Neural + "Zhiyu", # Chinese + "Mads", + "Naja", # Danish + "Ruben", + "Lotte", # Dutch + "Russell", + "Nicole", # English Australian + "Brian", + "Amy", + "Emma", # English + "Aditi", + "Raveena", # English, Indian + "Joey", + "Justin", + "Matthew", + "Ivy", + "Joanna", + "Kendra", + "Kimberly", + "Salli", # English + "Geraint", # English Welsh + "Mathieu", + "Celine", + "Lea", # French + "Chantal", # French Canadian + "Hans", + "Marlene", + "Vicki", # German + "Aditi", # Hindi + "Karl", + "Dora", # Icelandic + "Giorgio", + "Carla", + "Bianca", # Italian + "Takumi", + "Mizuki", # Japanese + "Seoyeon", # Korean + "Liv", # Norwegian + "Jacek", + "Jan", + "Ewa", + "Maja", # Polish + "Ricardo", + "Vitoria", # Portuguese, Brazilian + "Camila", # Portuguese, Brazilian + "Cristiano", + "Ines", # Portuguese, European + "Carmen", # Romanian + "Maxim", + "Tatyana", # Russian + "Enrique", + "Conchita", + "Lucia", # Spanish European + "Mia", # Spanish Mexican + "Miguel", # Spanish US + "Penelope", # Spanish US + "Lupe", # Spanish US + "Astrid", # Swedish + "Filiz", # Turkish + "Gwyneth", # Welsh +] + +SUPPORTED_OUTPUT_FORMATS: Final[list[str]] = ["mp3", "ogg_vorbis", "pcm"] + +SUPPORTED_ENGINES: Final[list[str]] = ["neural", "standard"] + +SUPPORTED_SAMPLE_RATES: Final[list[str]] = ["8000", "16000", "22050", "24000"] + +SUPPORTED_SAMPLE_RATES_MAP: Final[dict[str, list[str]]] = { + "mp3": ["8000", "16000", "22050", "24000"], + "ogg_vorbis": ["8000", "16000", "22050"], + "pcm": ["8000", "16000"], +} + +SUPPORTED_TEXT_TYPES: Final[list[str]] = ["text", "ssml"] + +CONTENT_TYPE_EXTENSIONS: Final[dict[str, str]] = { + "audio/mpeg": "mp3", + "audio/ogg": "ogg", + "audio/pcm": "pcm", +} + +DEFAULT_ENGINE: Final = "standard" +DEFAULT_VOICE: Final = "Joanna" +DEFAULT_OUTPUT_FORMAT: Final = "mp3" +DEFAULT_TEXT_TYPE: Final = "text" + +DEFAULT_SAMPLE_RATES: Final[dict[str, str]] = { + "mp3": "22050", + "ogg_vorbis": "22050", + "pcm": "16000", +} + +AWS_CONF_CONNECT_TIMEOUT: Final = 10 +AWS_CONF_READ_TIMEOUT: Final = 5 +AWS_CONF_MAX_POOL_CONNECTIONS: Final = 1 diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index bdb46abda9a1b..7e21b9ac603d5 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -1,136 +1,54 @@ """Support for the Amazon Polly text to speech service.""" +from __future__ import annotations + import logging +from typing import Final import boto3 import botocore import voluptuous as vol -from homeassistant.components.tts import PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, + Provider, + TtsAudioType, +) from homeassistant.const import ATTR_CREDENTIALS, CONF_PROFILE_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import ( + AWS_CONF_CONNECT_TIMEOUT, + AWS_CONF_MAX_POOL_CONNECTIONS, + AWS_CONF_READ_TIMEOUT, + CONF_ACCESS_KEY_ID, + CONF_ENGINE, + CONF_OUTPUT_FORMAT, + CONF_REGION, + CONF_SAMPLE_RATE, + CONF_SECRET_ACCESS_KEY, + CONF_TEXT_TYPE, + CONF_VOICE, + CONTENT_TYPE_EXTENSIONS, + DEFAULT_ENGINE, + DEFAULT_OUTPUT_FORMAT, + DEFAULT_REGION, + DEFAULT_SAMPLE_RATES, + DEFAULT_TEXT_TYPE, + DEFAULT_VOICE, + SUPPORTED_ENGINES, + SUPPORTED_OUTPUT_FORMATS, + SUPPORTED_REGIONS, + SUPPORTED_SAMPLE_RATES, + SUPPORTED_SAMPLE_RATES_MAP, + SUPPORTED_TEXT_TYPES, + SUPPORTED_VOICES, +) -_LOGGER = logging.getLogger(__name__) - -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" - -DEFAULT_REGION = "us-east-1" -SUPPORTED_REGIONS = [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - "eu-west-1", - "eu-central-1", - "eu-west-2", - "eu-west-3", - "ap-southeast-1", - "ap-southeast-2", - "ap-northeast-2", - "ap-northeast-1", - "ap-south-1", - "sa-east-1", -] - -CONF_ENGINE = "engine" -CONF_VOICE = "voice" -CONF_OUTPUT_FORMAT = "output_format" -CONF_SAMPLE_RATE = "sample_rate" -CONF_TEXT_TYPE = "text_type" - -SUPPORTED_VOICES = [ - "Olivia", # Female, Australian, Neural - "Zhiyu", # Chinese - "Mads", - "Naja", # Danish - "Ruben", - "Lotte", # Dutch - "Russell", - "Nicole", # English Australian - "Brian", - "Amy", - "Emma", # English - "Aditi", - "Raveena", # English, Indian - "Joey", - "Justin", - "Matthew", - "Ivy", - "Joanna", - "Kendra", - "Kimberly", - "Salli", # English - "Geraint", # English Welsh - "Mathieu", - "Celine", - "Lea", # French - "Chantal", # French Canadian - "Hans", - "Marlene", - "Vicki", # German - "Aditi", # Hindi - "Karl", - "Dora", # Icelandic - "Giorgio", - "Carla", - "Bianca", # Italian - "Takumi", - "Mizuki", # Japanese - "Seoyeon", # Korean - "Liv", # Norwegian - "Jacek", - "Jan", - "Ewa", - "Maja", # Polish - "Ricardo", - "Vitoria", # Portuguese, Brazilian - "Cristiano", - "Ines", # Portuguese, European - "Carmen", # Romanian - "Maxim", - "Tatyana", # Russian - "Enrique", - "Conchita", - "Lucia", # Spanish European - "Mia", # Spanish Mexican - "Miguel", # Spanish US - "Penelope", # Spanish US - "Lupe", # Spanish US - "Astrid", # Swedish - "Filiz", # Turkish - "Gwyneth", # Welsh -] - -SUPPORTED_OUTPUT_FORMATS = ["mp3", "ogg_vorbis", "pcm"] - -SUPPORTED_ENGINES = ["neural", "standard"] - -SUPPORTED_SAMPLE_RATES = ["8000", "16000", "22050", "24000"] - -SUPPORTED_SAMPLE_RATES_MAP = { - "mp3": ["8000", "16000", "22050", "24000"], - "ogg_vorbis": ["8000", "16000", "22050"], - "pcm": ["8000", "16000"], -} - -SUPPORTED_TEXT_TYPES = ["text", "ssml"] - -CONTENT_TYPE_EXTENSIONS = {"audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/pcm": "pcm"} - -DEFAULT_ENGINE = "standard" -DEFAULT_VOICE = "Joanna" -DEFAULT_OUTPUT_FORMAT = "mp3" -DEFAULT_TEXT_TYPE = "text" - -DEFAULT_SAMPLE_RATES = {"mp3": "22050", "ogg_vorbis": "22050", "pcm": "16000"} - -AWS_CONF_CONNECT_TIMEOUT = 10 -AWS_CONF_READ_TIMEOUT = 5 -AWS_CONF_MAX_POOL_CONNECTIONS = 1 +_LOGGER: Final = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(SUPPORTED_REGIONS), vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, @@ -151,11 +69,15 @@ ) -def get_engine(hass, config, discovery_info=None): +def get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> Provider | None: """Set up Amazon Polly speech component.""" output_format = config[CONF_OUTPUT_FORMAT] sample_rate = config.get(CONF_SAMPLE_RATE, DEFAULT_SAMPLE_RATES[output_format]) - if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP.get(output_format): + if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP[output_format]: _LOGGER.error( "%s is not a valid sample rate for %s", sample_rate, output_format ) @@ -163,7 +85,7 @@ def get_engine(hass, config, discovery_info=None): config[CONF_SAMPLE_RATE] = sample_rate - profile = config.get(CONF_PROFILE_NAME) + profile: str | None = config.get(CONF_PROFILE_NAME) if profile is not None: boto3.setup_default_session(profile_name=profile) @@ -185,16 +107,20 @@ def get_engine(hass, config, discovery_info=None): polly_client = boto3.client("polly", **aws_config) - supported_languages = [] + supported_languages: list[str] = [] - all_voices = {} + all_voices: dict[str, dict[str, str]] = {} all_voices_req = polly_client.describe_voices() - for voice in all_voices_req.get("Voices"): - all_voices[voice.get("Id")] = voice - if voice.get("LanguageCode") not in supported_languages: - supported_languages.append(voice.get("LanguageCode")) + for voice in all_voices_req.get("Voices", []): + voice_id: str | None = voice.get("Id") + if voice_id is None: + continue + all_voices[voice_id] = voice + language_code: str | None = voice.get("LanguageCode") + if language_code is not None and language_code not in supported_languages: + supported_languages.append(language_code) return AmazonPollyProvider(polly_client, config, supported_languages, all_voices) @@ -202,39 +128,53 @@ def get_engine(hass, config, discovery_info=None): class AmazonPollyProvider(Provider): """Amazon Polly speech api provider.""" - def __init__(self, polly_client, config, supported_languages, all_voices): + def __init__( + self, + polly_client: boto3.client, + config: ConfigType, + supported_languages: list[str], + all_voices: dict[str, dict[str, str]], + ) -> None: """Initialize Amazon Polly provider for TTS.""" self.client = polly_client self.config = config self.supported_langs = supported_languages self.all_voices = all_voices - self.default_voice = self.config[CONF_VOICE] + self.default_voice: str = self.config[CONF_VOICE] self.name = "Amazon Polly" @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return a list of supported languages.""" return self.supported_langs @property - def default_language(self): + def default_language(self) -> str | None: """Return the default language.""" - return self.all_voices.get(self.default_voice).get("LanguageCode") + return self.all_voices.get(self.default_voice, {}).get("LanguageCode") @property - def default_options(self): + def default_options(self) -> dict[str, str]: """Return dict include default options.""" return {CONF_VOICE: self.default_voice} @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return a list of supported options.""" return [CONF_VOICE] - def get_tts_audio(self, message, language=None, options=None): + def get_tts_audio( + self, + message: str, + language: str | None = None, + options: dict[str, str] | None = None, + ) -> TtsAudioType: """Request TTS file from Polly.""" + if options is None or language is None: + _LOGGER.debug("language and/or options were missing") + return None, None voice_id = options.get(CONF_VOICE, self.default_voice) - voice_in_dict = self.all_voices.get(voice_id) + voice_in_dict = self.all_voices[voice_id] if language != voice_in_dict.get("LanguageCode"): _LOGGER.error("%s does not support the %s language", voice_id, language) return None, None diff --git a/homeassistant/components/ambee/__init__.py b/homeassistant/components/ambee/__init__.py new file mode 100644 index 0000000000000..4481afb09cadb --- /dev/null +++ b/homeassistant/components/ambee/__init__.py @@ -0,0 +1,70 @@ +"""Support for Ambee.""" +from __future__ import annotations + +from ambee import AirQuality, Ambee, AmbeeAuthenticationError, Pollen + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL, SERVICE_AIR_QUALITY, SERVICE_POLLEN + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ambee from a config entry.""" + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) + + client = Ambee( + api_key=entry.data[CONF_API_KEY], + latitude=entry.data[CONF_LATITUDE], + longitude=entry.data[CONF_LONGITUDE], + ) + + async def update_air_quality() -> AirQuality: + """Update method for updating Ambee Air Quality data.""" + try: + return await client.air_quality() + except AmbeeAuthenticationError as err: + raise ConfigEntryAuthFailed from err + + air_quality: DataUpdateCoordinator[AirQuality] = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{DOMAIN}_{SERVICE_AIR_QUALITY}", + update_interval=SCAN_INTERVAL, + update_method=update_air_quality, + ) + await air_quality.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id][SERVICE_AIR_QUALITY] = air_quality + + async def update_pollen() -> Pollen: + """Update method for updating Ambee Pollen data.""" + try: + return await client.pollen() + except AmbeeAuthenticationError as err: + raise ConfigEntryAuthFailed from err + + pollen: DataUpdateCoordinator[Pollen] = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{DOMAIN}_{SERVICE_POLLEN}", + update_interval=SCAN_INTERVAL, + update_method=update_pollen, + ) + await pollen.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id][SERVICE_POLLEN] = pollen + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Ambee config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/ambee/config_flow.py b/homeassistant/components/ambee/config_flow.py new file mode 100644 index 0000000000000..0550c541ed0de --- /dev/null +++ b/homeassistant/components/ambee/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow to configure the Ambee integration.""" +from __future__ import annotations + +from typing import Any + +from ambee import Ambee, AmbeeAuthenticationError, AmbeeError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + + +class AmbeeFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Ambee.""" + + VERSION = 1 + + entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + try: + client = Ambee( + api_key=user_input[CONF_API_KEY], + latitude=user_input[CONF_LATITUDE], + longitude=user_input[CONF_LONGITUDE], + session=session, + ) + await client.air_quality() + except AmbeeAuthenticationError: + errors["base"] = "invalid_api_key" + except AmbeeError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Ambee.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with Ambee.""" + errors = {} + if user_input is not None and self.entry: + session = async_get_clientsession(self.hass) + client = Ambee( + api_key=user_input[CONF_API_KEY], + latitude=self.entry.data[CONF_LATITUDE], + longitude=self.entry.data[CONF_LONGITUDE], + session=session, + ) + try: + await client.air_quality() + except AmbeeAuthenticationError: + errors["base"] = "invalid_api_key" + except AmbeeError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/ambee/const.py b/homeassistant/components/ambee/const.py new file mode 100644 index 0000000000000..8f8f2237654ac --- /dev/null +++ b/homeassistant/components/ambee/const.py @@ -0,0 +1,232 @@ +"""Constants for the Ambee integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, +) + +DOMAIN: Final = "ambee" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(hours=1) + +DEVICE_CLASS_AMBEE_RISK: Final = "ambee__risk" + +SERVICE_AIR_QUALITY: Final = "air_quality" +SERVICE_POLLEN: Final = "pollen" + +SERVICES: dict[str, str] = { + SERVICE_AIR_QUALITY: "Air Quality", + SERVICE_POLLEN: "Pollen", +} + +SENSORS: dict[str, list[SensorEntityDescription]] = { + SERVICE_AIR_QUALITY: [ + SensorEntityDescription( + key="particulate_matter_2_5", + name="Particulate Matter < 2.5 μm", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="particulate_matter_10", + name="Particulate Matter < 10 μm", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="sulphur_dioxide", + name="Sulphur Dioxide (SO2)", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="nitrogen_dioxide", + name="Nitrogen Dioxide (NO2)", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="ozone", + name="Ozone", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="carbon_monoxide", + name="Carbon Monoxide (CO)", + device_class=SensorDeviceClass.CO, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="air_quality_index", + name="Air Quality Index (AQI)", + state_class=SensorStateClass.MEASUREMENT, + ), + ], + SERVICE_POLLEN: [ + SensorEntityDescription( + key="grass", + name="Grass Pollen", + icon="mdi:grass", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="tree", + name="Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="weed", + name="Weed Pollen", + icon="mdi:sprout", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="grass_risk", + name="Grass Pollen Risk", + icon="mdi:grass", + device_class=DEVICE_CLASS_AMBEE_RISK, + ), + SensorEntityDescription( + key="tree_risk", + name="Tree Pollen Risk", + icon="mdi:tree", + device_class=DEVICE_CLASS_AMBEE_RISK, + ), + SensorEntityDescription( + key="weed_risk", + name="Weed Pollen Risk", + icon="mdi:sprout", + device_class=DEVICE_CLASS_AMBEE_RISK, + ), + SensorEntityDescription( + key="grass_poaceae", + name="Poaceae Grass Pollen", + icon="mdi:grass", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_alder", + name="Alder Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_birch", + name="Birch Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_cypress", + name="Cypress Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_elm", + name="Elm Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_hazel", + name="Hazel Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_oak", + name="Oak Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_pine", + name="Pine Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_plane", + name="Plane Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_poplar", + name="Poplar Tree Pollen", + icon="mdi:tree", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_chenopod", + name="Chenopod Weed Pollen", + icon="mdi:sprout", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_mugwort", + name="Mugwort Weed Pollen", + icon="mdi:sprout", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_nettle", + name="Nettle Weed Pollen", + icon="mdi:sprout", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_ragweed", + name="Ragweed Weed Pollen", + icon="mdi:sprout", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + ], +} diff --git a/homeassistant/components/ambee/manifest.json b/homeassistant/components/ambee/manifest.json new file mode 100644 index 0000000000000..3226e9de3a3ee --- /dev/null +++ b/homeassistant/components/ambee/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ambee", + "name": "Ambee", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambee", + "requirements": ["ambee==0.4.0"], + "codeowners": ["@frenck"], + "quality_scale": "platinum", + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/ambee/sensor.py b/homeassistant/components/ambee/sensor.py new file mode 100644 index 0000000000000..bf9cfe74f3125 --- /dev/null +++ b/homeassistant/components/ambee/sensor.py @@ -0,0 +1,75 @@ +"""Support for Ambee sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, SENSORS, SERVICES + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ambee sensors based on a config entry.""" + async_add_entities( + AmbeeSensorEntity( + coordinator=hass.data[DOMAIN][entry.entry_id][service_key], + entry_id=entry.entry_id, + description=description, + service_key=service_key, + service=SERVICES[service_key], + ) + for service_key, service_sensors in SENSORS.items() + for description in service_sensors + ) + + +class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): + """Defines an Ambee sensor.""" + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + entry_id: str, + description: SensorEntityDescription, + service_key: str, + service: str, + ) -> None: + """Initialize Ambee sensor.""" + super().__init__(coordinator=coordinator) + self._service_key = service_key + + self.entity_id = f"{SENSOR_DOMAIN}.{service_key}_{description.key}" + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{service_key}_{description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry_id}_{service_key}")}, + manufacturer="Ambee", + name=service, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + value = getattr(self.coordinator.data, self.entity_description.key) + if isinstance(value, str): + return value.lower() + return value # type: ignore[no-any-return] diff --git a/homeassistant/components/ambee/strings.json b/homeassistant/components/ambee/strings.json new file mode 100644 index 0000000000000..e3c306788ddde --- /dev/null +++ b/homeassistant/components/ambee/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up Ambee to integrate with Home Assistant.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, + "reauth_confirm": { + "data": { + "description": "Re-authenticate with your Ambee account.", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/ambee/strings.sensor.json b/homeassistant/components/ambee/strings.sensor.json new file mode 100644 index 0000000000000..83eb3b3fd7303 --- /dev/null +++ b/homeassistant/components/ambee/strings.sensor.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "low": "Low", + "moderate": "Moderate", + "high": "High", + "very high": "Very High" + } + } +} diff --git a/homeassistant/components/ambee/translations/bg.json b/homeassistant/components/ambee/translations/bg.json new file mode 100644 index 0000000000000..c72dc5227cae0 --- /dev/null +++ b/homeassistant/components/ambee/translations/bg.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/ca.json b/homeassistant/components/ambee/translations/ca.json new file mode 100644 index 0000000000000..ac48eea1cd65d --- /dev/null +++ b/homeassistant/components/ambee/translations/ca.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_api_key": "Clau API inv\u00e0lida" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Clau API", + "description": "Torna a autenticar-te amb el compte d'Ambee." + } + }, + "user": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom" + }, + "description": "Configura la integraci\u00f3 d'Ambee amb Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/de.json b/homeassistant/components/ambee/translations/de.json new file mode 100644 index 0000000000000..4359ab7234945 --- /dev/null +++ b/homeassistant/components/ambee/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-Schl\u00fcssel", + "description": "Authentifiziere dich erneut mit deinem Ambee-Konto." + } + }, + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "name": "Name" + }, + "description": "Richte Ambee f\u00fcr die Integration mit Home Assistant ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/en.json b/homeassistant/components/ambee/translations/en.json new file mode 100644 index 0000000000000..433580e80237f --- /dev/null +++ b/homeassistant/components/ambee/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key", + "description": "Re-authenticate with your Ambee account." + } + }, + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "description": "Set up Ambee to integrate with Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/es-419.json b/homeassistant/components/ambee/translations/es-419.json new file mode 100644 index 0000000000000..dee7d514b4865 --- /dev/null +++ b/homeassistant/components/ambee/translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta de Ambee." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/es.json b/homeassistant/components/ambee/translations/es.json new file mode 100644 index 0000000000000..7f4f8b75de57a --- /dev/null +++ b/homeassistant/components/ambee/translations/es.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave API no v\u00e1lida" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Clave API", + "description": "Vuelva a autenticarse con su cuenta de Ambee." + } + }, + "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, + "description": "Configure Ambee para que se integre con Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/et.json b/homeassistant/components/ambee/translations/et.json new file mode 100644 index 0000000000000..085f13d6926bc --- /dev/null +++ b/homeassistant/components/ambee/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendumine nurjus", + "invalid_api_key": "Vale API v\u00f5ti" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti", + "description": "Taastuvasta Ambee konto" + } + }, + "user": { + "data": { + "api_key": "API v\u00f5ti", + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad", + "name": "Nimi" + }, + "description": "Seadista Ambee sidumine Home Assistantiga." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/fr.json b/homeassistant/components/ambee/translations/fr.json new file mode 100644 index 0000000000000..dc968329b491e --- /dev/null +++ b/homeassistant/components/ambee/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_api_key": "Cl\u00e9 API invalide" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Cl\u00e9 d'API", + "description": "R\u00e9-authentifiez-vous avec votre compte Ambee." + } + }, + "user": { + "data": { + "api_key": "Cl\u00e9 d'API", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom" + }, + "description": "Configurer Ambee pour l'int\u00e9grer \u00e0 Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/he.json b/homeassistant/components/ambee/translations/he.json new file mode 100644 index 0000000000000..7b7882cd4dff3 --- /dev/null +++ b/homeassistant/components/ambee/translations/he.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + }, + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/hu.json b/homeassistant/components/ambee/translations/hu.json new file mode 100644 index 0000000000000..e4cef44c5bad3 --- /dev/null +++ b/homeassistant/components/ambee/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API kulcs", + "description": "Hiteles\u00edtse mag\u00e1t \u00fajra az Ambee-fi\u00f3kj\u00e1val." + } + }, + "user": { + "data": { + "api_key": "API kulcs", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "name": "N\u00e9v" + }, + "description": "Integr\u00e1lja \u00f6ssze Ambeet Home Assistanttal." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/id.json b/homeassistant/components/ambee/translations/id.json new file mode 100644 index 0000000000000..a5790d95ecd1f --- /dev/null +++ b/homeassistant/components/ambee/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_api_key": "Kunci API tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API", + "description": "Autentikasi ulang dengan akun Ambee Anda." + } + }, + "user": { + "data": { + "api_key": "Kunci API", + "latitude": "Lintang", + "longitude": "Bujur", + "name": "Nama" + }, + "description": "Siapkan Ambee Anda untuk diintegrasikan dengan Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/it.json b/homeassistant/components/ambee/translations/it.json new file mode 100644 index 0000000000000..fe97ce3368620 --- /dev/null +++ b/homeassistant/components/ambee/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_api_key": "Chiave API non valida" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Chiave API", + "description": "Autenticati nuovamente con il tuo account Ambee." + } + }, + "user": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome" + }, + "description": "Configura Ambee per l'integrazione con Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/ja.json b/homeassistant/components/ambee/translations/ja.json new file mode 100644 index 0000000000000..e320189e0107d --- /dev/null +++ b/homeassistant/components/ambee/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc", + "description": "Ambee\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u518d\u8a8d\u8a3c\u3057\u307e\u3059\u3002" + } + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "Ambee \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/nl.json b/homeassistant/components/ambee/translations/nl.json new file mode 100644 index 0000000000000..837e39a72d77c --- /dev/null +++ b/homeassistant/components/ambee/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_api_key": "Ongeldige API-sleutel" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-sleutel", + "description": "Verifieer opnieuw met uw Ambee-account." + } + }, + "user": { + "data": { + "api_key": "API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "name": "Naam" + }, + "description": "Stel Ambee in om te integreren met Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/no.json b/homeassistant/components/ambee/translations/no.json new file mode 100644 index 0000000000000..b735ee91509ad --- /dev/null +++ b/homeassistant/components/ambee/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_api_key": "Ugyldig API-n\u00f8kkel" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel", + "description": "Autentiser p\u00e5 nytt med Ambee-kontoen din." + } + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "name": "Navn" + }, + "description": "Sett opp Ambee for \u00e5 integrere med Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/pl.json b/homeassistant/components/ambee/translations/pl.json new file mode 100644 index 0000000000000..d0b2225cc9aee --- /dev/null +++ b/homeassistant/components/ambee/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_api_key": "Nieprawid\u0142owy klucz API" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Klucz API", + "description": "Ponownie uwierzytelnij za pomoc\u0105 konta Ambee." + } + }, + "user": { + "data": { + "api_key": "Klucz API", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "name": "Nazwa" + }, + "description": "Skonfiguruj Ambee, aby zintegrowa\u0107 go z Home Assistantem." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/ru.json b/homeassistant/components/ambee/translations/ru.json new file mode 100644 index 0000000000000..c229c2d602008 --- /dev/null +++ b/homeassistant/components/ambee/translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API." + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Ambee" + } + }, + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Ambee." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.bg.json b/homeassistant/components/ambee/translations/sensor.bg.json new file mode 100644 index 0000000000000..07977ca4abf8c --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.bg.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "\u0412\u0438\u0441\u043e\u043a\u043e", + "low": "\u041d\u0438\u0441\u043a\u043e", + "moderate": "\u0423\u043c\u0435\u0440\u0435\u043d\u043e", + "very high": "\u041c\u043d\u043e\u0433\u043e \u0432\u0438\u0441\u043e\u043a\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.ca.json b/homeassistant/components/ambee/translations/sensor.ca.json new file mode 100644 index 0000000000000..b85d6bdc8e2d3 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.ca.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Alt", + "low": "Baix", + "moderate": "Moderat", + "very high": "Molt alt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.de.json b/homeassistant/components/ambee/translations/sensor.de.json new file mode 100644 index 0000000000000..c96a2c50eb7ac --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.de.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Hoch", + "low": "Niedrig", + "moderate": "M\u00e4\u00dfig", + "very high": "Sehr hoch" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.en.json b/homeassistant/components/ambee/translations/sensor.en.json new file mode 100644 index 0000000000000..a4b198eadf55a --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.en.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "High", + "low": "Low", + "moderate": "Moderate", + "very high": "Very High" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.es-419.json b/homeassistant/components/ambee/translations/sensor.es-419.json new file mode 100644 index 0000000000000..a676ca7aa5e64 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.es-419.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Alto", + "low": "Bajo", + "moderate": "Moderado", + "very high": "Muy alto" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.es.json b/homeassistant/components/ambee/translations/sensor.es.json new file mode 100644 index 0000000000000..a676ca7aa5e64 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.es.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Alto", + "low": "Bajo", + "moderate": "Moderado", + "very high": "Muy alto" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.et.json b/homeassistant/components/ambee/translations/sensor.et.json new file mode 100644 index 0000000000000..7599f2fd2c390 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.et.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "K\u00f5rge", + "low": "Madal", + "moderate": "M\u00f5\u00f5dukas", + "very high": "V\u00e4ga k\u00f5rge" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.fr.json b/homeassistant/components/ambee/translations/sensor.fr.json new file mode 100644 index 0000000000000..76dc3fe6301ff --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.fr.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Haute", + "low": "Faible", + "moderate": "Mod\u00e9rer", + "very high": "Tr\u00e8s \u00e9lev\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.he.json b/homeassistant/components/ambee/translations/sensor.he.json new file mode 100644 index 0000000000000..14ae06f2bc90e --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "ambee__risk": { + "high": "\u05d2\u05d1\u05d5\u05d4", + "low": "\u05e0\u05de\u05d5\u05da", + "very high": "\u05d2\u05d1\u05d5\u05d4 \u05de\u05d0\u05d5\u05d3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.hu.json b/homeassistant/components/ambee/translations/sensor.hu.json new file mode 100644 index 0000000000000..975d200a507ad --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.hu.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Magas", + "low": "Alacsony", + "moderate": "M\u00e9rs\u00e9kelt", + "very high": "Nagyon magas" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.id.json b/homeassistant/components/ambee/translations/sensor.id.json new file mode 100644 index 0000000000000..5cb74694da548 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.id.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Tinggi", + "low": "Rendah", + "moderate": "Sedang", + "very high": "Sangat Tinggi" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.it.json b/homeassistant/components/ambee/translations/sensor.it.json new file mode 100644 index 0000000000000..1c265a6ca53c0 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.it.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Alto", + "low": "Basso", + "moderate": "Moderato", + "very high": "Molto alto" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.ja.json b/homeassistant/components/ambee/translations/sensor.ja.json new file mode 100644 index 0000000000000..a750a257864e8 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.ja.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "\u9ad8\u3044", + "low": "\u4f4e\u3044", + "moderate": "\u9069\u5ea6", + "very high": "\u975e\u5e38\u306b\u9ad8\u3044" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.nl.json b/homeassistant/components/ambee/translations/sensor.nl.json new file mode 100644 index 0000000000000..e9ba0c76a342c --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.nl.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Hoog", + "low": "Laag", + "moderate": "Matig", + "very high": "Zeer hoog" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.no.json b/homeassistant/components/ambee/translations/sensor.no.json new file mode 100644 index 0000000000000..cf4e4bed6edd4 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.no.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "H\u00f8y", + "low": "Lav", + "moderate": "Moderat", + "very high": "Veldig h\u00f8y" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.pl.json b/homeassistant/components/ambee/translations/sensor.pl.json new file mode 100644 index 0000000000000..d67bdec08798d --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.pl.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "wysoki", + "low": "niski", + "moderate": "umiarkowany", + "very high": "bardzo wysoki" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.ru.json b/homeassistant/components/ambee/translations/sensor.ru.json new file mode 100644 index 0000000000000..c0dbe8cecd68d --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.ru.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "\u0412\u044b\u0441\u043e\u043a\u0438\u0439", + "low": "\u041d\u0438\u0437\u043a\u0438\u0439", + "moderate": "\u0423\u043c\u0435\u0440\u0435\u043d\u043d\u044b\u0439", + "very high": "\u041e\u0447\u0435\u043d\u044c \u0432\u044b\u0441\u043e\u043a\u0438\u0439" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.tr.json b/homeassistant/components/ambee/translations/sensor.tr.json new file mode 100644 index 0000000000000..087bea4ed993c --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.tr.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Y\u00fcksek", + "low": "D\u00fc\u015f\u00fck", + "moderate": "Moderate", + "very high": "\u00c7ok y\u00fcksek" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.zh-Hant.json b/homeassistant/components/ambee/translations/sensor.zh-Hant.json new file mode 100644 index 0000000000000..1e3c5bbe58d21 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.zh-Hant.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "\u9ad8", + "low": "\u4f4e", + "moderate": "\u4e2d", + "very high": "\u6975\u9ad8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/tr.json b/homeassistant/components/ambee/translations/tr.json new file mode 100644 index 0000000000000..45eacf30987c5 --- /dev/null +++ b/homeassistant/components/ambee/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131", + "description": "Ambee hesab\u0131n\u0131zla yeniden kimlik do\u011frulamas\u0131 yap\u0131n." + } + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Ambee'yi Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/zh-Hant.json b/homeassistant/components/ambee/translations/zh-Hant.json new file mode 100644 index 0000000000000..2e1de25fde2c4 --- /dev/null +++ b/homeassistant/components/ambee/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_api_key": "API \u91d1\u9470\u7121\u6548" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u91d1\u9470", + "description": "\u91cd\u65b0\u8a8d\u8b49 Ambee \u5e33\u865f\u3002" + } + }, + "user": { + "data": { + "api_key": "API \u91d1\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a Ambee \u4ee5\u6574\u5408\u81f3 Home Assistant\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py new file mode 100644 index 0000000000000..0d39077f2f199 --- /dev/null +++ b/homeassistant/components/amberelectric/__init__.py @@ -0,0 +1,32 @@ +"""Support for Amber Electric.""" + +from amberelectric import Configuration +from amberelectric.api import amber_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, PLATFORMS +from .coordinator import AmberUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Amber Electric from a config entry.""" + configuration = Configuration(access_token=entry.data[CONF_API_TOKEN]) + api_instance = amber_api.AmberApi.create(configuration) + site_id = entry.data[CONF_SITE_ID] + + coordinator = AmberUpdateCoordinator(hass, api_instance, site_id) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py new file mode 100644 index 0000000000000..422ff66db5901 --- /dev/null +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -0,0 +1,87 @@ +"""Amber Electric Binary Sensor definitions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import AmberUpdateCoordinator + +PRICE_SPIKE_ICONS = { + "none": "mdi:power-plug", + "potential": "mdi:power-plug-outline", + "spike": "mdi:power-plug-off", +} + + +class AmberPriceGridSensor(CoordinatorEntity, BinarySensorEntity): + """Sensor to show single grid binary values.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self._attr_unique_id = f"{coordinator.site_id}-{description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.data["grid"][self.entity_description.key] + + +class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): + """Sensor to show single grid binary values.""" + + @property + def icon(self): + """Return the sensor icon.""" + status = self.coordinator.data["grid"]["price_spike"] + return PRICE_SPIKE_ICONS[status] + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.data["grid"]["price_spike"] == "spike" + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price spike.""" + + spike_status = self.coordinator.data["grid"]["price_spike"] + return { + "spike_status": spike_status, + } + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list = [] + price_spike_description = BinarySensorEntityDescription( + key="price_spike", + name=f"{entry.title} - Price Spike", + ) + entities.append(AmberPriceSpikeBinarySensor(coordinator, price_spike_description)) + async_add_entities(entities) diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py new file mode 100644 index 0000000000000..efb5ddfb93193 --- /dev/null +++ b/homeassistant/components/amberelectric/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for the Amber Electric integration.""" +from __future__ import annotations + +from typing import Any + +import amberelectric +from amberelectric.api import amber_api +from amberelectric.model.site import Site +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN + +from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN + +API_URL = "https://app.amber.com.au/developers" + + +class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors: dict[str, str] = {} + self._sites: list[Site] | None = None + self._api_token: str | None = None + + def _fetch_sites(self, token: str) -> list[Site] | None: + configuration = amberelectric.Configuration(access_token=token) + api = amber_api.AmberApi.create(configuration) + + try: + sites = api.get_sites() + if len(sites) == 0: + self._errors[CONF_API_TOKEN] = "no_site" + return None + return sites + except amberelectric.ApiException as api_exception: + if api_exception.status == 403: + self._errors[CONF_API_TOKEN] = "invalid_api_token" + else: + self._errors[CONF_API_TOKEN] = "unknown_error" + return None + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Step when user initializes a integration.""" + self._errors = {} + self._sites = None + self._api_token = None + + if user_input is not None: + token = user_input[CONF_API_TOKEN] + self._sites = await self.hass.async_add_executor_job( + self._fetch_sites, token + ) + + if self._sites is not None: + self._api_token = token + return await self.async_step_site() + + else: + user_input = {CONF_API_TOKEN: ""} + + return self.async_show_form( + step_id="user", + description_placeholders={"api_url": API_URL}, + data_schema=vol.Schema( + { + vol.Required( + CONF_API_TOKEN, default=user_input[CONF_API_TOKEN] + ): str, + } + ), + errors=self._errors, + ) + + async def async_step_site(self, user_input: dict[str, Any] = None): + """Step to select site.""" + self._errors = {} + + assert self._sites is not None + + api_token = self._api_token + if user_input is not None: + site_nmi = user_input[CONF_SITE_NMI] + sites = [site for site in self._sites if site.nmi == site_nmi] + site = sites[0] + site_id = site.id + name = user_input.get(CONF_SITE_NAME, site_id) + return self.async_create_entry( + title=name, + data={ + CONF_SITE_ID: site_id, + CONF_API_TOKEN: api_token, + CONF_SITE_NMI: site.nmi, + }, + ) + + user_input = { + CONF_API_TOKEN: api_token, + CONF_SITE_NMI: "", + CONF_SITE_NAME: "", + } + + return self.async_show_form( + step_id="site", + data_schema=vol.Schema( + { + vol.Required( + CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] + ): vol.In([site.nmi for site in self._sites]), + vol.Optional( + CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] + ): str, + } + ), + errors=self._errors, + ) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py new file mode 100644 index 0000000000000..f3cda887150c2 --- /dev/null +++ b/homeassistant/components/amberelectric/const.py @@ -0,0 +1,15 @@ +"""Amber Electric Constants.""" +import logging + +from homeassistant.const import Platform + +DOMAIN = "amberelectric" +CONF_API_TOKEN = "api_token" +CONF_SITE_NAME = "site_name" +CONF_SITE_ID = "site_id" +CONF_SITE_NMI = "site_nmi" + +ATTRIBUTION = "Data provided by Amber Electric" + +LOGGER = logging.getLogger(__package__) +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py new file mode 100644 index 0000000000000..904da59f65c41 --- /dev/null +++ b/homeassistant/components/amberelectric/coordinator.py @@ -0,0 +1,111 @@ +"""Amber Electric Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from amberelectric import ApiException +from amberelectric.api import amber_api +from amberelectric.model.actual_interval import ActualInterval +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +def is_current(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is a CurrentInterval.""" + return isinstance(interval, CurrentInterval) + + +def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is a ForecastInterval.""" + return isinstance(interval, ForecastInterval) + + +def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is on the general channel.""" + return interval.channel_type == ChannelType.GENERAL + + +def is_controlled_load( + interval: ActualInterval | CurrentInterval | ForecastInterval, +) -> bool: + """Return true if the supplied interval is on the controlled load channel.""" + return interval.channel_type == ChannelType.CONTROLLED_LOAD + + +def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is on the feed in channel.""" + return interval.channel_type == ChannelType.FEED_IN + + +class AmberUpdateCoordinator(DataUpdateCoordinator): + """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" + + def __init__( + self, hass: HomeAssistant, api: amber_api.AmberApi, site_id: str + ) -> None: + """Initialise the data service.""" + super().__init__( + hass, + LOGGER, + name="amberelectric", + update_interval=timedelta(minutes=1), + ) + self._api = api + self.site_id = site_id + + def update_price_data(self) -> dict[str, dict[str, Any]]: + """Update callback.""" + + result: dict[str, dict[str, Any]] = { + "current": {}, + "forecasts": {}, + "grid": {}, + } + try: + data = self._api.get_current_price(self.site_id, next=48) + except ApiException as api_exception: + raise UpdateFailed("Missing price data, skipping update") from api_exception + + current = [interval for interval in data if is_current(interval)] + forecasts = [interval for interval in data if is_forecast(interval)] + general = [interval for interval in current if is_general(interval)] + + if len(general) == 0: + raise UpdateFailed("No general channel configured") + + result["current"]["general"] = general[0] + result["forecasts"]["general"] = [ + interval for interval in forecasts if is_general(interval) + ] + result["grid"]["renewables"] = round(general[0].renewables) + result["grid"]["price_spike"] = general[0].spike_status.value + + controlled_load = [ + interval for interval in current if is_controlled_load(interval) + ] + if controlled_load: + result["current"]["controlled_load"] = controlled_load[0] + result["forecasts"]["controlled_load"] = [ + interval for interval in forecasts if is_controlled_load(interval) + ] + + feed_in = [interval for interval in current if is_feed_in(interval)] + if feed_in: + result["current"]["feed_in"] = feed_in[0] + result["forecasts"]["feed_in"] = [ + interval for interval in forecasts if is_feed_in(interval) + ] + + LOGGER.debug("Fetched new Amber data: %s", data) + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Async update wrapper.""" + return await self.hass.async_add_executor_job(self.update_price_data) diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json new file mode 100644 index 0000000000000..6dc79513e5529 --- /dev/null +++ b/homeassistant/components/amberelectric/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "amberelectric", + "name": "Amber Electric", + "documentation": "https://www.home-assistant.io/integrations/amberelectric", + "config_flow": true, + "codeowners": [ + "@madpilot" + ], + "requirements": [ + "amberelectric==1.0.3" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py new file mode 100644 index 0000000000000..64ff09470e5de --- /dev/null +++ b/homeassistant/components/amberelectric/sensor.py @@ -0,0 +1,236 @@ +"""Amber Electric Sensor definitions.""" + +# There are three types of sensor: Current, Forecast and Grid +# Current and forecast will create general, controlled load and feed in as required +# At the moment renewables in the only grid sensor. + + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_DOLLAR, ENERGY_KILO_WATT_HOUR +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import AmberUpdateCoordinator + +ICONS = { + "general": "mdi:transmission-tower", + "controlled_load": "mdi:clock-outline", + "feed_in": "mdi:solar-power", +} + +UNIT = f"{CURRENCY_DOLLAR}/{ENERGY_KILO_WATT_HOUR}" + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) + + +def friendly_channel_type(channel_type: str) -> str: + """Return a human readable version of the channel type.""" + if channel_type == "controlled_load": + return "Controlled Load" + if channel_type == "feed_in": + return "Feed In" + return "General" + + +class AmberSensor(CoordinatorEntity, SensorEntity): + """Amber Base Sensor.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: SensorEntityDescription, + channel_type: ChannelType, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self.channel_type = channel_type + + self._attr_unique_id = ( + f"{self.site_id}-{self.entity_description.key}-{self.channel_type}" + ) + + +class AmberPriceSensor(AmberSensor): + """Amber Price Sensor.""" + + @property + def native_value(self) -> float | None: + """Return the current price in $/kWh.""" + interval = self.coordinator.data[self.entity_description.key][self.channel_type] + + if interval.channel_type == ChannelType.FEED_IN: + return format_cents_to_dollars(interval.per_kwh) * -1 + return format_cents_to_dollars(interval.per_kwh) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price.""" + interval = self.coordinator.data[self.entity_description.key][self.channel_type] + + data: dict[str, Any] = {} + if interval is None: + return data + + data["duration"] = interval.duration + data["date"] = interval.date.isoformat() + data["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEED_IN: + data["per_kwh"] = data["per_kwh"] * -1 + data["nem_date"] = interval.nem_time.isoformat() + data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + data["start_time"] = interval.start_time.isoformat() + data["end_time"] = interval.end_time.isoformat() + data["renewables"] = round(interval.renewables) + data["estimate"] = interval.estimate + data["spike_status"] = interval.spike_status.value + data["channel_type"] = interval.channel_type.value + + if interval.range is not None: + data["range_min"] = format_cents_to_dollars(interval.range.min) + data["range_max"] = format_cents_to_dollars(interval.range.max) + + return data + + +class AmberForecastSensor(AmberSensor): + """Amber Forecast Sensor.""" + + @property + def native_value(self) -> float | None: + """Return the first forecast price in $/kWh.""" + intervals = self.coordinator.data[self.entity_description.key].get( + self.channel_type + ) + if not intervals: + return None + interval = intervals[0] + + if interval.channel_type == ChannelType.FEED_IN: + return format_cents_to_dollars(interval.per_kwh) * -1 + return format_cents_to_dollars(interval.per_kwh) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price.""" + intervals = self.coordinator.data[self.entity_description.key].get( + self.channel_type + ) + + if not intervals: + return None + + data = { + "forecasts": [], + "channel_type": intervals[0].channel_type.value, + } + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEED_IN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + data["forecasts"].append(datum) + + return data + + +class AmberGridSensor(CoordinatorEntity, SensorEntity): + """Sensor to show single grid specific values.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self._attr_unique_id = f"{coordinator.site_id}-{description.key}" + + @property + def native_value(self) -> str | None: + """Return the value of the sensor.""" + return self.coordinator.data["grid"][self.entity_description.key] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + current: dict[str, CurrentInterval] = coordinator.data["current"] + forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"] + + entities: list = [] + for channel_type in current: + description = SensorEntityDescription( + key="current", + name=f"{entry.title} - {friendly_channel_type(channel_type)} Price", + native_unit_of_measurement=UNIT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICONS[channel_type], + ) + entities.append(AmberPriceSensor(coordinator, description, channel_type)) + + for channel_type in forecasts: + description = SensorEntityDescription( + key="forecasts", + name=f"{entry.title} - {friendly_channel_type(channel_type)} Forecast", + native_unit_of_measurement=UNIT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICONS[channel_type], + ) + entities.append(AmberForecastSensor(coordinator, description, channel_type)) + + renewables_description = SensorEntityDescription( + key="renewables", + name=f"{entry.title} - Renewables", + native_unit_of_measurement="%", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:solar-power", + ) + entities.append(AmberGridSensor(coordinator, renewables_description)) + + async_add_entities(entities) diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json new file mode 100644 index 0000000000000..cdbff2022b348 --- /dev/null +++ b/homeassistant/components/amberelectric/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "title": "Amber Electric", + "description": "Go to {api_url} to generate an API key" + }, + "site": { + "data": { + "site_nmi": "Site NMI", + "site_name": "Site Name" + }, + "title": "Amber Electric", + "description": "Select the NMI of the site you would like to add" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/bg.json b/homeassistant/components/amberelectric/translations/bg.json new file mode 100644 index 0000000000000..5cfcc05b1334e --- /dev/null +++ b/homeassistant/components/amberelectric/translations/bg.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "site": { + "title": "Amber Electric" + }, + "user": { + "description": "\u041e\u0442\u0438\u0434\u0435\u0442\u0435 \u043d\u0430 {api_url}, \u0437\u0430 \u0434\u0430 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u0442\u0435 API \u043a\u043b\u044e\u0447", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ca.json b/homeassistant/components/amberelectric/translations/ca.json new file mode 100644 index 0000000000000..cf9bca64df65c --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nom del lloc", + "site_nmi": "NMI del lloc" + }, + "description": "Selecciona l'NMI del lloc que vulguis afegir", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token d'API", + "site_id": "ID del lloc" + }, + "description": "Ves a {api_url} per generar una clau API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/de.json b/homeassistant/components/amberelectric/translations/de.json new file mode 100644 index 0000000000000..2143795f479de --- /dev/null +++ b/homeassistant/components/amberelectric/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Name des Standorts", + "site_nmi": "Standort NMI" + }, + "description": "W\u00e4hle die NMI des Standorts, den du hinzuf\u00fcgen m\u00f6chtest", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API-Token", + "site_id": "Site-ID" + }, + "description": "Gehe zu {api_url}, um einen API-Schl\u00fcssel zu generieren", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/en.json b/homeassistant/components/amberelectric/translations/en.json new file mode 100644 index 0000000000000..60c7caae45690 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Site Name", + "site_nmi": "Site NMI" + }, + "description": "Select the NMI of the site you would like to add", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "description": "Go to {api_url} to generate an API key", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/et.json b/homeassistant/components/amberelectric/translations/et.json new file mode 100644 index 0000000000000..05a7e6c6dc2f2 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Saidi nimi", + "site_nmi": "Saidi NMI" + }, + "description": "Vali lisatava saidi NMI", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API v\u00f5ti", + "site_id": "Saidi ID" + }, + "description": "API-v\u00f5tme saamiseks ava {api_url}.", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/fr.json b/homeassistant/components/amberelectric/translations/fr.json new file mode 100644 index 0000000000000..487ceff33f362 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nom du site", + "site_nmi": "Site NMI" + }, + "description": "S\u00e9lectionnez le NMI du site que vous souhaitez ajouter", + "title": "Amber Electrique" + }, + "user": { + "data": { + "api_token": "Jeton d'API", + "site_id": "ID du site" + }, + "description": "Acc\u00e9dez \u00e0 {api_url} pour g\u00e9n\u00e9rer une cl\u00e9 API", + "title": "Amber Electrique" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/hu.json b/homeassistant/components/amberelectric/translations/hu.json new file mode 100644 index 0000000000000..9811f5a5f8f82 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Hely neve", + "site_nmi": "Hely NMI" + }, + "description": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt hely NMI-j\u00e9t.", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Hely ID" + }, + "description": "API-kulcs gener\u00e1l\u00e1s\u00e1hoz l\u00e1togasson el ide: {api_url}", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/id.json b/homeassistant/components/amberelectric/translations/id.json new file mode 100644 index 0000000000000..4920ee7b17702 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nama Situs", + "site_nmi": "Situs NMI" + }, + "description": "Pilih NMI dari situs yang ingin ditambahkan", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token API", + "site_id": "ID Site" + }, + "description": "Buka {api_url} untuk membuat kunci API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/it.json b/homeassistant/components/amberelectric/translations/it.json new file mode 100644 index 0000000000000..5b061561954c9 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nome del sito", + "site_nmi": "Sito NMI" + }, + "description": "Seleziona l'NMI del sito che desideri aggiungere", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token API", + "site_id": "ID sito" + }, + "description": "Vai su {api_url} per generare una chiave API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ja.json b/homeassistant/components/amberelectric/translations/ja.json new file mode 100644 index 0000000000000..1fc5f6c58c18f --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u30b5\u30a4\u30c8\u540d", + "site_nmi": "\u30b5\u30a4\u30c8NMI" + }, + "description": "\u8ffd\u52a0\u3057\u305f\u3044\u30b5\u30a4\u30c8\u306eNMI\u3092\u9078\u629e", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API\u30c8\u30fc\u30af\u30f3", + "site_id": "\u30b5\u30a4\u30c8ID" + }, + "description": "API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u305f\u3081\u306b {api_url} \u306b\u30a2\u30af\u30bb\u30b9\u3057\u307e\u3059", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/nl.json b/homeassistant/components/amberelectric/translations/nl.json new file mode 100644 index 0000000000000..a874c12f283c9 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Sitenaam", + "site_nmi": "Site NMI" + }, + "description": "Selecteer de NMI van de site die u wilt toevoegen", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "description": "Ga naar {api_url} om een API sleutel aan te maken", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/no.json b/homeassistant/components/amberelectric/translations/no.json new file mode 100644 index 0000000000000..90d4bd930b950 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Side navn", + "site_nmi": "Nettsted NMI" + }, + "description": "Velg NMI for nettstedet du vil legge til", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API-token", + "site_id": "Nettsted -ID" + }, + "description": "G\u00e5 til {api_url} \u00e5 generere en API -n\u00f8kkel", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/pl.json b/homeassistant/components/amberelectric/translations/pl.json new file mode 100644 index 0000000000000..1e9b66e3c3c34 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nazwa obiektu", + "site_nmi": "Numer identyfikacyjny (NMI) obiektu" + }, + "description": "Wybierz NMI obiektu, kt\u00f3ry chcesz doda\u0107", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token API", + "site_id": "Identyfikator obiektu" + }, + "description": "Przejd\u017a do {api_url}, aby wygenerowa\u0107 klucz API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ru.json b/homeassistant/components/amberelectric/translations/ru.json new file mode 100644 index 0000000000000..4b8caee72eec5 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0447\u0430\u0441\u0442\u043a\u0430", + "site_nmi": "NMI \u0443\u0447\u0430\u0441\u0442\u043a\u0430" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 NMI \u0443\u0447\u0430\u0441\u0442\u043a\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "\u0422\u043e\u043a\u0435\u043d API", + "site_id": "ID \u0443\u0447\u0430\u0441\u0442\u043a\u0430" + }, + "description": "\u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 {api_url} \u0447\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API.", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/tr.json b/homeassistant/components/amberelectric/translations/tr.json new file mode 100644 index 0000000000000..274a347e57811 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/tr.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Site Ad\u0131", + "site_nmi": "Site NMI" + }, + "description": "Eklemek istedi\u011finiz sitenin NMI'sini se\u00e7in", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Anahtar\u0131", + "site_id": "Site Kimli\u011fi" + }, + "description": "API anahtar\u0131 olu\u015fturmak i\u00e7in {api_url} konumuna gidin", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/zh-Hant.json b/homeassistant/components/amberelectric/translations/zh-Hant.json new file mode 100644 index 0000000000000..c2cbef2778bcf --- /dev/null +++ b/homeassistant/components/amberelectric/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u4f4d\u5740\u540d\u7a31", + "site_nmi": "\u4f4d\u5740 NMI" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u4f4d\u5740 NMI", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API \u6b0a\u6756", + "site_id": "\u4f4d\u5740 ID" + }, + "description": "\u9023\u7dda\u81f3 {api_url} \u4ee5\u7522\u751f API \u91d1\u9470", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py index e9247b9fd7389..18cf8c177d99b 100644 --- a/homeassistant/components/ambiclimate/__init__.py +++ b/homeassistant/components/ambiclimate/__init__.py @@ -1,7 +1,9 @@ """Support for Ambiclimate devices.""" import voluptuous as vol -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from . import config_flow @@ -19,8 +21,10 @@ extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [Platform.CLIMATE] -async def async_setup(hass, config): + +async def async_setup(hass, config) -> bool: """Set up Ambiclimate components.""" if DOMAIN not in config: return True @@ -34,10 +38,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ambiclimate from a config entry.""" - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "climate") - ) - + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 93b3897446440..67fd3adeec713 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -1,6 +1,7 @@ """Support for Ambiclimate ac.""" import asyncio import logging +from typing import Any import ambiclimate import voluptuous as vol @@ -20,6 +21,7 @@ ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo from .const import ( ATTR_VALUE, @@ -137,92 +139,30 @@ async def set_temperature_mode(service): class AmbiclimateEntity(ClimateEntity): """Representation of a Ambiclimate Thermostat device.""" + _attr_temperature_unit = TEMP_CELSIUS + _attr_target_temperature_step = 1 + _attr_supported_features = SUPPORT_FLAGS + _attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + def __init__(self, heater, store): """Initialize the thermostat.""" self._heater = heater self._store = store - self._data = {} - - @property - def unique_id(self): - """Return a unique ID.""" - return self._heater.device_id - - @property - def name(self): - """Return the name of the entity.""" - return self._heater.name - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Ambiclimate", - } - - @property - def temperature_unit(self): - """Return the unit of measurement which this thermostat uses.""" - return TEMP_CELSIUS - - @property - def target_temperature(self): - """Return the target temperature.""" - return self._data.get("target_temperature") - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._data.get("temperature") - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._data.get("humidity") - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._heater.get_min_temp() - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._heater.get_max_temp() - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] - - @property - def hvac_mode(self): - """Return current operation.""" - if self._data.get("power", "").lower() == "on": - return HVAC_MODE_HEAT - - return HVAC_MODE_OFF - - async def async_set_temperature(self, **kwargs): + self._attr_unique_id = heater.device_id + self._attr_name = heater.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Ambiclimate", + name=self.name, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return await self._heater.set_target_temperature(temperature) - async def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: await self._heater.turn_on() @@ -230,7 +170,7 @@ async def async_set_hvac_mode(self, hvac_mode): if hvac_mode == HVAC_MODE_OFF: await self._heater.turn_off() - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest state.""" try: token_info = await self._heater.control.refresh_access_token() @@ -241,4 +181,12 @@ async def async_update(self): if token_info: await self._store.async_save(token_info) - self._data = await self._heater.update_device() + data = await self._heater.update_device() + self._attr_min_temp = self._heater.get_min_temp() + self._attr_max_temp = self._heater.get_max_temp() + self._attr_target_temperature = data.get("target_temperature") + self._attr_current_temperature = data.get("temperature") + self._attr_current_humidity = data.get("humidity") + self._attr_hvac_mode = ( + HVAC_MODE_HEAT if data.get("power", "").lower() == "on" else HVAC_MODE_OFF + ) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index d714a6bc2a6de..00fa339b0d898 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Ambiclimate.""" import logging +from aiohttp import web import ambiclimate from homeassistant import config_entries @@ -50,8 +51,7 @@ def __init__(self): async def async_step_user(self, user_input=None): """Handle external yaml configuration.""" - if self.hass.config_entries.async_entries(DOMAIN): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match() config = self.hass.data.get(DATA_AMBICLIMATE_IMPL, {}) @@ -63,8 +63,7 @@ async def async_step_user(self, user_input=None): async def async_step_auth(self, user_input=None): """Handle a flow start.""" - if self.hass.config_entries.async_entries(DOMAIN): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match() errors = {} @@ -85,12 +84,9 @@ async def async_step_auth(self, user_input=None): async def async_step_code(self, code=None): """Received code for authentication.""" - if self.hass.config_entries.async_entries(DOMAIN): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match() - token_info = await self._get_token_info(code) - - if token_info is None: + if await self._get_token_info(code) is None: return self.async_abort(reason="access_token") config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy() @@ -128,7 +124,7 @@ def _generate_oauth(self): ) def _cb_url(self): - return f"{get_url(self.hass)}{AUTH_CALLBACK_PATH}" + return f"{get_url(self.hass, prefer_external=True)}{AUTH_CALLBACK_PATH}" async def _get_authorize_url(self): oauth = self._generate_oauth() @@ -142,10 +138,10 @@ class AmbiclimateAuthCallbackView(HomeAssistantView): url = AUTH_CALLBACK_PATH name = AUTH_CALLBACK_NAME - async def get(self, request): + async def get(self, request: web.Request) -> str: """Receive authorization token.""" - code = request.query.get("code") - if code is None: + # pylint: disable=no-self-use + if (code := request.query.get("code")) is None: return "No code" hass = request.app["hass"] hass.async_create_task( diff --git a/homeassistant/components/ambiclimate/translations/bg.json b/homeassistant/components/ambiclimate/translations/bg.json index 627dd472018a6..35a413e3627d2 100644 --- a/homeassistant/components/ambiclimate/translations/bg.json +++ b/homeassistant/components/ambiclimate/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "access_token": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f." + "access_token": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f.", + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" }, "create_entry": { "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate." diff --git a/homeassistant/components/ambiclimate/translations/ca.json b/homeassistant/components/ambiclimate/translations/ca.json index 8e54a22221751..234cb1a413c13 100644 --- a/homeassistant/components/ambiclimate/translations/ca.json +++ b/homeassistant/components/ambiclimate/translations/ca.json @@ -2,7 +2,7 @@ "config": { "abort": { "access_token": "S'ha produ\u00eft un error desconegut al generat un token d'acc\u00e9s.", - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3." }, "create_entry": { diff --git a/homeassistant/components/ambiclimate/translations/de.json b/homeassistant/components/ambiclimate/translations/de.json index d91fc15f37d9e..3f4537a5d5c15 100644 --- a/homeassistant/components/ambiclimate/translations/de.json +++ b/homeassistant/components/ambiclimate/translations/de.json @@ -6,7 +6,7 @@ "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen." }, "create_entry": { - "default": "Erfolgreiche Authentifizierung mit Ambiclimate" + "default": "Erfolgreich authentifiziert" }, "error": { "follow_link": "Bitte folge dem Link und authentifizieren dich, bevor du auf Senden klickst", diff --git a/homeassistant/components/ambiclimate/translations/fr.json b/homeassistant/components/ambiclimate/translations/fr.json index 37ef95496860d..b6464b58244b5 100644 --- a/homeassistant/components/ambiclimate/translations/fr.json +++ b/homeassistant/components/ambiclimate/translations/fr.json @@ -6,7 +6,7 @@ "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." }, "create_entry": { - "default": "Authentifi\u00e9 avec succ\u00e8s avec Ambiclimate" + "default": "Authentification r\u00e9ussie" }, "error": { "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.", diff --git a/homeassistant/components/ambiclimate/translations/he.json b/homeassistant/components/ambiclimate/translations/he.json new file mode 100644 index 0000000000000..dc9f86871a70c --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4.", + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3." + }, + "create_entry": { + "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" + }, + "error": { + "follow_link": "\u05d9\u05e9 \u05dc\u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d5\u05dc\u05d0\u05de\u05ea \u05d0\u05d5\u05ea\u05d5 \u05dc\u05e4\u05e0\u05d9 \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc \u05e9\u05dc\u05d7", + "no_token": "\u05dc\u05d0 \u05de\u05d0\u05d5\u05de\u05ea \u05e2\u05dd Ambiclimate" + }, + "step": { + "auth": { + "description": "\u05e0\u05d0 \u05dc\u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8\u05d9 [\u05e7\u05d9\u05e9\u05d5\u05e8]({authorization_url}) **\u05d5\u05dc\u05d0\u05e4\u05e9\u05e8** \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05d4-Ambiclimate \u05e9\u05dc\u05da, \u05d5\u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df \u05dc\u05d7\u05d6\u05d5\u05e8 \u05d5\u05dc\u05dc\u05d7\u05d5\u05e5 \u05e2\u05dc **\u05e9\u05dc\u05d7** \u05dc\u05de\u05d8\u05d4.\n(\u05e0\u05d0 \u05dc\u05d5\u05d5\u05d3\u05d0 \u05e9\u05d4\u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05d4\u05ea\u05e7\u05e9\u05e8\u05d5\u05ea \u05d7\u05d5\u05d6\u05e8\u05ea \u05d4\u05d5\u05d0 {cb_url})", + "title": "\u05d0\u05de\u05ea \u05d0\u05ea Ambiclimate" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/hu.json b/homeassistant/components/ambiclimate/translations/hu.json index 04035f04ccae6..1e67873f1aa3c 100644 --- a/homeassistant/components/ambiclimate/translations/hu.json +++ b/homeassistant/components/ambiclimate/translations/hu.json @@ -1,11 +1,22 @@ { "config": { "abort": { + "access_token": "Ismeretlen hiba a hozz\u00e1f\u00e9r\u00e9si token gener\u00e1l\u00e1s\u00e1ban.", "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" + }, + "error": { + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", + "no_token": "Ambiclimate-al nem siker\u00fclt a hiteles\u00edt\u00e9s" + }, + "step": { + "auth": { + "description": "K\u00e9rj\u00fck, k\u00f6vesse ezt a [link]({authorization_url}}) \u00e9s ** Enged\u00e9lyezze ** a hozz\u00e1f\u00e9r\u00e9st Ambiclimate -fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot.\n(Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott visszah\u00edv\u00e1si URL {cb_url})", + "title": "Ambiclimate hiteles\u00edt\u00e9se" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/it.json b/homeassistant/components/ambiclimate/translations/it.json index 618d4cbe7ca32..ccf0836ee1dd7 100644 --- a/homeassistant/components/ambiclimate/translations/it.json +++ b/homeassistant/components/ambiclimate/translations/it.json @@ -15,7 +15,7 @@ "step": { "auth": { "description": "Segui questo [link]({authorization_url}) e **Consenti** l'accesso al tuo account Ambiclimate, quindi torna indietro e premi **Invia** qui sotto. \n(Assicurati che l'URL di richiamata specificato sia {cb_url})", - "title": "Autenticare Ambiclimate" + "title": "Autentica Ambiclimate" } } } diff --git a/homeassistant/components/ambiclimate/translations/ja.json b/homeassistant/components/ambiclimate/translations/ja.json new file mode 100644 index 0000000000000..a6fbfc256ead7 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u306e\u751f\u6210\u4e2d\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002", + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "error": { + "follow_link": "\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u524d\u306b\u3001\u4e8b\u524d\u306b\u30ea\u30f3\u30af\u3092\u305f\u3069\u3063\u3066\u8a8d\u8a3c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "no_token": "Ambiclimate\u3067\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + }, + "step": { + "auth": { + "description": "\u3053\u306e[\u30ea\u30f3\u30af]({authorization_url}) \u306b\u5f93\u3044\u3001Ambiclimate\u30a2\u30ab\u30a6\u30f3\u30c8\u3078\u306e\u30a2\u30af\u30bb\u30b9\u3092 **\u8a31\u53ef(Allow)** \u3057\u3066\u304b\u3089\u3001\u623b\u3063\u3066\u304d\u3066\u4ee5\u4e0b\u306e **\u9001\u4fe1(submit)** \u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n(\u6307\u5b9a\u3055\u308c\u305f\u30b3\u30fc\u30eb\u30d0\u30c3\u30afURL\u304c {cb_url} \u3067\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044)", + "title": "Ambiclimate\u3092\u8a8d\u8a3c\u3059\u308b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/tr.json b/homeassistant/components/ambiclimate/translations/tr.json index bcaeba8455875..76d0292dd3d7d 100644 --- a/homeassistant/components/ambiclimate/translations/tr.json +++ b/homeassistant/components/ambiclimate/translations/tr.json @@ -1,7 +1,22 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "access_token": "Eri\u015fim anahtar\u0131 olu\u015ftururken bilinmeyen hata.", + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin." + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, + "error": { + "follow_link": "L\u00fctfen ba\u011flant\u0131y\u0131 takip edin ve G\u00f6nder'e basmadan \u00f6nce kimlik do\u011frulamas\u0131 yap\u0131n", + "no_token": "Ambiclimate ile kimli\u011fi do\u011frulanmad\u0131" + }, + "step": { + "auth": { + "description": "L\u00fctfen bu [ba\u011flant\u0131y\u0131]( {authorization_url} ) takip edin ve Ambiclimate hesab\u0131n\u0131za **izin verin**, ard\u0131ndan geri d\u00f6n\u00fcn ve a\u015fa\u011f\u0131daki **G\u00f6nder**'e bas\u0131n.\n (Belirtilen geri \u00e7a\u011f\u0131rma URL'sinin {cb_url} oldu\u011fundan emin olun)", + "title": "Ambiclimate kimlik do\u011frulamas\u0131" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 9036a4d89a213..fb4a865a72e14 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -1,360 +1,89 @@ """Support for Ambient Weather Station Service.""" +from __future__ import annotations -from aioambient import Client +from typing import Any + +from aioambient import Websocket from aioambient.errors import WebsocketError -import voluptuous as vol +from aioambient.util import get_public_device_id -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DOMAIN as BINARY_SENSOR, -) -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, - DEGREE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, EVENT_HOMEASSISTANT_STOP, - IRRADIATION_WATTS_PER_SQUARE_METER, - LIGHT_LUX, - PERCENTAGE, - PRESSURE_INHG, - SPEED_MILES_PER_HOUR, - TEMP_FAHRENHEIT, + Platform, ) -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import config_validation as cv +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +import homeassistant.helpers.entity_registry as er from .const import ( ATTR_LAST_DATA, - ATTR_MONITORED_CONDITIONS, CONF_APP_KEY, - DATA_CLIENT, DOMAIN, LOGGER, + TYPE_SOLARRADIATION, + TYPE_SOLARRADIATION_LX, ) -PLATFORMS = [BINARY_SENSOR, SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] DATA_CONFIG = "config" DEFAULT_SOCKET_MIN_RETRY = 15 -TYPE_24HOURRAININ = "24hourrainin" -TYPE_BAROMABSIN = "baromabsin" -TYPE_BAROMRELIN = "baromrelin" -TYPE_BATT1 = "batt1" -TYPE_BATT10 = "batt10" -TYPE_BATT2 = "batt2" -TYPE_BATT3 = "batt3" -TYPE_BATT4 = "batt4" -TYPE_BATT5 = "batt5" -TYPE_BATT6 = "batt6" -TYPE_BATT7 = "batt7" -TYPE_BATT8 = "batt8" -TYPE_BATT9 = "batt9" -TYPE_BATT_CO2 = "batt_co2" -TYPE_BATTOUT = "battout" -TYPE_CO2 = "co2" -TYPE_DAILYRAININ = "dailyrainin" -TYPE_DEWPOINT = "dewPoint" -TYPE_EVENTRAININ = "eventrainin" -TYPE_FEELSLIKE = "feelsLike" -TYPE_HOURLYRAININ = "hourlyrainin" -TYPE_HUMIDITY = "humidity" -TYPE_HUMIDITY1 = "humidity1" -TYPE_HUMIDITY10 = "humidity10" -TYPE_HUMIDITY2 = "humidity2" -TYPE_HUMIDITY3 = "humidity3" -TYPE_HUMIDITY4 = "humidity4" -TYPE_HUMIDITY5 = "humidity5" -TYPE_HUMIDITY6 = "humidity6" -TYPE_HUMIDITY7 = "humidity7" -TYPE_HUMIDITY8 = "humidity8" -TYPE_HUMIDITY9 = "humidity9" -TYPE_HUMIDITYIN = "humidityin" -TYPE_LASTRAIN = "lastRain" -TYPE_MAXDAILYGUST = "maxdailygust" -TYPE_MONTHLYRAININ = "monthlyrainin" -TYPE_PM25 = "pm25" -TYPE_PM25_24H = "pm25_24h" -TYPE_PM25_BATT = "batt_25" -TYPE_PM25_IN = "pm25_in" -TYPE_PM25_IN_24H = "pm25_in_24h" -TYPE_PM25IN_BATT = "batt_25in" -TYPE_RELAY1 = "relay1" -TYPE_RELAY10 = "relay10" -TYPE_RELAY2 = "relay2" -TYPE_RELAY3 = "relay3" -TYPE_RELAY4 = "relay4" -TYPE_RELAY5 = "relay5" -TYPE_RELAY6 = "relay6" -TYPE_RELAY7 = "relay7" -TYPE_RELAY8 = "relay8" -TYPE_RELAY9 = "relay9" -TYPE_SOILHUM1 = "soilhum1" -TYPE_SOILHUM10 = "soilhum10" -TYPE_SOILHUM2 = "soilhum2" -TYPE_SOILHUM3 = "soilhum3" -TYPE_SOILHUM4 = "soilhum4" -TYPE_SOILHUM5 = "soilhum5" -TYPE_SOILHUM6 = "soilhum6" -TYPE_SOILHUM7 = "soilhum7" -TYPE_SOILHUM8 = "soilhum8" -TYPE_SOILHUM9 = "soilhum9" -TYPE_SOILTEMP1F = "soiltemp1f" -TYPE_SOILTEMP10F = "soiltemp10f" -TYPE_SOILTEMP2F = "soiltemp2f" -TYPE_SOILTEMP3F = "soiltemp3f" -TYPE_SOILTEMP4F = "soiltemp4f" -TYPE_SOILTEMP5F = "soiltemp5f" -TYPE_SOILTEMP6F = "soiltemp6f" -TYPE_SOILTEMP7F = "soiltemp7f" -TYPE_SOILTEMP8F = "soiltemp8f" -TYPE_SOILTEMP9F = "soiltemp9f" -TYPE_SOLARRADIATION = "solarradiation" -TYPE_SOLARRADIATION_LX = "solarradiation_lx" -TYPE_TEMP10F = "temp10f" -TYPE_TEMP1F = "temp1f" -TYPE_TEMP2F = "temp2f" -TYPE_TEMP3F = "temp3f" -TYPE_TEMP4F = "temp4f" -TYPE_TEMP5F = "temp5f" -TYPE_TEMP6F = "temp6f" -TYPE_TEMP7F = "temp7f" -TYPE_TEMP8F = "temp8f" -TYPE_TEMP9F = "temp9f" -TYPE_TEMPF = "tempf" -TYPE_TEMPINF = "tempinf" -TYPE_TOTALRAININ = "totalrainin" -TYPE_UV = "uv" -TYPE_WEEKLYRAININ = "weeklyrainin" -TYPE_WINDDIR = "winddir" -TYPE_WINDDIR_AVG10M = "winddir_avg10m" -TYPE_WINDDIR_AVG2M = "winddir_avg2m" -TYPE_WINDGUSTDIR = "windgustdir" -TYPE_WINDGUSTMPH = "windgustmph" -TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" -TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" -TYPE_WINDSPEEDMPH = "windspeedmph" -TYPE_YEARLYRAININ = "yearlyrainin" -SENSOR_TYPES = { - TYPE_24HOURRAININ: ("24 Hr Rain", "in", SENSOR, None), - TYPE_BAROMABSIN: ("Abs Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), - TYPE_BAROMRELIN: ("Rel Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), - TYPE_BATT10: ("Battery 10", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT1: ("Battery 1", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT2: ("Battery 2", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT3: ("Battery 3", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT4: ("Battery 4", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT5: ("Battery 5", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT6: ("Battery 6", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT7: ("Battery 7", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT8: ("Battery 8", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT9: ("Battery 9", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATTOUT: ("Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT_CO2: ("CO2 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, SENSOR, DEVICE_CLASS_CO2), - TYPE_DAILYRAININ: ("Daily Rain", "in", SENSOR, None), - TYPE_DEWPOINT: ("Dew Point", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_EVENTRAININ: ("Event Rain", "in", SENSOR, None), - TYPE_FEELSLIKE: ("Feels Like", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", SENSOR, None), - TYPE_HUMIDITY10: ("Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY1: ("Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY2: ("Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY3: ("Humidity 3", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY4: ("Humidity 4", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY5: ("Humidity 5", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY6: ("Humidity 6", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY7: ("Humidity 7", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY8: ("Humidity 8", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY9: ("Humidity 9", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY: ("Humidity", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITYIN: ("Humidity In", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_LASTRAIN: ("Last Rain", None, SENSOR, DEVICE_CLASS_TIMESTAMP), - TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_MONTHLYRAININ: ("Monthly Rain", "in", SENSOR, None), - TYPE_PM25_24H: ( - "PM25 24h Avg", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25_BATT: ("PM25 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_PM25_IN: ( - "PM25 Indoor", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25_IN_24H: ( - "PM25 Indoor 24h Avg", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25: ("PM25", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR, None), - TYPE_PM25IN_BATT: ( - "PM25 Indoor Battery", - None, - BINARY_SENSOR, - DEVICE_CLASS_BATTERY, - ), - TYPE_RELAY10: ("Relay 10", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY1: ("Relay 1", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY2: ("Relay 2", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY3: ("Relay 3", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY4: ("Relay 4", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY5: ("Relay 5", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY6: ("Relay 6", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY7: ("Relay 7", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY8: ("Relay 8", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY9: ("Relay 9", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_SOILHUM10: ("Soil Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM1: ("Soil Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM2: ("Soil Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM3: ("Soil Humidity 3", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM4: ("Soil Humidity 4", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM5: ("Soil Humidity 5", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM6: ("Soil Humidity 6", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM7: ("Soil Humidity 7", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM8: ("Soil Humidity 8", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM9: ("Soil Humidity 9", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILTEMP10F: ( - "Soil Temp 10", - TEMP_FAHRENHEIT, - SENSOR, - DEVICE_CLASS_TEMPERATURE, - ), - TYPE_SOILTEMP1F: ("Soil Temp 1", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP2F: ("Soil Temp 2", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP3F: ("Soil Temp 3", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP4F: ("Soil Temp 4", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP5F: ("Soil Temp 5", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP6F: ("Soil Temp 6", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP7F: ("Soil Temp 7", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP8F: ("Soil Temp 8", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP9F: ("Soil Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOLARRADIATION: ( - "Solar Rad", - IRRADIATION_WATTS_PER_SQUARE_METER, - SENSOR, - None, - ), - TYPE_SOLARRADIATION_LX: ( - "Solar Rad (lx)", - LIGHT_LUX, - SENSOR, - DEVICE_CLASS_ILLUMINANCE, - ), - TYPE_TEMP10F: ("Temp 10", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP1F: ("Temp 1", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP2F: ("Temp 2", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP3F: ("Temp 3", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP4F: ("Temp 4", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP5F: ("Temp 5", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP6F: ("Temp 6", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP7F: ("Temp 7", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP8F: ("Temp 8", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP9F: ("Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMPF: ("Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMPINF: ("Inside Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TOTALRAININ: ("Lifetime Rain", "in", SENSOR, None), - TYPE_UV: ("uv", "Index", SENSOR, None), - TYPE_WEEKLYRAININ: ("Weekly Rain", "in", SENSOR, None), - TYPE_WINDDIR: ("Wind Dir", DEGREE, SENSOR, None), - TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", DEGREE, SENSOR, None), - TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDGUSTDIR: ("Gust Dir", DEGREE, SENSOR, None), - TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_YEARLYRAININ: ("Yearly Rain", "in", SENSOR, None), -} - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_APP_KEY): cv.string, - vol.Required(CONF_API_KEY): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup(hass, config): - """Set up the Ambient PWS integration.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CLIENT] = {} +@callback +def async_wm2_to_lx(value: float) -> int: + """Calculate illuminance (in lux).""" + return round(value / 0.0079) - if DOMAIN not in config: - return True - conf = config[DOMAIN] - # Store config for use during entry setup: - hass.data[DOMAIN][DATA_CONFIG] = conf +@callback +def async_hydrate_station_data(data: dict[str, Any]) -> dict[str, Any]: + """Hydrate station data with addition or normalized data.""" + if (irradiation := data.get(TYPE_SOLARRADIATION)) is not None: + data[TYPE_SOLARRADIATION_LX] = async_wm2_to_lx(irradiation) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_API_KEY: conf[CONF_API_KEY], CONF_APP_KEY: conf[CONF_APP_KEY]}, - ) - ) - - return True + return data -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Ambient PWS as config entry.""" - if not config_entry.unique_id: + if not entry.unique_id: hass.config_entries.async_update_entry( - config_entry, unique_id=config_entry.data[CONF_APP_KEY] + entry, unique_id=entry.data[CONF_APP_KEY] ) - session = aiohttp_client.async_get_clientsession(hass) + + ambient = AmbientStation( + hass, + entry, + Websocket(entry.data[CONF_APP_KEY], entry.data[CONF_API_KEY]), + ) try: - ambient = AmbientStation( - hass, - config_entry, - Client( - config_entry.data[CONF_API_KEY], - config_entry.data[CONF_APP_KEY], - session=session, - ), - ) - hass.loop.create_task(ambient.ws_connect()) - hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient + await ambient.ws_connect() except WebsocketError as err: LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - async def _async_disconnect_websocket(*_): - await ambient.client.websocket.disconnect() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ambient - config_entry.async_on_unload( + async def _async_disconnect_websocket(_: Event) -> None: + await ambient.websocket.disconnect() + + entry.async_on_unload( hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket ) @@ -363,30 +92,33 @@ async def _async_disconnect_websocket(*_): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an Ambient PWS config entry.""" - ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) - hass.async_create_task(ambient.ws_disconnect()) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + ambient = hass.data[DOMAIN].pop(entry.entry_id) + hass.async_create_task(ambient.ws_disconnect()) - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return unload_ok -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" - version = config_entry.version + version = entry.version LOGGER.debug("Migrating from version %s", version) # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: - dev_reg = await hass.helpers.device_registry.async_get_registry() - dev_reg.async_clear_config_entry(config_entry) + dev_reg = dr.async_get(hass) + dev_reg.async_clear_config_entry(entry.entry_id) + + en_reg = er.async_get(hass) + en_reg.async_clear_config_entry(entry.entry_id) - en_reg = await hass.helpers.entity_registry.async_get_registry() - en_reg.async_clear_config_entry(config_entry) + version = entry.version = 2 + hass.config_entries.async_update_entry(entry) - version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.info("Migration to version %s successful", version) return True @@ -395,167 +127,123 @@ async def async_migrate_entry(hass, config_entry): class AmbientStation: """Define a class to handle the Ambient websocket.""" - def __init__(self, hass, config_entry, client): + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, websocket: Websocket + ) -> None: """Initialize.""" - self._config_entry = config_entry + self._entry = entry self._entry_setup_complete = False self._hass = hass self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY - self.client = client - self.stations = {} - - async def _attempt_connect(self): - """Attempt to connect to the socket (retrying later on fail).""" - - async def connect(timestamp=None): - """Connect.""" - await self.client.websocket.connect() - - try: - await connect() - except WebsocketError as err: - LOGGER.error("Error with the websocket connection: %s", err) - self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, 480) - async_call_later(self._hass, self._ws_reconnect_delay, connect) + self.stations: dict[str, dict] = {} + self.websocket = websocket - async def ws_connect(self): + async def ws_connect(self) -> None: """Register handlers and connect to the websocket.""" - def on_connect(): + def on_connect() -> None: """Define a handler to fire when the websocket is connected.""" LOGGER.info("Connected to websocket") - def on_data(data): + def on_data(data: dict) -> None: """Define a handler to fire when the data is received.""" - mac_address = data["macAddress"] - if data != self.stations[mac_address][ATTR_LAST_DATA]: - LOGGER.debug("New data received: %s", data) - self.stations[mac_address][ATTR_LAST_DATA] = data - async_dispatcher_send( - self._hass, f"ambient_station_data_update_{mac_address}" - ) + mac = data["macAddress"] + + if data == self.stations[mac][ATTR_LAST_DATA]: + return + + LOGGER.debug("New data received: %s", data) + self.stations[mac][ATTR_LAST_DATA] = async_hydrate_station_data(data) + async_dispatcher_send(self._hass, f"ambient_station_data_update_{mac}") - def on_disconnect(): + def on_disconnect() -> None: """Define a handler to fire when the websocket is disconnected.""" LOGGER.info("Disconnected from websocket") - def on_subscribed(data): + def on_subscribed(data: dict) -> None: """Define a handler to fire when the subscription is set.""" for station in data["devices"]: - if station["macAddress"] in self.stations: + if (mac := station["macAddress"]) in self.stations: continue + LOGGER.debug("New station subscription: %s", data) - # Only create entities based on the data coming through the socket. - # If the user is monitoring brightness (in W/m^2), make sure we also - # add a calculated sensor for the same data measured in lx: - monitored_conditions = [ - k for k in station["lastData"] if k in SENSOR_TYPES - ] - if TYPE_SOLARRADIATION in monitored_conditions: - monitored_conditions.append(TYPE_SOLARRADIATION_LX) - self.stations[station["macAddress"]] = { - ATTR_LAST_DATA: station["lastData"], + self.stations[mac] = { + ATTR_LAST_DATA: async_hydrate_station_data(station["lastData"]), ATTR_LOCATION: station.get("info", {}).get("location"), - ATTR_MONITORED_CONDITIONS: monitored_conditions, - ATTR_NAME: station.get("info", {}).get( - "name", station["macAddress"] - ), + ATTR_NAME: station.get("info", {}).get("name", mac), } + # If the websocket disconnects and reconnects, the on_subscribed # handler will get called again; in that case, we don't want to # attempt forward setup of the config entry (because it will have # already been done): if not self._entry_setup_complete: - self._hass.config_entries.async_setup_platforms( - self._config_entry, PLATFORMS - ) + self._hass.config_entries.async_setup_platforms(self._entry, PLATFORMS) self._entry_setup_complete = True self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY - self.client.websocket.on_connect(on_connect) - self.client.websocket.on_data(on_data) - self.client.websocket.on_disconnect(on_disconnect) - self.client.websocket.on_subscribed(on_subscribed) + self.websocket.on_connect(on_connect) + self.websocket.on_data(on_data) + self.websocket.on_disconnect(on_disconnect) + self.websocket.on_subscribed(on_subscribed) - await self._attempt_connect() + await self.websocket.connect() - async def ws_disconnect(self): + async def ws_disconnect(self) -> None: """Disconnect from the websocket.""" - await self.client.websocket.disconnect() + await self.websocket.disconnect() class AmbientWeatherEntity(Entity): """Define a base Ambient PWS entity.""" + _attr_should_poll = False + def __init__( - self, ambient, mac_address, station_name, sensor_type, sensor_name, device_class - ): - """Initialize the sensor.""" + self, + ambient: AmbientStation, + mac_address: str, + station_name: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" self._ambient = ambient - self._device_class = device_class - self._mac_address = mac_address - self._sensor_name = sensor_name - self._sensor_type = sensor_type - self._state = None - self._station_name = station_name - - @property - def available(self): - """Return True if entity is available.""" - # Since the solarradiation_lx sensor is created only if the - # user shows a solarradiation sensor, ensure that the - # solarradiation_lx sensor shows as available if the solarradiation - # sensor is available: - if self._sensor_type == TYPE_SOLARRADIATION_LX: - return ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - TYPE_SOLARRADIATION - ) - is not None - ) - return ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) - is not None + + public_device_id = get_public_device_id(mac_address) + self._attr_device_info = DeviceInfo( + configuration_url=f"https://ambientweather.net/dashboard/{public_device_id}", + identifiers={(DOMAIN, mac_address)}, + manufacturer="Ambient Weather", + name=station_name, ) - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def device_info(self): - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._mac_address)}, - "name": self._station_name, - "manufacturer": "Ambient Weather", - } - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._station_name}_{self._sensor_name}" - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def unique_id(self): - """Return a unique, unchanging string that represents this sensor.""" - return f"{self._mac_address}_{self._sensor_type}" - - async def async_added_to_hass(self): + self._attr_name = f"{station_name}_{description.name}" + self._attr_unique_id = f"{mac_address}_{description.key}" + self._mac_address = mac_address + self.entity_description = description + + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" + if self.entity_description.key == TYPE_SOLARRADIATION_LX: + self._attr_available = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + TYPE_SOLARRADIATION + ] + is not None + ) + else: + self._attr_available = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] + is not None + ) + self.update_from_latest_data() self.async_write_ha_state() @@ -568,6 +256,6 @@ def update(): self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index c2e5ad8b4f4cb..153fbf066db73 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -1,84 +1,264 @@ """Support for Ambient Weather Station binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME -from homeassistant.core import callback - -from . import ( - SENSOR_TYPES, - TYPE_BATT1, - TYPE_BATT2, - TYPE_BATT3, - TYPE_BATT4, - TYPE_BATT5, - TYPE_BATT6, - TYPE_BATT7, - TYPE_BATT8, - TYPE_BATT9, - TYPE_BATT10, - TYPE_BATT_CO2, - TYPE_BATTOUT, - TYPE_PM25_BATT, - TYPE_PM25IN_BATT, - AmbientWeatherEntity, +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AmbientWeatherEntity +from .const import ATTR_LAST_DATA, DOMAIN + +TYPE_BATT1 = "batt1" +TYPE_BATT10 = "batt10" +TYPE_BATT2 = "batt2" +TYPE_BATT3 = "batt3" +TYPE_BATT4 = "batt4" +TYPE_BATT5 = "batt5" +TYPE_BATT6 = "batt6" +TYPE_BATT7 = "batt7" +TYPE_BATT8 = "batt8" +TYPE_BATT9 = "batt9" +TYPE_BATT_CO2 = "batt_co2" +TYPE_BATTOUT = "battout" +TYPE_PM25_BATT = "batt_25" +TYPE_PM25IN_BATT = "batt_25in" +TYPE_RELAY1 = "relay1" +TYPE_RELAY10 = "relay10" +TYPE_RELAY2 = "relay2" +TYPE_RELAY3 = "relay3" +TYPE_RELAY4 = "relay4" +TYPE_RELAY5 = "relay5" +TYPE_RELAY6 = "relay6" +TYPE_RELAY7 = "relay7" +TYPE_RELAY8 = "relay8" +TYPE_RELAY9 = "relay9" + + +@dataclass +class AmbientBinarySensorDescriptionMixin: + """Define an entity description mixin for binary sensors.""" + + on_state: Literal[0, 1] + + +@dataclass +class AmbientBinarySensorDescription( + BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin +): + """Describe an Ambient PWS binary sensor.""" + + +BINARY_SENSOR_DESCRIPTIONS = ( + AmbientBinarySensorDescription( + key=TYPE_BATTOUT, + name="Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT1, + name="Battery 1", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT2, + name="Battery 2", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT3, + name="Battery 3", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT4, + name="Battery 4", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT5, + name="Battery 5", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT6, + name="Battery 6", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT7, + name="Battery 7", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT8, + name="Battery 8", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT9, + name="Battery 9", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT10, + name="Battery 10", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT_CO2, + name="CO2 Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_PM25IN_BATT, + name="PM25 Indoor Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_PM25_BATT, + name="PM25 Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY1, + name="Relay 1", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY2, + name="Relay 2", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY3, + name="Relay 3", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY4, + name="Relay 4", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY5, + name="Relay 5", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY6, + name="Relay 6", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY7, + name="Relay 7", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY8, + name="Relay 8", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY9, + name="Relay 9", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY10, + name="Relay 10", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=1, + ), ) -from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ambient PWS binary sensors based on a config entry.""" - ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - - binary_sensor_list = [] - for mac_address, station in ambient.stations.items(): - for condition in station[ATTR_MONITORED_CONDITIONS]: - name, _, kind, device_class = SENSOR_TYPES[condition] - if kind == BINARY_SENSOR: - binary_sensor_list.append( - AmbientWeatherBinarySensor( - ambient, - mac_address, - station[ATTR_NAME], - condition, - name, - device_class, - ) - ) - - async_add_entities(binary_sensor_list, True) + ambient = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + AmbientWeatherBinarySensor( + ambient, mac_address, station[ATTR_NAME], description + ) + for mac_address, station in ambient.stations.items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.key in station[ATTR_LAST_DATA] + ] + ) class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): """Define an Ambient binary sensor.""" - @property - def is_on(self): - """Return the status of the sensor.""" - if self._sensor_type in ( - TYPE_BATT1, - TYPE_BATT10, - TYPE_BATT2, - TYPE_BATT3, - TYPE_BATT4, - TYPE_BATT5, - TYPE_BATT6, - TYPE_BATT7, - TYPE_BATT8, - TYPE_BATT9, - TYPE_BATT_CO2, - TYPE_BATTOUT, - TYPE_PM25_BATT, - TYPE_PM25IN_BATT, - ): - return self._state == 0 - - return self._state == 1 + entity_description: AmbientBinarySensorDescription @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Fetch new state data for the entity.""" - self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type + self._attr_is_on = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] + == self.entity_description.on_state ) diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index 429388dcaba4e..2c2d231b33eef 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -1,10 +1,13 @@ """Config flow to configure the Ambient PWS component.""" -from aioambient import Client +from __future__ import annotations + +from aioambient import API from aioambient.errors import AmbientError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_APP_KEY, DOMAIN @@ -15,13 +18,13 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" self.data_schema = vol.Schema( {vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str} ) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", @@ -29,11 +32,7 @@ async def _show_form(self, errors=None): errors=errors if errors else {}, ) - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) - - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return await self._show_form() @@ -42,12 +41,10 @@ async def async_step_user(self, user_input=None): self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) - client = Client( - user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session=session - ) + api = API(user_input[CONF_APP_KEY], user_input[CONF_API_KEY], session=session) try: - devices = await client.api.get_devices() + devices = await api.get_devices() except AmbientError: return await self._show_form({"base": "invalid_key"}) diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py index 87b5ff6187752..4e0ec598fb184 100644 --- a/homeassistant/components/ambient_station/const.py +++ b/homeassistant/components/ambient_station/const.py @@ -5,8 +5,8 @@ LOGGER = logging.getLogger(__package__) ATTR_LAST_DATA = "last_data" -ATTR_MONITORED_CONDITIONS = "monitored_conditions" CONF_APP_KEY = "app_key" -DATA_CLIENT = "data_client" +TYPE_SOLARRADIATION = "solarradiation" +TYPE_SOLARRADIATION_LX = "solarradiation_lx" diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 6d4c40d260dc7..33cb84706ff2f 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.4"], + "requirements": ["aioambient==2021.11.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 7c60d1da9bc7d..c5b8b57297ffb 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,39 +1,619 @@ """Support for Ambient Weather Station sensors.""" -from homeassistant.components.sensor import DOMAIN as SENSOR, SensorEntity -from homeassistant.const import ATTR_NAME -from homeassistant.core import callback +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + IRRADIATION_WATTS_PER_SQUARE_METER, + LIGHT_LUX, + PERCENTAGE, + PRECIPITATION_INCHES, + PRECIPITATION_INCHES_PER_HOUR, + PRESSURE_INHG, + SPEED_MILES_PER_HOUR, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( - SENSOR_TYPES, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX, + AmbientStation, AmbientWeatherEntity, ) -from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN +from .const import ATTR_LAST_DATA, DOMAIN +TYPE_24HOURRAININ = "24hourrainin" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_HUMIDITY1 = "humidity1" +TYPE_HUMIDITY10 = "humidity10" +TYPE_HUMIDITY2 = "humidity2" +TYPE_HUMIDITY3 = "humidity3" +TYPE_HUMIDITY4 = "humidity4" +TYPE_HUMIDITY5 = "humidity5" +TYPE_HUMIDITY6 = "humidity6" +TYPE_HUMIDITY7 = "humidity7" +TYPE_HUMIDITY8 = "humidity8" +TYPE_HUMIDITY9 = "humidity9" +TYPE_HUMIDITYIN = "humidityin" +TYPE_LASTRAIN = "lastRain" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_PM25 = "pm25" +TYPE_PM25_24H = "pm25_24h" +TYPE_PM25_IN = "pm25_in" +TYPE_PM25_IN_24H = "pm25_in_24h" +TYPE_SOILHUM1 = "soilhum1" +TYPE_SOILHUM10 = "soilhum10" +TYPE_SOILHUM2 = "soilhum2" +TYPE_SOILHUM3 = "soilhum3" +TYPE_SOILHUM4 = "soilhum4" +TYPE_SOILHUM5 = "soilhum5" +TYPE_SOILHUM6 = "soilhum6" +TYPE_SOILHUM7 = "soilhum7" +TYPE_SOILHUM8 = "soilhum8" +TYPE_SOILHUM9 = "soilhum9" +TYPE_SOILTEMP1F = "soiltemp1f" +TYPE_SOILTEMP10F = "soiltemp10f" +TYPE_SOILTEMP2F = "soiltemp2f" +TYPE_SOILTEMP3F = "soiltemp3f" +TYPE_SOILTEMP4F = "soiltemp4f" +TYPE_SOILTEMP5F = "soiltemp5f" +TYPE_SOILTEMP6F = "soiltemp6f" +TYPE_SOILTEMP7F = "soiltemp7f" +TYPE_SOILTEMP8F = "soiltemp8f" +TYPE_SOILTEMP9F = "soiltemp9f" +TYPE_TEMP10F = "temp10f" +TYPE_TEMP1F = "temp1f" +TYPE_TEMP2F = "temp2f" +TYPE_TEMP3F = "temp3f" +TYPE_TEMP4F = "temp4f" +TYPE_TEMP5F = "temp5f" +TYPE_TEMP6F = "temp6f" +TYPE_TEMP7F = "temp7f" +TYPE_TEMP8F = "temp8f" +TYPE_TEMP9F = "temp9f" +TYPE_TEMPF = "tempf" +TYPE_TEMPINF = "tempinf" +TYPE_TOTALRAININ = "totalrainin" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDDIR_AVG10M = "winddir_avg10m" +TYPE_WINDDIR_AVG2M = "winddir_avg2m" +TYPE_WINDGUSTDIR = "windgustdir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" +TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_24HOURRAININ, + name="24 Hr Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key=TYPE_BAROMABSIN, + name="Abs Pressure", + native_unit_of_measurement=PRESSURE_INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_BAROMRELIN, + name="Rel Pressure", + native_unit_of_measurement=PRESSURE_INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_CO2, + name="co2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_DAILYRAININ, + name="Daily Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key=TYPE_DEWPOINT, + name="Dew Point", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_EVENTRAININ, + name="Event Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_FEELSLIKE, + name="Feels Like", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_HOURLYRAININ, + name="Hourly Rain Rate", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY10, + name="Humidity 10", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY1, + name="Humidity 1", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY2, + name="Humidity 2", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY3, + name="Humidity 3", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY4, + name="Humidity 4", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY5, + name="Humidity 5", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY6, + name="Humidity 6", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY7, + name="Humidity 7", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY8, + name="Humidity 8", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY9, + name="Humidity 9", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITYIN, + name="Humidity In", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_LASTRAIN, + name="Last Rain", + icon="mdi:water", + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=TYPE_MAXDAILYGUST, + name="Max Gust", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_MONTHLYRAININ, + name="Monthly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_PM25_24H, + name="PM25 24h Avg", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + ), + SensorEntityDescription( + key=TYPE_PM25_IN, + name="PM25 Indoor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_PM25_IN_24H, + name="PM25 Indoor 24h Avg", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + ), + SensorEntityDescription( + key=TYPE_PM25, + name="PM25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILHUM10, + name="Soil Humidity 10", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM1, + name="Soil Humidity 1", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM2, + name="Soil Humidity 2", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM3, + name="Soil Humidity 3", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM4, + name="Soil Humidity 4", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM5, + name="Soil Humidity 5", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM6, + name="Soil Humidity 6", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM7, + name="Soil Humidity 7", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM8, + name="Soil Humidity 8", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM9, + name="Soil Humidity 9", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP10F, + name="Soil Temp 10", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP1F, + name="Soil Temp 1", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP2F, + name="Soil Temp 2", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP3F, + name="Soil Temp 3", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP4F, + name="Soil Temp 4", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP5F, + name="Soil Temp 5", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP6F, + name="Soil Temp 6", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP7F, + name="Soil Temp 7", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP8F, + name="Soil Temp 8", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP9F, + name="Soil Temp 9", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION, + name="Solar Rad", + native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION_LX, + name="Solar Rad", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP10F, + name="Temp 10", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP1F, + name="Temp 1", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP2F, + name="Temp 2", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP3F, + name="Temp 3", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP4F, + name="Temp 4", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP5F, + name="Temp 5", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP6F, + name="Temp 6", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP7F, + name="Temp 7", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP8F, + name="Temp 8", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP9F, + name="Temp 9", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMPF, + name="Temp", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMPINF, + name="Inside Temp", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TOTALRAININ, + name="Lifetime Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_UV, + name="UV Index", + native_unit_of_measurement="Index", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_WEEKLYRAININ, + name="Weekly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_WINDDIR, + name="Wind Dir", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDDIR_AVG10M, + name="Wind Dir Avg 10m", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDDIR_AVG2M, + name="Wind Dir Avg 2m", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTDIR, + name="Gust Dir", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTMPH, + name="Wind Gust", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_WINDSPDMPH_AVG10M, + name="Wind Avg 10m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPDMPH_AVG2M, + name="Wind Avg 2m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPEEDMPH, + name="Wind Speed", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_YEARLYRAININ, + name="Yearly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) -async def async_setup_entry(hass, entry, async_add_entities): - """Set up Ambient PWS sensors based on a config entry.""" - ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - sensor_list = [] - for mac_address, station in ambient.stations.items(): - for condition in station[ATTR_MONITORED_CONDITIONS]: - name, unit, kind, device_class = SENSOR_TYPES[condition] - if kind == SENSOR: - sensor_list.append( - AmbientWeatherSensor( - ambient, - mac_address, - station[ATTR_NAME], - condition, - name, - device_class, - unit, - ) - ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ambient PWS sensors based on a config entry.""" + ambient = hass.data[DOMAIN][entry.entry_id] - async_add_entities(sensor_list, True) + async_add_entities( + [ + AmbientWeatherSensor(ambient, mac_address, station[ATTR_NAME], description) + for mac_address, station in ambient.stations.items() + for description in SENSOR_DESCRIPTIONS + if description.key in station[ATTR_LAST_DATA] + ] + ) class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): @@ -41,47 +621,28 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): def __init__( self, - ambient, - mac_address, - station_name, - sensor_type, - sensor_name, - device_class, - unit, - ): + ambient: AmbientStation, + mac_address: str, + station_name: str, + description: EntityDescription, + ) -> None: """Initialize the sensor.""" - super().__init__( - ambient, mac_address, station_name, sensor_type, sensor_name, device_class - ) - - self._unit = unit - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + super().__init__(ambient, mac_address, station_name, description) - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + if description.key == TYPE_SOLARRADIATION_LX: + # Since TYPE_SOLARRADIATION and TYPE_SOLARRADIATION_LX will have the same + # name in the UI, we influence the entity ID of TYPE_SOLARRADIATION_LX here + # to differentiate them: + self.entity_id = f"sensor.{station_name}_solar_rad_lx" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - if self._sensor_type == TYPE_SOLARRADIATION_LX: - # If the user requests the solarradiation_lx sensor, use the - # value of the solarradiation sensor and apply a very accurate - # approximation of converting sunlight W/m^2 to lx: - w_m2_brightness_val = self._ambient.stations[self._mac_address][ - ATTR_LAST_DATA - ].get(TYPE_SOLARRADIATION) + raw = self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] - if w_m2_brightness_val is None: - self._state = None - else: - self._state = round(float(w_m2_brightness_val) / 0.0079) + if self.entity_description.key == TYPE_LASTRAIN: + self._attr_native_value = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S.%f%z") else: - self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) + self._attr_native_value = raw diff --git a/homeassistant/components/ambient_station/translations/de.json b/homeassistant/components/ambient_station/translations/de.json index c6570fee0e352..8dda644cc2646 100644 --- a/homeassistant/components/ambient_station/translations/de.json +++ b/homeassistant/components/ambient_station/translations/de.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API Schl\u00fcssel", + "api_key": "API-Schl\u00fcssel", "app_key": "Anwendungsschl\u00fcssel" }, "title": "Gib deine Informationen ein" diff --git a/homeassistant/components/ambient_station/translations/fr.json b/homeassistant/components/ambient_station/translations/fr.json index d88e9f9c9f67a..1877a0af4ff7c 100644 --- a/homeassistant/components/ambient_station/translations/fr.json +++ b/homeassistant/components/ambient_station/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Cette cl\u00e9 d'application est d\u00e9j\u00e0 utilis\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_key": "Cl\u00e9 d'API et / ou cl\u00e9 d'application non valide", + "invalid_key": "Cl\u00e9 API invalide", "no_devices": "Aucun appareil trouv\u00e9 dans le compte" }, "step": { diff --git a/homeassistant/components/ambient_station/translations/he.json b/homeassistant/components/ambient_station/translations/he.json index f5afbca71c0f2..f34e568aa2f4b 100644 --- a/homeassistant/components/ambient_station/translations/he.json +++ b/homeassistant/components/ambient_station/translations/he.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, "error": { + "invalid_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "no_devices": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05df \u05d1\u05d7\u05e9\u05d1\u05d5\u05df" }, "step": { diff --git a/homeassistant/components/ambient_station/translations/hu.json b/homeassistant/components/ambient_station/translations/hu.json index 7c7e3a658b90a..6974bda2c20bb 100644 --- a/homeassistant/components/ambient_station/translations/hu.json +++ b/homeassistant/components/ambient_station/translations/hu.json @@ -13,7 +13,7 @@ "api_key": "API kulcs", "app_key": "Alkalmaz\u00e1skulcs" }, - "title": "T\u00f6ltsd ki az adataid" + "title": "T\u00f6ltse ki az adatait" } } } diff --git a/homeassistant/components/ambient_station/translations/ja.json b/homeassistant/components/ambient_station/translations/ja.json new file mode 100644 index 0000000000000..63a42f88a641d --- /dev/null +++ b/homeassistant/components/ambient_station/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "no_devices": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "app_key": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30ad\u30fc" + }, + "title": "\u3042\u306a\u305f\u306e\u60c5\u5831\u3092\u5165\u529b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/translations/tr.json b/homeassistant/components/ambient_station/translations/tr.json index 908d97f5758bf..ed4b15c144973 100644 --- a/homeassistant/components/ambient_station/translations/tr.json +++ b/homeassistant/components/ambient_station/translations/tr.json @@ -4,13 +4,16 @@ "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "invalid_key": "Ge\u00e7ersiz API anahtar\u0131" + "invalid_key": "Ge\u00e7ersiz API anahtar\u0131", + "no_devices": "Hesapta cihaz bulunamad\u0131" }, "step": { "user": { "data": { - "api_key": "API Anahtar\u0131" - } + "api_key": "API Anahtar\u0131", + "app_key": "Uygulama Anahtar\u0131" + }, + "title": "Bilgilerinizi doldurun" } } } diff --git a/homeassistant/components/ambient_station/translations/zh-Hant.json b/homeassistant/components/ambient_station/translations/zh-Hant.json index dab15def7b413..8b1f528f01c4f 100644 --- a/homeassistant/components/ambient_station/translations/zh-Hant.json +++ b/homeassistant/components/ambient_station/translations/zh-Hant.json @@ -4,14 +4,14 @@ "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" }, "error": { - "invalid_key": "API \u5bc6\u9470\u7121\u6548", + "invalid_key": "API \u91d1\u9470\u7121\u6548", "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e" }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", - "app_key": "\u61c9\u7528\u5bc6\u9470" + "api_key": "API \u91d1\u9470", + "app_key": "\u61c9\u7528\u91d1\u9470" }, "title": "\u586b\u5beb\u8cc7\u8a0a" } diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 8d274f120443c..3ee6e685eb5bf 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,17 +1,24 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + +from collections.abc import Callable from contextlib import suppress -from datetime import timedelta +from dataclasses import dataclass +from datetime import datetime, timedelta import logging import threading +from typing import Any import aiohttp -from amcrest import AmcrestError, Http, LoginError +from amcrest import AmcrestError, ApiWrapper, LoginError import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.camera import DOMAIN as CAMERA from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.const import ( ATTR_ENTITY_ID, CONF_AUTHENTICATION, @@ -22,19 +29,22 @@ CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, + CONF_SWITCHES, CONF_USERNAME, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, HTTP_BASIC_AUTHENTICATION, ) +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers.typing import ConfigType -from .binary_sensor import BINARY_POLLED_SENSORS, BINARY_SENSORS, check_binary_sensors +from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST from .const import ( CAMERAS, @@ -43,12 +53,12 @@ DATA_AMCREST, DEVICES, DOMAIN, - SENSOR_EVENT_CODE, SERVICE_EVENT, SERVICE_UPDATE, ) from .helpers import service_signal -from .sensor import SENSORS +from .sensor import SENSOR_KEYS +from .switch import SWITCH_KEYS _LOGGER = logging.getLogger(__name__) @@ -74,7 +84,7 @@ AUTHENTICATION_LIST = {"basic": "basic"} -def _has_unique_names(devices): +def _has_unique_names(devices: list[dict[str, Any]]) -> list[dict[str, Any]]: names = [device[CONF_NAME] for device in devices] vol.Schema(vol.Unique())(names) return devices @@ -99,10 +109,16 @@ def _has_unique_names(devices): vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [vol.In(BINARY_SENSORS)], vol.Unique(), check_binary_sensors + cv.ensure_list, + [vol.In(BINARY_SENSOR_KEYS)], + vol.Unique(), + check_binary_sensors, + ), + vol.Optional(CONF_SWITCHES): vol.All( + cv.ensure_list, [vol.In(SWITCH_KEYS)], vol.Unique() ), vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSORS)], vol.Unique() + cv.ensure_list, [vol.In(SENSOR_KEYS)], vol.Unique() ), vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, } @@ -114,10 +130,18 @@ def _has_unique_names(devices): ) -class AmcrestChecker(Http): - """amcrest.Http wrapper for catching errors.""" +class AmcrestChecker(ApiWrapper): + """amcrest.ApiWrapper wrapper for catching errors.""" - def __init__(self, hass, name, host, port, user, password): + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str, + port: int, + user: str, + password: str, + ) -> None: """Initialize.""" self._hass = hass self._wrap_name = name @@ -126,7 +150,7 @@ def __init__(self, hass, name, host, port, user, password): self._wrap_login_err = False self._wrap_event_flag = threading.Event() self._wrap_event_flag.set() - self._unsub_recheck = None + self._unsub_recheck: Callable[[], None] | None = None super().__init__( host, port, @@ -137,24 +161,24 @@ def __init__(self, hass, name, host, port, user, password): ) @property - def available(self): + def available(self) -> bool: """Return if camera's API is responding.""" return self._wrap_errors <= MAX_ERRORS and not self._wrap_login_err @property - def available_flag(self): + def available_flag(self) -> threading.Event: """Return threading event flag that indicates if camera's API is responding.""" return self._wrap_event_flag - def _start_recovery(self): + def _start_recovery(self) -> None: self._wrap_event_flag.clear() dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) self._unsub_recheck = track_time_interval( self._hass, self._wrap_test_online, RECHECK_INTERVAL ) - def command(self, *args, **kwargs): - """amcrest.Http.command wrapper to catch errors.""" + def command(self, *args: Any, **kwargs: Any) -> Any: + """amcrest.ApiWrapper.command wrapper to catch errors.""" try: ret = super().command(*args, **kwargs) except LoginError as ex: @@ -182,6 +206,7 @@ def command(self, *args, **kwargs): self._wrap_errors = 0 self._wrap_login_err = False if was_offline: + assert self._unsub_recheck is not None self._unsub_recheck() self._unsub_recheck = None _LOGGER.error("%s camera back online", self._wrap_name) @@ -189,15 +214,19 @@ def command(self, *args, **kwargs): dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) return ret - def _wrap_test_online(self, now): + def _wrap_test_online(self, now: datetime) -> None: """Test if camera is back online.""" _LOGGER.debug("Testing if %s back online", self._wrap_name) with suppress(AmcrestError): self.current_time # pylint: disable=pointless-statement -def _monitor_events(hass, name, api, event_codes): - event_codes = set(event_codes) +def _monitor_events( + hass: HomeAssistant, + name: str, + api: AmcrestChecker, + event_codes: set[str], +) -> None: while True: api.available_flag.wait() try: @@ -218,7 +247,12 @@ def _monitor_events(hass, name, api, event_codes): ) -def _start_event_monitor(hass, name, api, event_codes): +def _start_event_monitor( + hass: HomeAssistant, + name: str, + api: AmcrestChecker, + event_codes: set[str], +) -> None: thread = threading.Thread( target=_monitor_events, name=f"Amcrest {name}", @@ -228,14 +262,14 @@ def _start_event_monitor(hass, name, api, event_codes): thread.start() -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Amcrest IP Camera component.""" hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) for device in config[DOMAIN]: - name = device[CONF_NAME] - username = device[CONF_USERNAME] - password = device[CONF_PASSWORD] + name: str = device[CONF_NAME] + username: str = device[CONF_USERNAME] + password: str = device[CONF_PASSWORD] api = AmcrestChecker( hass, name, device[CONF_HOST], device[CONF_PORT], username, password @@ -245,13 +279,16 @@ def setup(hass, config): resolution = RESOLUTION_LIST[device[CONF_RESOLUTION]] binary_sensors = device.get(CONF_BINARY_SENSORS) sensors = device.get(CONF_SENSORS) + switches = device.get(CONF_SWITCHES) stream_source = device[CONF_STREAM_SOURCE] control_light = device.get(CONF_CONTROL_LIGHT) # currently aiohttp only works with basic authentication # only valid for mjpeg streaming if device[CONF_AUTHENTICATION] == HTTP_BASIC_AUTHENTICATION: - authentication = aiohttp.BasicAuth(username, password) + authentication: aiohttp.BasicAuth | None = aiohttp.BasicAuth( + username, password + ) else: authentication = None @@ -266,7 +303,7 @@ def setup(hass, config): discovery.load_platform(hass, CAMERA, DOMAIN, {CONF_NAME: name}, config) - event_codes = [] + event_codes = set() if binary_sensors: discovery.load_platform( hass, @@ -275,11 +312,13 @@ def setup(hass, config): {CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors}, config, ) - event_codes = [ - BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] - for sensor_type in binary_sensors - if sensor_type not in BINARY_POLLED_SENSORS - ] + event_codes = { + sensor.event_code + for sensor in BINARY_SENSORS + if sensor.key in binary_sensors + and not sensor.should_poll + and sensor.event_code is not None + } _start_event_monitor(hass, name, api, event_codes) @@ -288,13 +327,18 @@ def setup(hass, config): hass, SENSOR, DOMAIN, {CONF_NAME: name, CONF_SENSORS: sensors}, config ) + if switches: + discovery.load_platform( + hass, SWITCH, DOMAIN, {CONF_NAME: name, CONF_SWITCHES: switches}, config + ) + if not hass.data[DATA_AMCREST][DEVICES]: return False - def have_permission(user, entity_id): + def have_permission(user: User | None, entity_id: str) -> bool: return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) - async def async_extract_from_service(call): + async def async_extract_from_service(call: ServiceCall) -> list[str]: if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) if user is None: @@ -325,7 +369,7 @@ async def async_extract_from_service(call): entity_ids.append(entity_id) return entity_ids - async def async_service_handler(call): + async def async_service_handler(call: ServiceCall) -> None: args = [] for arg in CAMERA_SERVICES[call.service][2]: args.append(call.data[arg]) @@ -338,22 +382,14 @@ async def async_service_handler(call): return True +@dataclass class AmcrestDevice: """Representation of a base Amcrest discovery device.""" - def __init__( - self, - api, - authentication, - ffmpeg_arguments, - stream_source, - resolution, - control_light, - ): - """Initialize the entity.""" - self.api = api - self.authentication = authentication - self.ffmpeg_arguments = ffmpeg_arguments - self.stream_source = stream_source - self.resolution = resolution - self.control_light = control_light + api: AmcrestChecker + authentication: aiohttp.BasicAuth | None + ffmpeg_arguments: list[str] + stream_source: str + resolution: int + control_light: bool + channel: int = 0 diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 0add382b81f0d..8cbc00e1f1f0a 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,80 +1,129 @@ """Support for Amcrest IP camera binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass from datetime import timedelta import logging +from typing import TYPE_CHECKING from amcrest import AmcrestError import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_SOUND, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from .const import ( BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, - SENSOR_DEVICE_CLASS, - SENSOR_EVENT_CODE, - SENSOR_NAME, SERVICE_EVENT, SERVICE_UPDATE, ) from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + + +@dataclass +class AmcrestSensorEntityDescription(BinarySensorEntityDescription): + """Describe Amcrest sensor entity.""" + + event_code: str | None = None + should_poll: bool = False + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) _ONLINE_SCAN_INTERVAL = timedelta(seconds=60 - BINARY_SENSOR_SCAN_INTERVAL_SECS) -BINARY_SENSOR_AUDIO_DETECTED = "audio_detected" -BINARY_SENSOR_AUDIO_DETECTED_POLLED = "audio_detected_polled" -BINARY_SENSOR_MOTION_DETECTED = "motion_detected" -BINARY_SENSOR_MOTION_DETECTED_POLLED = "motion_detected_polled" -BINARY_SENSOR_ONLINE = "online" -BINARY_SENSOR_CROSSLINE_DETECTED = "crossline_detected" -BINARY_SENSOR_CROSSLINE_DETECTED_POLLED = "crossline_detected_polled" -BINARY_POLLED_SENSORS = [ - BINARY_SENSOR_AUDIO_DETECTED_POLLED, - BINARY_SENSOR_MOTION_DETECTED_POLLED, - BINARY_SENSOR_ONLINE, -] -_AUDIO_DETECTED_PARAMS = ("Audio Detected", DEVICE_CLASS_SOUND, "AudioMutation") -_MOTION_DETECTED_PARAMS = ("Motion Detected", DEVICE_CLASS_MOTION, "VideoMotion") -_CROSSLINE_DETECTED_PARAMS = ( - "CrossLine Detected", - DEVICE_CLASS_MOTION, - "CrossLineDetection", +_AUDIO_DETECTED_KEY = "audio_detected" +_AUDIO_DETECTED_POLLED_KEY = "audio_detected_polled" +_AUDIO_DETECTED_NAME = "Audio Detected" +_AUDIO_DETECTED_EVENT_CODE = "AudioMutation" + +_CROSSLINE_DETECTED_KEY = "crossline_detected" +_CROSSLINE_DETECTED_POLLED_KEY = "crossline_detected_polled" +_CROSSLINE_DETECTED_NAME = "CrossLine Detected" +_CROSSLINE_DETECTED_EVENT_CODE = "CrossLineDetection" + +_MOTION_DETECTED_KEY = "motion_detected" +_MOTION_DETECTED_POLLED_KEY = "motion_detected_polled" +_MOTION_DETECTED_NAME = "Motion Detected" +_MOTION_DETECTED_EVENT_CODE = "VideoMotion" + +_ONLINE_KEY = "online" + +BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = ( + AmcrestSensorEntityDescription( + key=_AUDIO_DETECTED_KEY, + name=_AUDIO_DETECTED_NAME, + device_class=BinarySensorDeviceClass.SOUND, + event_code=_AUDIO_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_AUDIO_DETECTED_POLLED_KEY, + name=_AUDIO_DETECTED_NAME, + device_class=BinarySensorDeviceClass.SOUND, + event_code=_AUDIO_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_CROSSLINE_DETECTED_KEY, + name=_CROSSLINE_DETECTED_NAME, + device_class=BinarySensorDeviceClass.MOTION, + event_code=_CROSSLINE_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_CROSSLINE_DETECTED_POLLED_KEY, + name=_CROSSLINE_DETECTED_NAME, + device_class=BinarySensorDeviceClass.MOTION, + event_code=_CROSSLINE_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_MOTION_DETECTED_KEY, + name=_MOTION_DETECTED_NAME, + device_class=BinarySensorDeviceClass.MOTION, + event_code=_MOTION_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_MOTION_DETECTED_POLLED_KEY, + name=_MOTION_DETECTED_NAME, + device_class=BinarySensorDeviceClass.MOTION, + event_code=_MOTION_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_ONLINE_KEY, + name="Online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + should_poll=True, + ), ) -BINARY_SENSORS = { - BINARY_SENSOR_AUDIO_DETECTED: _AUDIO_DETECTED_PARAMS, - BINARY_SENSOR_AUDIO_DETECTED_POLLED: _AUDIO_DETECTED_PARAMS, - BINARY_SENSOR_MOTION_DETECTED: _MOTION_DETECTED_PARAMS, - BINARY_SENSOR_MOTION_DETECTED_POLLED: _MOTION_DETECTED_PARAMS, - BINARY_SENSOR_CROSSLINE_DETECTED: _CROSSLINE_DETECTED_PARAMS, - BINARY_SENSOR_CROSSLINE_DETECTED_POLLED: _CROSSLINE_DETECTED_PARAMS, - BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY, None), -} -BINARY_SENSORS = { - k: dict(zip((SENSOR_NAME, SENSOR_DEVICE_CLASS, SENSOR_EVENT_CODE), v)) - for k, v in BINARY_SENSORS.items() -} +BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS] _EXCLUSIVE_OPTIONS = [ - {BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSOR_MOTION_DETECTED_POLLED}, - {BINARY_SENSOR_CROSSLINE_DETECTED, BINARY_SENSOR_CROSSLINE_DETECTED_POLLED}, + {_AUDIO_DETECTED_KEY, _AUDIO_DETECTED_POLLED_KEY}, + {_MOTION_DETECTED_KEY, _MOTION_DETECTED_POLLED_KEY}, + {_CROSSLINE_DETECTED_KEY, _CROSSLINE_DETECTED_POLLED_KEY}, ] _UPDATE_MSG = "Updating %s binary sensor" -def check_binary_sensors(value): +def check_binary_sensors(value: list[str]) -> list[str]: """Validate binary sensor configurations.""" for exclusive_options in _EXCLUSIVE_OPTIONS: if len(set(value) & exclusive_options) > 1: @@ -84,17 +133,24 @@ def check_binary_sensors(value): return value -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a binary sensor for an Amcrest IP Camera.""" if discovery_info is None: return name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] + binary_sensors = discovery_info[CONF_BINARY_SENSORS] async_add_entities( [ - AmcrestBinarySensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_BINARY_SENSORS] + AmcrestBinarySensor(name, device, entity_description) + for entity_description in BINARY_SENSORS + if entity_description.key in binary_sensors ], True, ) @@ -103,91 +159,94 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestBinarySensor(BinarySensorEntity): """Binary sensor for Amcrest camera.""" - def __init__(self, name, device, sensor_type): + def __init__( + self, + name: str, + device: AmcrestDevice, + entity_description: AmcrestSensorEntityDescription, + ) -> None: """Initialize entity.""" - self._name = f"{name} {BINARY_SENSORS[sensor_type][SENSOR_NAME]}" self._signal_name = name self._api = device.api - self._sensor_type = sensor_type - self._state = None - self._device_class = BINARY_SENSORS[sensor_type][SENSOR_DEVICE_CLASS] - self._event_code = BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] - self._unsub_dispatcher = [] - - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return self._sensor_type in BINARY_POLLED_SENSORS - - @property - def name(self): - """Return entity name.""" - return self._name - - @property - def is_on(self): - """Return if entity is on.""" - return self._state + self._channel = device.channel + self.entity_description: AmcrestSensorEntityDescription = entity_description - @property - def device_class(self): - """Return device class.""" - return self._device_class + self._attr_name = f"{name} {entity_description.name}" + self._attr_should_poll = entity_description.should_poll + self._unsub_dispatcher: list[Callable[[], None]] = [] @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" - return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available + return self.entity_description.key == _ONLINE_KEY or self._api.available - def update(self): + def update(self) -> None: """Update entity.""" - if self._sensor_type == BINARY_SENSOR_ONLINE: + if self.entity_description.key == _ONLINE_KEY: self._update_online() else: self._update_others() @Throttle(_ONLINE_SCAN_INTERVAL) - def _update_online(self): + def _update_online(self) -> None: if not (self._api.available or self.is_on): return - _LOGGER.debug(_UPDATE_MSG, self._name) + _LOGGER.debug(_UPDATE_MSG, self.name) + if self._api.available: # Send a command to the camera to test if we can still communicate with it. # Override of Http.command() in __init__.py will set self._api.available # accordingly. with suppress(AmcrestError): self._api.current_time # pylint: disable=pointless-statement - self._state = self._api.available + self._update_unique_id() + self._attr_is_on = self._api.available - def _update_others(self): + def _update_others(self) -> None: if not self.available: return - _LOGGER.debug(_UPDATE_MSG, self._name) + _LOGGER.debug(_UPDATE_MSG, self.name) try: - self._state = "channels" in self._api.event_channels_happened( - self._event_code - ) + self._update_unique_id() + except AmcrestError as error: + log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + return + + if (event_code := self.entity_description.event_code) is None: + _LOGGER.error("Binary sensor %s event code not set", self.name) + return + + try: + self._attr_is_on = len(self._api.event_channels_happened(event_code)) > 0 except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + return - async def async_on_demand_update(self): + def _update_unique_id(self) -> None: + """Set the unique id.""" + if self._attr_unique_id is None and (serial_number := self._api.serial_number): + self._attr_unique_id = ( + f"{serial_number}-{self.entity_description.key}-{self._channel}" + ) + + async def async_on_demand_update(self) -> None: """Update state.""" - if self._sensor_type == BINARY_SENSOR_ONLINE: - _LOGGER.debug(_UPDATE_MSG, self._name) - self._state = self._api.available + if self.entity_description.key == _ONLINE_KEY: + _LOGGER.debug(_UPDATE_MSG, self.name) + self._attr_is_on = self._api.available self.async_write_ha_state() - return - self.async_schedule_update_ha_state(True) + else: + self.async_schedule_update_ha_state(True) @callback - def async_event_received(self, start): + def async_event_received(self, state: bool) -> None: """Update state from received event.""" - _LOGGER.debug(_UPDATE_MSG, self._name) - self._state = start + _LOGGER.debug(_UPDATE_MSG, self.name) + self._attr_is_on = state self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to signals.""" self._unsub_dispatcher.append( async_dispatcher_connect( @@ -196,16 +255,23 @@ async def async_added_to_hass(self): self.async_on_demand_update, ) ) - if self._event_code and self._sensor_type not in BINARY_POLLED_SENSORS: + if ( + self.entity_description.event_code + and not self.entity_description.should_poll + ): self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, - service_signal(SERVICE_EVENT, self._signal_name, self._event_code), + service_signal( + SERVICE_EVENT, + self._signal_name, + self.entity_description.event_code, + ), self.async_event_received, ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect from update signal.""" for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 92453d24144e4..284857964201a 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,16 +1,24 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable from datetime import timedelta from functools import partial import logging +from typing import TYPE_CHECKING, Any +from aiohttp import web from amcrest import AmcrestError from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import SUPPORT_ON_OFF, SUPPORT_STREAM, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -18,6 +26,8 @@ ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CAMERA_WEB_SESSION_TIMEOUT, @@ -25,11 +35,15 @@ COMM_TIMEOUT, DATA_AMCREST, DEVICES, + DOMAIN, SERVICE_UPDATE, SNAPSHOT_TIMEOUT, ) from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=15) @@ -110,14 +124,33 @@ _BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up an Amcrest IP Camera.""" if discovery_info is None: return name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] - async_add_entities([AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) + entity = AmcrestCam(name, device, get_ffmpeg_manager(hass)) + + # 2021.9.0 introduced unique id's for the camera entity, but these were not + # unique for different resolution streams. If any cameras were configured + # with this version, update the old entity with the new unique id. + serial_number = await hass.async_add_executor_job(lambda: device.api.serial_number) # type: ignore[no-any-return] + serial_number = serial_number.strip() + registry = entity_registry.async_get(hass) + entity_id = registry.async_get_entity_id(CAMERA_DOMAIN, DOMAIN, serial_number) + if entity_id is not None: + _LOGGER.debug("Updating unique id for camera %s", entity_id) + new_unique_id = f"{serial_number}-{device.resolution}-{device.channel}" + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + async_add_entities([entity], True) class CannotSnapshot(Exception): @@ -131,7 +164,7 @@ class AmcrestCommandFailed(Exception): class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, name, device, ffmpeg): + def __init__(self, name: str, device: AmcrestDevice, ffmpeg: FFmpegManager) -> None: """Initialize an Amcrest camera.""" super().__init__() self._name = name @@ -140,21 +173,22 @@ def __init__(self, name, device, ffmpeg): self._ffmpeg_arguments = device.ffmpeg_arguments self._stream_source = device.stream_source self._resolution = device.resolution + self._channel = device.channel 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_task = None - self._unsub_dispatcher = [] + self._is_recording: bool = False + self._motion_detection_enabled: bool = False + self._brand: str | None = None + self._model: str | None = None + self._audio_enabled: bool | None = None + self._motion_recording_enabled: bool | None = None + self._color_bw: str | None = None + self._rtsp_url: str | None = None + self._snapshot_task: asyncio.tasks.Task | None = None + self._unsub_dispatcher: list[Callable[[], None]] = [] self._update_succeeded = False - def _check_snapshot_ok(self): + def _check_snapshot_ok(self) -> None: available = self.available if not available or not self.is_on: _LOGGER.warning( @@ -164,7 +198,7 @@ def _check_snapshot_ok(self): ) raise CannotSnapshot - async def _async_get_image(self): + async def _async_get_image(self) -> None: try: # Send the request to snap a picture and return raw jpg data # Snapshot command needs a much longer read timeout than other commands. @@ -177,11 +211,13 @@ async def _async_get_image(self): ) except AmcrestError as error: log_update_error(_LOGGER, "get image from", self.name, "camera", error) - return None + return finally: self._snapshot_task = None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" _LOGGER.debug("Take snapshot from %s", self._name) try: @@ -203,7 +239,9 @@ async def async_camera_image(self): except CannotSnapshot: return None - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse | None: """Return an MJPEG stream.""" # The snapshot implementation is handled by the parent class if self._stream_source == "snapshot": @@ -228,7 +266,7 @@ async def handle_async_mjpeg_stream(self, request): return await async_aiohttp_proxy_web(self.hass, request, stream_coro) # streaming via ffmpeg - + assert self._rtsp_url is not None streaming_url = self._rtsp_url stream = CameraMjpeg(self._ffmpeg.binary) await stream.open_camera(streaming_url, extra_cmd=self._ffmpeg_arguments) @@ -255,12 +293,12 @@ def should_poll(self) -> bool: return True @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the Amcrest-specific camera state attributes.""" attr = {} if self._audio_enabled is not None: @@ -274,78 +312,78 @@ def extra_state_attributes(self): return attr @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._api.available @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" return SUPPORT_ON_OFF | SUPPORT_STREAM # Camera property overrides @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" return self._is_recording @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return self._brand @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._motion_detection_enabled @property - def model(self): + def model(self) -> str | None: """Return the camera model.""" return self._model - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" return self._rtsp_url @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self.is_streaming # Other Entity method overrides - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to signals and add camera to list.""" - for service, params in CAMERA_SERVICES.items(): - self._unsub_dispatcher.append( - async_dispatcher_connect( - self.hass, - service_signal(service, self.entity_id), - getattr(self, params[1]), - ) + self._unsub_dispatcher.extend( + async_dispatcher_connect( + self.hass, + service_signal(service, self.entity_id), + getattr(self, callback_name), ) + for service, (_, callback_name, _) in CAMERA_SERVICES.items() + ) self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, - service_signal(SERVICE_UPDATE, self._name), + 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): + async def async_will_remove_from_hass(self) -> None: """Remove camera from list and disconnect from signals.""" self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() - def update(self): + def update(self) -> None: """Update entity status.""" if not self.available or self._update_succeeded: if not self.available: @@ -355,17 +393,26 @@ def update(self): try: if self._brand is None: resp = self._api.vendor_information.strip() - if resp.startswith("vendor="): - self._brand = resp.split("=")[-1] + _LOGGER.debug("Assigned brand=%s", resp) + if resp: + self._brand = resp else: self._brand = "unknown" if self._model is None: resp = self._api.device_type.strip() - if resp.startswith("type="): - self._model = resp.split("=")[-1] + _LOGGER.debug("Assigned model=%s", resp) + if resp: + self._model = resp else: self._model = "unknown" - self.is_streaming = self._get_video() + if self._attr_unique_id is None: + serial_number = self._api.serial_number.strip() + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{self._resolution}-{self._channel}" + ) + _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) + self._attr_is_streaming = self._get_video() self._is_recording = self._get_recording() self._motion_detection_enabled = self._get_motion_detection() self._audio_enabled = self._get_audio() @@ -380,65 +427,65 @@ def update(self): # Other Camera method overrides - def turn_off(self): + def turn_off(self) -> None: """Turn off camera.""" self._enable_video(False) - def turn_on(self): + def turn_on(self) -> None: """Turn on camera.""" self._enable_video(True) - def enable_motion_detection(self): + def enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" self._enable_motion_detection(True) - def disable_motion_detection(self): + def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" self._enable_motion_detection(False) # Additional Amcrest Camera service methods - async def async_enable_recording(self): + async def async_enable_recording(self) -> None: """Call the job and enable recording.""" await self.hass.async_add_executor_job(self._enable_recording, True) - async def async_disable_recording(self): + async def async_disable_recording(self) -> None: """Call the job and disable recording.""" await self.hass.async_add_executor_job(self._enable_recording, False) - async def async_enable_audio(self): + async def async_enable_audio(self) -> None: """Call the job and enable audio.""" await self.hass.async_add_executor_job(self._enable_audio, True) - async def async_disable_audio(self): + async def async_disable_audio(self) -> None: """Call the job and disable audio.""" await self.hass.async_add_executor_job(self._enable_audio, False) - async def async_enable_motion_recording(self): + async def async_enable_motion_recording(self) -> None: """Call the job and enable motion recording.""" await self.hass.async_add_executor_job(self._enable_motion_recording, True) - async def async_disable_motion_recording(self): + async def async_disable_motion_recording(self) -> None: """Call the job and disable motion recording.""" await self.hass.async_add_executor_job(self._enable_motion_recording, False) - async def async_goto_preset(self, preset): + async def async_goto_preset(self, preset: int) -> None: """Call the job and move camera to preset position.""" await self.hass.async_add_executor_job(self._goto_preset, preset) - async def async_set_color_bw(self, color_bw): + async def async_set_color_bw(self, color_bw: str) -> None: """Call the job and set camera color mode.""" await self.hass.async_add_executor_job(self._set_color_bw, color_bw) - async def async_start_tour(self): + async def async_start_tour(self) -> None: """Call the job and start camera tour.""" await self.hass.async_add_executor_job(self._start_tour, True) - async def async_stop_tour(self): + async def async_stop_tour(self) -> None: """Call the job and stop camera tour.""" await self.hass.async_add_executor_job(self._start_tour, False) - async def async_ptz_control(self, movement, travel_time): + async def async_ptz_control(self, movement: str, travel_time: float) -> None: """Move or zoom camera in specified direction.""" code = _ACTION[_MOV.index(movement)] @@ -463,11 +510,14 @@ async def async_ptz_control(self, movement, travel_time): # Methods to send commands to Amcrest camera and handle errors - def _change_setting(self, value, attr, description, action="set"): + def _change_setting( + self, value: str | bool, description: str, attr: str | None = None + ) -> None: func = description.replace(" ", "_") description = f"camera {description} to {value}" - tries = 3 - while True: + action = "set" + max_tries = 3 + for tries in range(max_tries, 0, -1): try: getattr(self, f"_set_{func}")(value) new_value = getattr(self, f"_get_{func}")() @@ -485,109 +535,113 @@ def _change_setting(self, value, attr, description, action="set"): setattr(self, attr, new_value) self.schedule_update_ha_state() return - tries -= 1 - def _get_video(self): + def _get_video(self) -> bool: return self._api.video_enabled - def _set_video(self, enable): + def _set_video(self, enable: bool) -> None: self._api.video_enabled = enable - def _enable_video(self, enable): + def _enable_video(self, enable: bool) -> None: """Enable or disable camera video stream.""" # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # recording on if video stream is being turned off. if self.is_recording and not enable: self._enable_recording(False) - self._change_setting(enable, "is_streaming", "video") + self._change_setting(enable, "video", "is_streaming") if self._control_light: self._change_light() - def _get_recording(self): + def _get_recording(self) -> bool: return self._api.record_mode == "Manual" - def _set_recording(self, enable): + def _set_recording(self, enable: bool) -> None: rec_mode = {"Automatic": 0, "Manual": 1} - self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] + # The property has a str type, but setter has int type, which causes mypy confusion + self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] # type: ignore[assignment] - def _enable_recording(self, enable): + def _enable_recording(self, enable: bool) -> None: """Turn recording on or off.""" # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # video stream off if recording is being turned on. if not self.is_streaming and enable: self._enable_video(True) - self._change_setting(enable, "_is_recording", "recording") + self._change_setting(enable, "recording", "_is_recording") - def _get_motion_detection(self): + def _get_motion_detection(self) -> bool: return self._api.is_motion_detector_on() - def _set_motion_detection(self, enable): - self._api.motion_detection = str(enable).lower() + def _set_motion_detection(self, enable: bool) -> None: + # The property has a str type, but setter has bool type, which causes mypy confusion + self._api.motion_detection = enable # type: ignore[assignment] - def _enable_motion_detection(self, enable): + def _enable_motion_detection(self, enable: bool) -> None: """Enable or disable motion detection.""" - self._change_setting(enable, "_motion_detection_enabled", "motion detection") + self._change_setting(enable, "motion detection", "_motion_detection_enabled") - def _get_audio(self): + def _get_audio(self) -> bool: return self._api.audio_enabled - def _set_audio(self, enable): + def _set_audio(self, enable: bool) -> None: self._api.audio_enabled = enable - def _enable_audio(self, enable): + def _enable_audio(self, enable: bool) -> None: """Enable or disable audio stream.""" - self._change_setting(enable, "_audio_enabled", "audio") + self._change_setting(enable, "audio", "_audio_enabled") if self._control_light: self._change_light() - def _get_indicator_light(self): - return "true" in self._api.command( - "configManager.cgi?action=getConfig&name=LightGlobal" - ).content.decode("utf-8") + def _get_indicator_light(self) -> bool: + return ( + "true" + in self._api.command( + "configManager.cgi?action=getConfig&name=LightGlobal" + ).content.decode() + ) - def _set_indicator_light(self, enable): + def _set_indicator_light(self, enable: bool) -> None: self._api.command( f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={str(enable).lower()}" ) - def _change_light(self): + def _change_light(self) -> None: """Enable or disable indicator light.""" self._change_setting( - self._audio_enabled or self.is_streaming, None, "indicator light" + self._audio_enabled or self.is_streaming, "indicator light" ) - def _get_motion_recording(self): + def _get_motion_recording(self) -> bool: return self._api.is_record_on_motion_detection() - def _set_motion_recording(self, enable): - self._api.motion_recording = str(enable).lower() + def _set_motion_recording(self, enable: bool) -> None: + self._api.motion_recording = enable - def _enable_motion_recording(self, enable): + def _enable_motion_recording(self, enable: bool) -> None: """Enable or disable motion recording.""" - self._change_setting(enable, "_motion_recording_enabled", "motion recording") + self._change_setting(enable, "motion recording", "_motion_recording_enabled") - def _goto_preset(self, preset): + def _goto_preset(self, preset: int) -> None: """Move camera position and zoom to preset.""" try: - self._api.go_to_preset(action="start", preset_point_number=preset) + self._api.go_to_preset(preset_point_number=preset) except AmcrestError as error: log_update_error( _LOGGER, "move", self.name, f"camera to preset {preset}", error ) - def _get_color_mode(self): + def _get_color_mode(self) -> str: return _CBW[self._api.day_night_color] - def _set_color_mode(self, cbw): + def _set_color_mode(self, cbw: str) -> None: self._api.day_night_color = _CBW.index(cbw) - def _set_color_bw(self, cbw): + def _set_color_bw(self, cbw: str) -> None: """Set camera color mode.""" - self._change_setting(cbw, "_color_bw", "color mode") + self._change_setting(cbw, "color mode", "_color_bw") - def _start_tour(self, start): + def _start_tour(self, start: bool) -> None: """Start camera tour.""" try: self._api.tour(start=start) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index ba7597d61af28..89cde63a08a73 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -13,7 +13,3 @@ SERVICE_EVENT = "event" SERVICE_UPDATE = "update" - -SENSOR_DEVICE_CLASS = "class" -SENSOR_EVENT_CODE = "code" -SENSOR_NAME = "name" diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index ef0ae2db15be5..ff1a283769dc4 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -1,15 +1,24 @@ """Helpers for amcrest component.""" +from __future__ import annotations + import logging from .const import DOMAIN -def service_signal(service, *args): +def service_signal(service: str, *args: str) -> str: """Encode signal.""" return "_".join([DOMAIN, service, *args]) -def log_update_error(logger, action, name, entity_type, error, level=logging.ERROR): +def log_update_error( + logger: logging.Logger, + action: str, + name: str | None, + entity_type: str, + error: Exception, + level: int = logging.ERROR, +) -> None: """Log an update error.""" logger.log( level, diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 702e6a614874e..0d6c1380c2009 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,8 +2,8 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.7.2"], + "requirements": ["amcrest==1.9.3"], "dependencies": ["ffmpeg"], - "codeowners": [], + "codeowners": ["@flacjacket"], "iot_class": "local_polling" } diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index a30de62494e52..c262e16ec7cf7 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,40 +1,68 @@ """Support for Amcrest IP camera sensors.""" +from __future__ import annotations + +from collections.abc import Callable from datetime import timedelta import logging +from typing import TYPE_CHECKING from amcrest import AmcrestError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, CONF_SENSORS, PERCENTAGE +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) SENSOR_PTZ_PRESET = "ptz_preset" SENSOR_SDCARD = "sdcard" -# Sensor types are defined like: Name, units, icon -SENSORS = { - SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"], - SENSOR_SDCARD: ["SD Used", PERCENTAGE, "mdi:sd"], -} - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_PTZ_PRESET, + name="PTZ Preset", + icon="mdi:camera-iris", + ), + SensorEntityDescription( + key=SENSOR_SDCARD, + name="SD Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:sd", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a sensor for an Amcrest IP Camera.""" if discovery_info is None: return name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] + sensors = discovery_info[CONF_SENSORS] async_add_entities( [ - AmcrestSensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_SENSORS] + AmcrestSensor(name, device, description) + for description in SENSOR_TYPES + if description.key in sensors ], True, ) @@ -43,86 +71,73 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestSensor(SensorEntity): """A sensor implementation for Amcrest IP camera.""" - def __init__(self, name, device, sensor_type): + def __init__( + self, name: str, device: AmcrestDevice, description: SensorEntityDescription + ) -> None: """Initialize a sensor for Amcrest camera.""" - self._name = f"{name} {SENSORS[sensor_type][0]}" + self.entity_description = description self._signal_name = name self._api = device.api - self._sensor_type = sensor_type - self._state = None - self._attrs = {} - self._unit_of_measurement = SENSORS[sensor_type][1] - self._icon = SENSORS[sensor_type][2] - self._unsub_dispatcher = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._channel = device.channel + self._unsub_dispatcher: Callable[[], None] | None = None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs + self._attr_name = f"{name} {description.name}" + self._attr_extra_state_attributes = {} @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit_of_measurement - - @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._api.available - def update(self): + def update(self) -> None: """Get the latest data and updates the state.""" if not self.available: return - _LOGGER.debug("Updating %s sensor", self._name) + _LOGGER.debug("Updating %s sensor", self.name) + + sensor_type = self.entity_description.key + if self._attr_unique_id is None and (serial_number := self._api.serial_number): + self._attr_unique_id = f"{serial_number}-{sensor_type}-{self._channel}" try: - if self._sensor_type == SENSOR_PTZ_PRESET: - self._state = self._api.ptz_presets_count + if self._attr_unique_id is None and ( + serial_number := self._api.serial_number + ): + self._attr_unique_id = f"{serial_number}-{sensor_type}-{self._channel}" + + if sensor_type == SENSOR_PTZ_PRESET: + self._attr_native_value = self._api.ptz_presets_count - elif self._sensor_type == SENSOR_SDCARD: + elif sensor_type == SENSOR_SDCARD: storage = self._api.storage_all try: - self._attrs[ + self._attr_extra_state_attributes[ "Total" ] = f"{storage['total'][0]:.2f} {storage['total'][1]}" except ValueError: - self._attrs[ + self._attr_extra_state_attributes[ "Total" ] = f"{storage['total'][0]} {storage['total'][1]}" try: - self._attrs[ + self._attr_extra_state_attributes[ "Used" ] = f"{storage['used'][0]:.2f} {storage['used'][1]}" except ValueError: - self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}" + self._attr_extra_state_attributes[ + "Used" + ] = f"{storage['used'][0]} {storage['used'][1]}" try: - self._state = f"{storage['used_percent']:.2f}" + self._attr_native_value = f"{storage['used_percent']:.2f}" except ValueError: - self._state = storage["used_percent"] + self._attr_native_value = storage["used_percent"] except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "sensor", error) - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to update signal.""" self._unsub_dispatcher = async_dispatcher_connect( self.hass, @@ -130,6 +145,7 @@ async def async_added_to_hass(self): self.async_on_demand_update, ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect from update signal.""" + assert self._unsub_dispatcher is not None self._unsub_dispatcher() diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml index c4a12c5982852..7baad96d6d56a 100644 --- a/homeassistant/components/amcrest/services.yaml +++ b/homeassistant/components/amcrest/services.yaml @@ -70,12 +70,14 @@ goto_preset: fields: entity_id: description: "Name(s) of the cameras, or 'all' for all cameras." - example: "camera.house_front" + selector: + entity: + integration: amcrest + domain: camera preset: name: Preset description: Preset number. required: true - example: 1 selector: number: min: 1 @@ -94,7 +96,6 @@ set_color_bw: color_bw: name: Color description: Color mode. - example: auto selector: select: options: @@ -138,7 +139,6 @@ ptz_control: name: Movement description: "Direction to move the camera." required: true - example: "right" selector: select: options: @@ -155,7 +155,6 @@ ptz_control: travel_time: name: Travel time description: "Travel time in fractional seconds: from 0 to 1." - example: ".5" default: .2 selector: number: diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py new file mode 100644 index 0000000000000..876deeacf9179 --- /dev/null +++ b/homeassistant/components/amcrest/switch.py @@ -0,0 +1,90 @@ +"""Support for Amcrest Switches.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import CONF_NAME, CONF_SWITCHES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DATA_AMCREST, DEVICES + +if TYPE_CHECKING: + from . import AmcrestDevice + +PRIVACY_MODE_KEY = "privacy_mode" + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=PRIVACY_MODE_KEY, + name="Privacy Mode", + icon="mdi:eye-off", + ), +) + +SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up amcrest platform switches.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST][DEVICES][name] + switches = discovery_info[CONF_SWITCHES] + async_add_entities( + [ + AmcrestSwitch(name, device, description) + for description in SWITCH_TYPES + if description.key in switches + ], + True, + ) + + +class AmcrestSwitch(SwitchEntity): + """Representation of an Amcrest Camera Switch.""" + + def __init__( + self, + name: str, + device: AmcrestDevice, + entity_description: SwitchEntityDescription, + ) -> None: + """Initialize switch.""" + self._api = device.api + self.entity_description = entity_description + self._attr_name = f"{name} {entity_description.name}" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._api.available + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._turn_switch(True) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._turn_switch(False) + + def _turn_switch(self, mode: bool) -> None: + """Set privacy mode.""" + lower_str = str(mode).lower() + self._api.command( + f"configManager.cgi?action=setConfig&LeLensMask[0].Enable={lower_str}" + ) + + def update(self) -> None: + """Update switch.""" + io_res = self._api.privacy_config().splitlines()[0].split("=")[1] + self._attr_is_on = io_res == "true" diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index c925909a9a81b..f8119e9c1b454 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -1,28 +1,39 @@ """Support for Ampio Air Quality data.""" -from datetime import timedelta +from __future__ import annotations + import logging +from typing import Final from asmog import AmpioSmog import voluptuous as vol -from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity +from homeassistant.components.air_quality import ( + PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, + AirQualityEntity, +) from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) +from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL -ATTRIBUTION = "Data provided by Ampio" -CONF_STATION_ID = "station_id" -SCAN_INTERVAL = timedelta(minutes=10) +_LOGGER: Final = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( {vol.Required(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME): cv.string} ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Ampio Smog air quality platform.""" name = config.get(CONF_NAME) @@ -43,38 +54,40 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" - def __init__(self, api, station_id, name): + def __init__( + self, api: AmpioSmogMapData, station_id: str, name: str | None + ) -> None: """Initialize the air quality entity.""" self._ampio = api self._station_id = station_id self._name = name or api.api.name @property - def name(self): + def name(self) -> str: """Return the name of the air quality entity.""" return self._name @property - def unique_id(self): + def unique_id(self) -> str: """Return unique_name.""" return f"ampio_smog_{self._station_id}" @property - def particulate_matter_2_5(self): + def particulate_matter_2_5(self) -> str | None: """Return the particulate matter 2.5 level.""" - return self._ampio.api.pm2_5 + return self._ampio.api.pm2_5 # type: ignore[no-any-return] @property - def particulate_matter_10(self): + def particulate_matter_10(self) -> str | None: """Return the particulate matter 10 level.""" - return self._ampio.api.pm10 + return self._ampio.api.pm10 # type: ignore[no-any-return] @property - def attribution(self): + def attribution(self) -> str: """Return the attribution.""" return ATTRIBUTION - async def async_update(self): + async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() @@ -82,11 +95,11 @@ async def async_update(self): class AmpioSmogMapData: """Get the latest data and update the states.""" - def __init__(self, api): + def __init__(self, api: AmpioSmog) -> None: """Initialize the data object.""" self.api = api @Throttle(SCAN_INTERVAL) - async def async_update(self): + async def async_update(self) -> None: """Get the latest data from AmpioMap.""" await self.api.get_data() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py new file mode 100644 index 0000000000000..3162308ff416a --- /dev/null +++ b/homeassistant/components/ampio/const.py @@ -0,0 +1,7 @@ +"""Constants for Ampio Air Quality platform.""" +from datetime import timedelta +from typing import Final + +ATTRIBUTION: Final = "Data provided by Ampio" +CONF_STATION_ID: Final = "station_id" +SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index d41970a79de49..f7bdb303eb708 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,12 +5,13 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.typing import ConfigType from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA -async def async_setup(hass: HomeAssistant, _): +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Set up the analytics integration.""" analytics = Analytics(hass) @@ -35,8 +36,8 @@ async def start_schedule(_event): @websocket_api.require_admin -@websocket_api.async_response @websocket_api.websocket_command({vol.Required("type"): "analytics"}) +@websocket_api.async_response async def websocket_analytics( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, @@ -51,13 +52,13 @@ async def websocket_analytics( @websocket_api.require_admin -@websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "analytics/preferences", vol.Required("preferences", default={}): PREFERENCE_SCHEMA, } ) +@websocket_api.async_response async def websocket_analytics_preferences( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index e6e7ffac3371d..d1b8879bf7c11 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -1,5 +1,6 @@ """Analytics helper class for the analytics integration.""" import asyncio +from typing import cast import uuid import aiohttp @@ -8,6 +9,10 @@ from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE from homeassistant.components.automation.const import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.energy import ( + DOMAIN as ENERGY_DOMAIN, + is_configured as energy_is_configured, +) from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -21,12 +26,15 @@ ANALYTICS_ENDPOINT_URL_DEV, ATTR_ADDON_COUNT, ATTR_ADDONS, + ATTR_ARCH, ATTR_AUTO_UPDATE, ATTR_AUTOMATION_COUNT, ATTR_BASE, ATTR_BOARD, + ATTR_CONFIGURED, ATTR_CUSTOM_INTEGRATIONS, ATTR_DIAGNOSTICS, + ATTR_ENERGY, ATTR_HEALTHY, ATTR_INTEGRATION_COUNT, ATTR_INTEGRATIONS, @@ -57,7 +65,11 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize the Analytics class.""" self.hass: HomeAssistant = hass self.session = async_get_clientsession(hass) - self._data = {ATTR_PREFERENCES: {}, ATTR_ONBOARDED: False, ATTR_UUID: None} + self._data: dict = { + ATTR_PREFERENCES: {}, + ATTR_ONBOARDED: False, + ATTR_UUID: None, + } self._store: Store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @property @@ -96,7 +108,7 @@ def supervisor(self) -> bool: async def load(self) -> None: """Load preferences.""" - stored = await self._store.async_load() + stored = cast(dict, await self._store.async_load()) if stored: self._data = stored @@ -157,6 +169,7 @@ async def send_analytics(self, _=None) -> None: payload[ATTR_SUPERVISOR] = { ATTR_HEALTHY: supervisor_info[ATTR_HEALTHY], ATTR_SUPPORTED: supervisor_info[ATTR_SUPPORTED], + ATTR_ARCH: supervisor_info[ATTR_ARCH], } if operating_system_info.get(ATTR_BOARD) is not None: @@ -169,10 +182,10 @@ async def send_analytics(self, _=None) -> None: ATTR_STATISTICS, False ): configured_integrations = await asyncio.gather( - *[ + *( async_get_integration(self.hass, domain) for domain in async_get_loaded_integrations(self.hass) - ], + ), return_exceptions=True, ) @@ -199,10 +212,10 @@ async def send_analytics(self, _=None) -> None: if supervisor_info is not None: installed_addons = await asyncio.gather( - *[ + *( hassio.async_get_addon_info(self.hass, addon[ATTR_SLUG]) for addon in supervisor_info[ATTR_ADDONS] - ] + ) ) for addon in installed_addons: addons.append( @@ -220,6 +233,11 @@ async def send_analytics(self, _=None) -> None: if supervisor_info is not None: payload[ATTR_ADDONS] = addons + if ENERGY_DOMAIN in integrations: + payload[ATTR_ENERGY] = { + ATTR_CONFIGURED: await energy_is_configured(self.hass) + } + if self.preferences.get(ATTR_STATISTICS, False): payload[ATTR_STATE_COUNT] = len(self.hass.states.async_all()) payload[ATTR_AUTOMATION_COUNT] = len( @@ -237,7 +255,7 @@ async def send_analytics(self, _=None) -> None: ) try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): response = await self.session.post(self.endpoint, json=payload) if response.status == 200: LOGGER.info( diff --git a/homeassistant/components/analytics/const.py b/homeassistant/components/analytics/const.py index 16929a7131d4c..8576e22073f13 100644 --- a/homeassistant/components/analytics/const.py +++ b/homeassistant/components/analytics/const.py @@ -16,12 +16,15 @@ ATTR_ADDON_COUNT = "addon_count" ATTR_ADDONS = "addons" +ATTR_ARCH = "arch" ATTR_AUTO_UPDATE = "auto_update" ATTR_AUTOMATION_COUNT = "automation_count" ATTR_BASE = "base" ATTR_BOARD = "board" +ATTR_CONFIGURED = "configured" ATTR_CUSTOM_INTEGRATIONS = "custom_integrations" ATTR_DIAGNOSTICS = "diagnostics" +ATTR_ENERGY = "energy" ATTR_HEALTHY = "healthy" ATTR_INSTALLATION_TYPE = "installation_type" ATTR_INTEGRATION_COUNT = "integration_count" diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 49edf1bcf8c3c..2dae8d4e629c7 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -2,8 +2,17 @@ "domain": "analytics", "name": "Analytics", "documentation": "https://www.home-assistant.io/integrations/analytics", - "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "codeowners": [ + "@home-assistant/core", + "@ludeeus" + ], + "dependencies": [ + "api", + "websocket_api" + ], + "after_dependencies": [ + "energy" + ], "quality_scale": "internal", "iot_class": "cloud_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant/components/android_ip_webcam/binary_sensor.py index 377ecfec66759..82d18d0ca3d26 100644 --- a/homeassistant/components/android_ip_webcam/binary_sensor.py +++ b/homeassistant/components/android_ip_webcam/binary_sensor.py @@ -1,6 +1,6 @@ """Support for Android IP Webcam binary sensors.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, + BinarySensorDeviceClass, BinarySensorEntity, ) @@ -22,32 +22,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorEntity): """Representation of an IP Webcam binary sensor.""" + _attr_device_class = BinarySensorDeviceClass.MOTION + def __init__(self, name, host, ipcam, sensor): """Initialize the binary sensor.""" super().__init__(host, ipcam) self._sensor = sensor self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the binary sensor, if any.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state + self._attr_name = f"{name} {self._mapped_name}" + self._attr_is_on = None async def async_update(self): """Retrieve latest state.""" state, _ = self._ipcam.export_sensor(self._sensor) - self._state = state == 1.0 - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_MOTION + self._attr_is_on = state == 1.0 diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index adedb297cd1fb..5690dab093741 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -40,38 +40,26 @@ def __init__(self, name, host, ipcam, sensor): self._sensor = sensor self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{name} {self._mapped_name}" + self._attr_native_value = None + self._attr_native_unit_of_measurement = None async def async_update(self): """Retrieve latest state.""" if self._sensor in ("audio_connections", "video_connections"): if not self._ipcam.status_data: return - self._state = self._ipcam.status_data.get(self._sensor) - self._unit = "Connections" + self._attr_native_value = self._ipcam.status_data.get(self._sensor) + self._attr_native_unit_of_measurement = "Connections" else: - self._state, self._unit = self._ipcam.export_sensor(self._sensor) + ( + self._attr_native_value, + self._attr_native_unit_of_measurement, + ) = self._ipcam.export_sensor(self._sensor) @property def icon(self): """Return the icon for the sensor.""" - if self._sensor == "battery_level" and self._state is not None: - return icon_for_battery_level(int(self._state)) + if self._sensor == "battery_level" and self._attr_native_value is not None: + return icon_for_battery_level(int(self._attr_native_value)) return ICON_MAP.get(self._sensor, "mdi:eye") diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index bdbb37e76613e..3adb958c4ff1b 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -39,22 +39,12 @@ def __init__(self, name, host, ipcam, setting): self._setting = setting self._mapped_name = KEY_MAP.get(self._setting, self._setting) - self._name = f"{name} {self._mapped_name}" - self._state = False - - @property - def name(self): - """Return the name of the node.""" - return self._name + self._attr_name = f"{name} {self._mapped_name}" + self._attr_is_on = False async def async_update(self): """Get the updated status of the switch.""" - self._state = bool(self._ipcam.current_settings.get(self._setting)) - - @property - def is_on(self): - """Return the boolean response if the node is on.""" - return self._state + self._attr_is_on = bool(self._ipcam.current_settings.get(self._setting)) async def async_turn_on(self, **kwargs): """Turn device on.""" @@ -66,7 +56,7 @@ async def async_turn_on(self, **kwargs): await self._ipcam.record(record=True) else: await self._ipcam.change_setting(self._setting, True) - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs): @@ -79,7 +69,7 @@ async def async_turn_off(self, **kwargs): await self._ipcam.record(record=False) else: await self._ipcam.change_setting(self._setting, False) - self._state = False + self._attr_is_on = False self.async_write_ha_state() @property diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 14832aef31587..d64329526b88b 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1 +1,192 @@ """Support for functionality to interact with Android TV/Fire TV devices.""" +import logging +import os + +from adb_shell.auth.keygen import keygen +from androidtv.adb_manager.adb_manager_sync import ADBPythonSync +from androidtv.setup_async import setup + +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.storage import STORAGE_DIR + +from .const import ( + ANDROID_DEV, + ANDROID_DEV_OPT, + CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, + CONF_ADBKEY, + CONF_STATE_DETECTION_RULES, + DEFAULT_ADB_SERVER_PORT, + DEVICE_ANDROIDTV, + DEVICE_FIRETV, + DOMAIN, + PROP_SERIALNO, + SIGNAL_CONFIG_ENTITY, +) + +PLATFORMS = [Platform.MEDIA_PLAYER] +RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES] + +_LOGGER = logging.getLogger(__name__) + + +def _setup_androidtv(hass, config): + """Generate an ADB key (if needed) and load it.""" + adbkey = config.get(CONF_ADBKEY, hass.config.path(STORAGE_DIR, "androidtv_adbkey")) + if CONF_ADB_SERVER_IP not in config: + # Use "adb_shell" (Python ADB implementation) + if not os.path.isfile(adbkey): + # Generate ADB key files + keygen(adbkey) + + # Load the ADB key + signer = ADBPythonSync.load_adbkey(adbkey) + adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" + + else: + # Use "pure-python-adb" (communicate with ADB server) + signer = None + adb_log = f"using ADB server at {config[CONF_ADB_SERVER_IP]}:{config[CONF_ADB_SERVER_PORT]}" + + return adbkey, signer, adb_log + + +async def async_connect_androidtv( + hass, config, *, state_detection_rules=None, timeout=30.0 +): + """Connect to Android device.""" + address = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + + adbkey, signer, adb_log = await hass.async_add_executor_job( + _setup_androidtv, hass, config + ) + + aftv = await setup( + config[CONF_HOST], + config[CONF_PORT], + adbkey, + config.get(CONF_ADB_SERVER_IP), + config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT), + state_detection_rules, + config[CONF_DEVICE_CLASS], + timeout, + signer, + ) + + if not aftv.available: + # Determine the name that will be used for the device in the log + if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: + device_name = "Android TV device" + elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: + device_name = "Fire TV device" + else: + device_name = "Android TV / Fire TV device" + + error_message = f"Could not connect to {device_name} at {address} {adb_log}" + return None, error_message + + return aftv, None + + +def _migrate_aftv_entity(hass, aftv, entry_unique_id): + """Migrate a entity to new unique id.""" + entity_reg = er.async_get(hass) + + entity_unique_id = entry_unique_id + if entity_reg.async_get_entity_id(MP_DOMAIN, DOMAIN, entity_unique_id): + # entity already exist, nothing to do + return + + old_unique_id = aftv.device_properties.get(PROP_SERIALNO) + if not old_unique_id: + # serial no not found, exit + return + + migr_entity = entity_reg.async_get_entity_id(MP_DOMAIN, DOMAIN, old_unique_id) + if not migr_entity: + # old entity not found, exit + return + + try: + entity_reg.async_update_entity(migr_entity, new_unique_id=entity_unique_id) + except ValueError as exp: + _LOGGER.warning("Migration of old entity failed: %s", exp) + + +async def async_setup(hass, config): + """Set up the Android TV integration.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Android TV platform.""" + + state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) + aftv, error_message = await async_connect_androidtv( + hass, entry.data, state_detection_rules=state_det_rules + ) + if not aftv: + raise ConfigEntryNotReady(error_message) + + # migrate existing entity to new unique ID + if entry.source == SOURCE_IMPORT: + _migrate_aftv_entity(hass, aftv, entry.unique_id) + + async def async_close_connection(event): + """Close Android TV connection on HA Stop.""" + await aftv.adb_close() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) + ) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + ANDROID_DEV: aftv, + ANDROID_DEV_OPT: entry.options.copy(), + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] + await aftv.adb_close() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Update when config_entry options update.""" + reload_opt = False + old_options = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV_OPT] + for opt_key, opt_val in entry.options.items(): + if opt_key in RELOAD_OPTIONS: + old_val = old_options.get(opt_key) + if old_val is None or old_val != opt_val: + reload_opt = True + break + + if reload_opt: + await hass.config_entries.async_reload(entry.entry_id) + return + + hass.data[DOMAIN][entry.entry_id][ANDROID_DEV_OPT] = entry.options.copy() + async_dispatcher_send(hass, f"{SIGNAL_CONFIG_ENTITY}_{entry.entry_id}") diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py new file mode 100644 index 0000000000000..c346378fbc221 --- /dev/null +++ b/homeassistant/components/androidtv/config_flow.py @@ -0,0 +1,378 @@ +"""Config flow to configure the Android TV integration.""" +import json +import logging +import os +import socket + +from androidtv import state_detection_rules_validator +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from . import async_connect_androidtv +from .const import ( + CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, + CONF_ADBKEY, + CONF_APPS, + CONF_EXCLUDE_UNNAMED_APPS, + CONF_GET_SOURCES, + CONF_MIGRATION_OPTIONS, + CONF_SCREENCAP, + CONF_STATE_DETECTION_RULES, + CONF_TURN_OFF_COMMAND, + CONF_TURN_ON_COMMAND, + DEFAULT_ADB_SERVER_PORT, + DEFAULT_DEVICE_CLASS, + DEFAULT_EXCLUDE_UNNAMED_APPS, + DEFAULT_GET_SOURCES, + DEFAULT_PORT, + DEFAULT_SCREENCAP, + DEVICE_CLASSES, + DOMAIN, + PROP_ETHMAC, + PROP_WIFIMAC, +) + +APPS_NEW_ID = "NewApp" +CONF_APP_DELETE = "app_delete" +CONF_APP_ID = "app_id" +CONF_APP_NAME = "app_name" + +RULES_NEW_ID = "NewRule" +CONF_RULE_DELETE = "rule_delete" +CONF_RULE_ID = "rule_id" +CONF_RULE_VALUES = "rule_values" + +RESULT_CONN_ERROR = "cannot_connect" +RESULT_UNKNOWN = "unknown" + +_LOGGER = logging.getLogger(__name__) + + +def _is_file(value): + """Validate that the value is an existing file.""" + file_in = os.path.expanduser(str(value)) + return os.path.isfile(file_in) and os.access(file_in, os.R_OK) + + +def _get_ip(host): + """Get the ip address from the host name.""" + try: + return socket.gethostbyname(host) + except socket.gaierror: + return None + + +class AndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize AndroidTV config flow.""" + self._import_options = None + + @callback + def _show_setup_form(self, user_input=None, error=None): + """Show the setup form to the user.""" + user_input = user_input or {} + data_schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Required(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( + DEVICE_CLASSES + ), + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + }, + ) + + if self.show_advanced_options: + data_schema = data_schema.extend( + { + vol.Optional(CONF_ADBKEY): str, + vol.Optional(CONF_ADB_SERVER_IP): str, + vol.Required( + CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT + ): cv.port, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": error}, + ) + + async def _async_check_connection(self, user_input): + """Attempt to connect the Android TV.""" + + try: + aftv, error_message = await async_connect_androidtv(self.hass, user_input) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + ) + return RESULT_UNKNOWN, None + + if not aftv: + _LOGGER.warning(error_message) + return RESULT_CONN_ERROR, None + + dev_prop = aftv.device_properties + unique_id = format_mac( + dev_prop.get(PROP_ETHMAC) or dev_prop.get(PROP_WIFIMAC, "") + ) + await aftv.adb_close() + return None, unique_id + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + error = None + + if user_input is not None: + host = user_input[CONF_HOST] + adb_key = user_input.get(CONF_ADBKEY) + adb_server = user_input.get(CONF_ADB_SERVER_IP) + + if adb_key and adb_server: + return self._show_setup_form(user_input, "key_and_server") + + if adb_key: + isfile = await self.hass.async_add_executor_job(_is_file, adb_key) + if not isfile: + return self._show_setup_form(user_input, "adbkey_not_file") + + ip_address = await self.hass.async_add_executor_job(_get_ip, host) + if not ip_address: + return self._show_setup_form(user_input, "invalid_host") + + self._async_abort_entries_match({CONF_HOST: host}) + if ip_address != host: + self._async_abort_entries_match({CONF_HOST: ip_address}) + + error, unique_id = await self._async_check_connection(user_input) + if error is None: + if not unique_id: + return self.async_abort(reason="invalid_unique_id") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input.get(CONF_NAME) or host, + data=user_input, + options=self._import_options, + ) + + user_input = user_input or {} + return self._show_setup_form(user_input, error) + + async def async_step_import(self, import_config=None): + """Import a config entry.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == import_config[CONF_HOST]: + _LOGGER.warning( + "Host [%s] already configured. This yaml configuration has already been imported. Please remove it", + import_config[CONF_HOST], + ) + return self.async_abort(reason="already_configured") + self._import_options = import_config.pop(CONF_MIGRATION_OPTIONS, None) + return await self.async_step_user(import_config) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle an option flow for Android TV.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + apps = config_entry.options.get(CONF_APPS, {}) + det_rules = config_entry.options.get(CONF_STATE_DETECTION_RULES, {}) + self._apps = apps.copy() + self._state_det_rules = det_rules.copy() + self._conf_app_id = None + self._conf_rule_id = None + + @callback + def _save_config(self, data): + """Save the updated options.""" + new_data = { + k: v + for k, v in data.items() + if k not in [CONF_APPS, CONF_STATE_DETECTION_RULES] + } + if self._apps: + new_data[CONF_APPS] = self._apps + if self._state_det_rules: + new_data[CONF_STATE_DETECTION_RULES] = self._state_det_rules + + return self.async_create_entry(title="", data=new_data) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + if sel_app := user_input.get(CONF_APPS): + return await self.async_step_apps(None, sel_app) + if sel_rule := user_input.get(CONF_STATE_DETECTION_RULES): + return await self.async_step_rules(None, sel_rule) + return self._save_config(user_input) + + return self._async_init_form() + + @callback + def _async_init_form(self): + """Return initial configuration form.""" + + apps_list = {k: f"{v} ({k})" if v else k for k, v in self._apps.items()} + apps = {APPS_NEW_ID: "Add new", **apps_list} + rules = [RULES_NEW_ID] + list(self._state_det_rules) + options = self.config_entry.options + + data_schema = vol.Schema( + { + vol.Optional(CONF_APPS): vol.In(apps), + vol.Optional( + CONF_GET_SOURCES, + default=options.get(CONF_GET_SOURCES, DEFAULT_GET_SOURCES), + ): bool, + vol.Optional( + CONF_EXCLUDE_UNNAMED_APPS, + default=options.get( + CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS + ), + ): bool, + vol.Optional( + CONF_SCREENCAP, + default=options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP), + ): bool, + vol.Optional( + CONF_TURN_OFF_COMMAND, + description={ + "suggested_value": options.get(CONF_TURN_OFF_COMMAND, "") + }, + ): str, + vol.Optional( + CONF_TURN_ON_COMMAND, + description={ + "suggested_value": options.get(CONF_TURN_ON_COMMAND, "") + }, + ): str, + vol.Optional(CONF_STATE_DETECTION_RULES): vol.In(rules), + } + ) + + return self.async_show_form(step_id="init", data_schema=data_schema) + + async def async_step_apps(self, user_input=None, app_id=None): + """Handle options flow for apps list.""" + if app_id is not None: + self._conf_app_id = app_id if app_id != APPS_NEW_ID else None + return self._async_apps_form(app_id) + + if user_input is not None: + app_id = user_input.get(CONF_APP_ID, self._conf_app_id) + if app_id: + if user_input.get(CONF_APP_DELETE, False): + self._apps.pop(app_id) + else: + self._apps[app_id] = user_input.get(CONF_APP_NAME, "") + + return await self.async_step_init() + + @callback + def _async_apps_form(self, app_id): + """Return configuration form for apps.""" + data_schema = { + vol.Optional( + CONF_APP_NAME, + description={"suggested_value": self._apps.get(app_id, "")}, + ): str, + } + if app_id == APPS_NEW_ID: + data_schema[vol.Optional(CONF_APP_ID)] = str + else: + data_schema[vol.Optional(CONF_APP_DELETE, default=False)] = bool + + return self.async_show_form( + step_id="apps", + data_schema=vol.Schema(data_schema), + description_placeholders={ + "app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "", + }, + ) + + async def async_step_rules(self, user_input=None, rule_id=None): + """Handle options flow for detection rules.""" + if rule_id is not None: + self._conf_rule_id = rule_id if rule_id != RULES_NEW_ID else None + return self._async_rules_form(rule_id) + + if user_input is not None: + rule_id = user_input.get(CONF_RULE_ID, self._conf_rule_id) + if rule_id: + if user_input.get(CONF_RULE_DELETE, False): + self._state_det_rules.pop(rule_id) + elif str_det_rule := user_input.get(CONF_RULE_VALUES): + state_det_rule = _validate_state_det_rules(str_det_rule) + if state_det_rule is None: + return self._async_rules_form( + rule_id=self._conf_rule_id or RULES_NEW_ID, + default_id=rule_id, + errors={"base": "invalid_det_rules"}, + ) + self._state_det_rules[rule_id] = state_det_rule + + return await self.async_step_init() + + @callback + def _async_rules_form(self, rule_id, default_id="", errors=None): + """Return configuration form for detection rules.""" + state_det_rule = self._state_det_rules.get(rule_id) + str_det_rule = json.dumps(state_det_rule) if state_det_rule else "" + + data_schema = {} + if rule_id == RULES_NEW_ID: + data_schema[vol.Optional(CONF_RULE_ID, default=default_id)] = str + data_schema[vol.Optional(CONF_RULE_VALUES, default=str_det_rule)] = str + if rule_id != RULES_NEW_ID: + data_schema[vol.Optional(CONF_RULE_DELETE, default=False)] = bool + + return self.async_show_form( + step_id="rules", + data_schema=vol.Schema(data_schema), + description_placeholders={ + "rule_id": f"`{rule_id}`" if rule_id != RULES_NEW_ID else "", + }, + errors=errors, + ) + + +def _validate_state_det_rules(state_det_rules): + """Validate a string that contain state detection rules and return a dict.""" + try: + json_rules = json.loads(state_det_rules) + except ValueError: + _LOGGER.warning("Error loading state detection rules") + return None + + if not isinstance(json_rules, list): + json_rules = [json_rules] + + try: + state_detection_rules_validator(json_rules, ValueError) + except ValueError as exc: + _LOGGER.warning("Invalid state detection rules: %s", exc) + return None + return json_rules diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py new file mode 100644 index 0000000000000..f6f0c07286faa --- /dev/null +++ b/homeassistant/components/androidtv/const.py @@ -0,0 +1,34 @@ +"""Android TV component constants.""" +DOMAIN = "androidtv" + +ANDROID_DEV = DOMAIN +ANDROID_DEV_OPT = "androidtv_opt" + +CONF_ADB_SERVER_IP = "adb_server_ip" +CONF_ADB_SERVER_PORT = "adb_server_port" +CONF_ADBKEY = "adbkey" +CONF_APPS = "apps" +CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps" +CONF_GET_SOURCES = "get_sources" +CONF_MIGRATION_OPTIONS = "migration_options" +CONF_SCREENCAP = "screencap" +CONF_STATE_DETECTION_RULES = "state_detection_rules" +CONF_TURN_OFF_COMMAND = "turn_off_command" +CONF_TURN_ON_COMMAND = "turn_on_command" + +DEFAULT_ADB_SERVER_PORT = 5037 +DEFAULT_DEVICE_CLASS = "auto" +DEFAULT_EXCLUDE_UNNAMED_APPS = False +DEFAULT_GET_SOURCES = True +DEFAULT_PORT = 5555 +DEFAULT_SCREENCAP = True + +DEVICE_ANDROIDTV = "androidtv" +DEVICE_FIRETV = "firetv" +DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV] + +PROP_ETHMAC = "ethmac" +PROP_SERIALNO = "serialno" +PROP_WIFIMAC = "wifimac" + +SIGNAL_CONFIG_ENTITY = "androidtv_config" diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 9ab02fec68a3d..f50876e66296c 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,10 +3,11 @@ "name": "Android TV", "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ - "adb-shell[async]==0.3.1", - "androidtv[async]==0.0.59", + "adb-shell[async]==0.4.0", + "androidtv[async]==0.0.60", "pure-python-adb[async]==0.3.0.dev0" ], - "codeowners": ["@JeffLIrion"], + "codeowners": ["@JeffLIrion", "@ollo69"], + "config_flow": true, "iot_class": "local_polling" } diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 5db73d1491432..099c4fddba2e2 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,10 +1,10 @@ """Support for functionality to interact with Android TV / Fire TV devices.""" +from __future__ import annotations + from datetime import datetime import functools import logging -import os -from adb_shell.auth.keygen import keygen from adb_shell.exceptions import ( AdbTimeoutError, InvalidChecksumError, @@ -13,10 +13,8 @@ TcpTimeoutException, ) from androidtv import ha_state_detection_rules_validator -from androidtv.adb_manager.adb_manager_sync import ADBPythonSync from androidtv.constants import APPS, KEYS from androidtv.exceptions import LockNotAcquiredException -from androidtv.setup_async import setup import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity @@ -33,25 +31,58 @@ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_COMMAND, - ATTR_ENTITY_ID, + ATTR_CONNECTIONS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, ) -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.storage import STORAGE_DIR - -ANDROIDTV_DOMAIN = "androidtv" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ANDROID_DEV, + ANDROID_DEV_OPT, + CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, + CONF_ADBKEY, + CONF_APPS, + CONF_EXCLUDE_UNNAMED_APPS, + CONF_GET_SOURCES, + CONF_MIGRATION_OPTIONS, + CONF_SCREENCAP, + CONF_STATE_DETECTION_RULES, + CONF_TURN_OFF_COMMAND, + CONF_TURN_ON_COMMAND, + DEFAULT_ADB_SERVER_PORT, + DEFAULT_DEVICE_CLASS, + DEFAULT_EXCLUDE_UNNAMED_APPS, + DEFAULT_GET_SOURCES, + DEFAULT_PORT, + DEFAULT_SCREENCAP, + DEVICE_ANDROIDTV, + DEVICE_CLASSES, + DOMAIN, + PROP_ETHMAC, + PROP_WIFIMAC, + SIGNAL_CONFIG_ENTITY, +) _LOGGER = logging.getLogger(__name__) @@ -80,80 +111,50 @@ | SUPPORT_STOP ) +ATTR_ADB_RESPONSE = "adb_response" ATTR_DEVICE_PATH = "device_path" +ATTR_HDMI_INPUT = "hdmi_input" ATTR_LOCAL_PATH = "local_path" -CONF_ADBKEY = "adbkey" -CONF_ADB_SERVER_IP = "adb_server_ip" -CONF_ADB_SERVER_PORT = "adb_server_port" -CONF_APPS = "apps" -CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps" -CONF_GET_SOURCES = "get_sources" -CONF_STATE_DETECTION_RULES = "state_detection_rules" -CONF_TURN_ON_COMMAND = "turn_on_command" -CONF_TURN_OFF_COMMAND = "turn_off_command" -CONF_SCREENCAP = "screencap" - -DEFAULT_NAME = "Android TV" -DEFAULT_PORT = 5555 -DEFAULT_ADB_SERVER_PORT = 5037 -DEFAULT_GET_SOURCES = True -DEFAULT_DEVICE_CLASS = "auto" -DEFAULT_SCREENCAP = True - -DEVICE_ANDROIDTV = "androidtv" -DEVICE_FIRETV = "firetv" -DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV] - SERVICE_ADB_COMMAND = "adb_command" SERVICE_DOWNLOAD = "download" SERVICE_LEARN_SENDEVENT = "learn_sendevent" SERVICE_UPLOAD = "upload" -SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( - {vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string} -) - -SERVICE_DOWNLOAD_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_DEVICE_PATH): cv.string, - vol.Required(ATTR_LOCAL_PATH): cv.string, - } -) - -SERVICE_UPLOAD_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_DEVICE_PATH): cv.string, - vol.Required(ATTR_LOCAL_PATH): cv.string, - } -) - +DEFAULT_NAME = "Android TV" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( - DEVICE_CLASSES - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ADBKEY): cv.isfile, - vol.Optional(CONF_ADB_SERVER_IP): cv.string, - vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, - vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, - vol.Optional(CONF_APPS, default={}): vol.Schema( - {cv.string: vol.Any(cv.string, None)} - ), - vol.Optional(CONF_TURN_ON_COMMAND): cv.string, - vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, - vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( - {cv.string: ha_state_detection_rules_validator(vol.Invalid)} +# Deprecated in Home Assistant 2022.2 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA=PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( + DEVICE_CLASSES + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ADBKEY): cv.isfile, + vol.Optional(CONF_ADB_SERVER_IP): cv.string, + vol.Optional( + CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT + ): cv.port, + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, + vol.Optional(CONF_APPS, default={}): vol.Schema( + {cv.string: vol.Any(cv.string, None)} + ), + vol.Optional(CONF_TURN_ON_COMMAND): cv.string, + vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, + vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( + {cv.string: ha_state_detection_rules_validator(vol.Invalid)} + ), + vol.Optional( + CONF_EXCLUDE_UNNAMED_APPS, default=DEFAULT_EXCLUDE_UNNAMED_APPS + ): cv.boolean, + vol.Optional(CONF_SCREENCAP, default=DEFAULT_SCREENCAP): cv.boolean, + } ), - vol.Optional(CONF_EXCLUDE_UNNAMED_APPS, default=False): cv.boolean, - vol.Optional(CONF_SCREENCAP, default=DEFAULT_SCREENCAP): cv.boolean, - } + ) ) # Translate from `AndroidTV` / `FireTV` reported state to HA state. @@ -166,180 +167,108 @@ } -def setup_androidtv(hass, config): - """Generate an ADB key (if needed) and load it.""" - adbkey = config.get(CONF_ADBKEY, hass.config.path(STORAGE_DIR, "androidtv_adbkey")) - if CONF_ADB_SERVER_IP not in config: - # Use "adb_shell" (Python ADB implementation) - if not os.path.isfile(adbkey): - # Generate ADB key files - keygen(adbkey) - - # Load the ADB key - signer = ADBPythonSync.load_adbkey(adbkey) - adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" - - else: - # Use "pure-python-adb" (communicate with ADB server) - signer = None - adb_log = f"using ADB server at {config[CONF_ADB_SERVER_IP]}:{config[CONF_ADB_SERVER_PORT]}" - - return adbkey, signer, adb_log - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info=None, +) -> None: """Set up the Android TV / Fire TV platform.""" - hass.data.setdefault(ANDROIDTV_DOMAIN, {}) - - address = f"{config[CONF_HOST]}:{config[CONF_PORT]}" - if address in hass.data[ANDROIDTV_DOMAIN]: - _LOGGER.warning("Platform already setup on %s, skipping", address) - return - - adbkey, signer, adb_log = await hass.async_add_executor_job( - setup_androidtv, hass, config - ) + host = config[CONF_HOST] - aftv = await setup( - config[CONF_HOST], - config[CONF_PORT], - adbkey, - config.get(CONF_ADB_SERVER_IP, ""), - config[CONF_ADB_SERVER_PORT], - config[CONF_STATE_DETECTION_RULES], - config[CONF_DEVICE_CLASS], - 10.0, - signer, - ) + # get main data + config_data = { + CONF_HOST: host, + CONF_DEVICE_CLASS: config.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS), + CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), + } + for key in (CONF_ADBKEY, CONF_ADB_SERVER_IP, CONF_ADB_SERVER_PORT, CONF_NAME): + if key in config: + config_data[key] = config[key] + + # get options + config_options = { + key: config[key] + for key in ( + CONF_APPS, + CONF_EXCLUDE_UNNAMED_APPS, + CONF_GET_SOURCES, + CONF_SCREENCAP, + CONF_STATE_DETECTION_RULES, + CONF_TURN_OFF_COMMAND, + CONF_TURN_ON_COMMAND, + ) + if key in config + } - if not aftv.available: - # Determine the name that will be used for the device in the log - if CONF_NAME in config: - device_name = config[CONF_NAME] - elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" - elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: - device_name = "Fire TV device" - else: - device_name = "Android TV / Fire TV device" + # save option to use with entry + if config_options: + config_data[CONF_MIGRATION_OPTIONS] = config_options - _LOGGER.warning( - "Could not connect to %s at %s %s", device_name, address, adb_log + # Launch config entries setup + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config_data ) - raise PlatformNotReady + ) - async def _async_close(event): - """Close the ADB socket connection when HA stops.""" - await aftv.adb_close() - # Close the ADB connection when HA stops - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV entity.""" + aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] + device_class = aftv.DEVICE_CLASS + device_type = "Android TV" if device_class == DEVICE_ANDROIDTV else "Fire TV" + if CONF_NAME in entry.data: + device_name = entry.data[CONF_NAME] + else: + device_name = f"{device_type} {entry.data[CONF_HOST]}" device_args = [ aftv, - config[CONF_NAME], - config[CONF_APPS], - config[CONF_GET_SOURCES], - config.get(CONF_TURN_ON_COMMAND), - config.get(CONF_TURN_OFF_COMMAND), - config[CONF_EXCLUDE_UNNAMED_APPS], - config[CONF_SCREENCAP], + device_name, + device_type, + entry.unique_id, + entry.entry_id, + hass.data[DOMAIN][entry.entry_id], ] - if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: - device = AndroidTVDevice(*device_args) - device_name = config.get(CONF_NAME, "Android TV") - else: - device = FireTVDevice(*device_args) - device_name = config.get(CONF_NAME, "Fire TV") - - async_add_entities([device]) - _LOGGER.debug("Setup %s at %s %s", device_name, address, adb_log) - hass.data[ANDROIDTV_DOMAIN][address] = device - - if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND): - return - - platform = entity_platform.async_get_current_platform() - - async def service_adb_command(service): - """Dispatch service calls to target entities.""" - cmd = service.data[ATTR_COMMAND] - entity_id = service.data[ATTR_ENTITY_ID] - target_devices = [ - dev - for dev in hass.data[ANDROIDTV_DOMAIN].values() - if dev.entity_id in entity_id + async_add_entities( + [ + AndroidTVDevice(*device_args) + if device_class == DEVICE_ANDROIDTV + else FireTVDevice(*device_args) ] + ) - for target_device in target_devices: - output = await target_device.adb_command(cmd) - - # log the output, if there is any - if output: - _LOGGER.info( - "Output of command '%s' from '%s': %s", - cmd, - target_device.entity_id, - output, - ) - - hass.services.async_register( - ANDROIDTV_DOMAIN, + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( SERVICE_ADB_COMMAND, - service_adb_command, - schema=SERVICE_ADB_COMMAND_SCHEMA, + {vol.Required(ATTR_COMMAND): cv.string}, + "adb_command", ) - platform.async_register_entity_service( SERVICE_LEARN_SENDEVENT, {}, "learn_sendevent" ) - - async def service_download(service): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" - local_path = service.data[ATTR_LOCAL_PATH] - if not hass.config.is_allowed_path(local_path): - _LOGGER.warning("'%s' is not secure to load data from!", local_path) - return - - device_path = service.data[ATTR_DEVICE_PATH] - entity_id = service.data[ATTR_ENTITY_ID] - target_device = [ - dev - for dev in hass.data[ANDROIDTV_DOMAIN].values() - if dev.entity_id in entity_id - ][0] - - await target_device.adb_pull(local_path, device_path) - - hass.services.async_register( - ANDROIDTV_DOMAIN, + platform.async_register_entity_service( SERVICE_DOWNLOAD, - service_download, - schema=SERVICE_DOWNLOAD_SCHEMA, + { + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + }, + "service_download", ) - - async def service_upload(service): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" - local_path = service.data[ATTR_LOCAL_PATH] - if not hass.config.is_allowed_path(local_path): - _LOGGER.warning("'%s' is not secure to load data from!", local_path) - return - - device_path = service.data[ATTR_DEVICE_PATH] - entity_id = service.data[ATTR_ENTITY_ID] - target_devices = [ - dev - for dev in hass.data[ANDROIDTV_DOMAIN].values() - if dev.entity_id in entity_id - ] - - for target_device in target_devices: - await target_device.adb_push(local_path, device_path) - - hass.services.async_register( - ANDROIDTV_DOMAIN, SERVICE_UPLOAD, service_upload, schema=SERVICE_UPLOAD_SCHEMA + platform.async_register_entity_service( + SERVICE_UPLOAD, + { + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + }, + "service_upload", ) @@ -356,6 +285,7 @@ def _adb_decorator(func): @functools.wraps(func) async def _adb_exception_catcher(self, *args, **kwargs): """Call an ADB-related method and catch exceptions.""" + # pylint: disable=protected-access if not self.available and not override_available: return None @@ -374,13 +304,13 @@ async def _adb_exception_catcher(self, *args, **kwargs): err, ) await self.aftv.adb_close() - self._available = False + self._attr_available = False return None except Exception: # An unforeseen exception occurred. Close the ADB connection so that # it doesn't happen over and over again, then raise the exception. await self.aftv.adb_close() - self._available = False + self._attr_available = False raise return _adb_exception_catcher @@ -395,41 +325,42 @@ def __init__( self, aftv, name, - apps, - get_sources, - turn_on_command, - turn_off_command, - exclude_unnamed_apps, - screencap, + dev_type, + unique_id, + entry_id, + entry_data, ): """Initialize the Android TV / Fire TV device.""" self.aftv = aftv - self._name = name - self._app_id_to_name = APPS.copy() - self._app_id_to_name.update(apps) - self._app_name_to_id = { - value: key for key, value in self._app_id_to_name.items() if value - } - - # Make sure that apps overridden via the `apps` parameter are reflected - # in `self._app_name_to_id` - for key, value in apps.items(): - self._app_name_to_id[value] = key - - self._get_sources = get_sources - self._keys = KEYS - - self._device_properties = self.aftv.device_properties - self._unique_id = self._device_properties.get("serialno") - - self.turn_on_command = turn_on_command - self.turn_off_command = turn_off_command - - self._exclude_unnamed_apps = exclude_unnamed_apps - self._screencap = screencap + self._attr_name = name + self._attr_unique_id = unique_id + self._entry_id = entry_id + self._entry_data = entry_data + + info = aftv.device_properties + model = info.get(ATTR_MODEL) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + model=f"{model} ({dev_type})" if model else dev_type, + name=name, + ) + if manufacturer := info.get(ATTR_MANUFACTURER): + self._attr_device_info[ATTR_MANUFACTURER] = manufacturer + if sw_version := info.get(ATTR_SW_VERSION): + self._attr_device_info[ATTR_SW_VERSION] = sw_version + if mac := format_mac(info.get(PROP_ETHMAC) or info.get(PROP_WIFIMAC, "")): + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} + + self._app_id_to_name = {} + self._app_name_to_id = {} + self._get_sources = DEFAULT_GET_SOURCES + self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS + self._screencap = DEFAULT_SCREENCAP + self.turn_on_command = None + self.turn_off_command = None # ADB exceptions to catch - if not self.aftv.adb_server_ip: + if not aftv.adb_server_ip: # Using "adb_shell" (Python ADB implementation) self.exceptions = ( AdbTimeoutError, @@ -446,66 +377,54 @@ def __init__( self.exceptions = (ConnectionResetError, RuntimeError) # Property attributes - self._adb_response = None - self._available = True - self._current_app = None - self._sources = None - self._state = None - self._hdmi_input = None + self._attr_extra_state_attributes = { + ATTR_ADB_RESPONSE: None, + ATTR_HDMI_INPUT: None, + } - @property - def app_id(self): - """Return the current app.""" - return self._current_app + def _process_config(self): + """Load the config options.""" + _LOGGER.debug("Loading configuration options") + options = self._entry_data[ANDROID_DEV_OPT] - @property - def app_name(self): - """Return the friendly name of the current app.""" - return self._app_id_to_name.get(self._current_app, self._current_app) + apps = options.get(CONF_APPS, {}) + self._app_id_to_name = APPS.copy() + self._app_id_to_name.update(apps) + self._app_name_to_id = { + value: key for key, value in self._app_id_to_name.items() if value + } - @property - def available(self): - """Return whether or not the ADB connection is valid.""" - return self._available + # Make sure that apps overridden via the `apps` parameter are reflected + # in `self._app_name_to_id` + for key, value in apps.items(): + self._app_name_to_id[value] = key - @property - def extra_state_attributes(self): - """Provide the last ADB command's response and the device's HDMI input as attributes.""" - return { - "adb_response": self._adb_response, - "hdmi_input": self._hdmi_input, - } + self._get_sources = options.get(CONF_GET_SOURCES, DEFAULT_GET_SOURCES) + self._exclude_unnamed_apps = options.get( + CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS + ) + self._screencap = options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP) + self.turn_off_command = options.get(CONF_TURN_OFF_COMMAND) + self.turn_on_command = options.get(CONF_TURN_ON_COMMAND) + + async def async_added_to_hass(self): + """Set config parameter when add to hass.""" + await super().async_added_to_hass() + self._process_config() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_CONFIG_ENTITY}_{self._entry_id}", + self._process_config, + ) + ) + return @property - def media_image_hash(self): + def media_image_hash(self) -> str | None: """Hash value for media image.""" return f"{datetime.now().timestamp()}" if self._screencap else None - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def source(self): - """Return the current app.""" - return self._app_id_to_name.get(self._current_app, self._current_app) - - @property - def source_list(self): - """Return a list of running apps.""" - return self._sources - - @property - def state(self): - """Return the state of the player.""" - return self._state - - @property - def unique_id(self): - """Return the device unique id.""" - return self._unique_id - @adb_decorator() async def _adb_screencap(self): """Take a screen capture from the device.""" @@ -513,7 +432,7 @@ async def _adb_screencap(self): async def async_get_media_image(self): """Fetch current playing image.""" - if not self._screencap or self.state in [STATE_OFF, None] or not self.available: + if not self._screencap or self.state in (STATE_OFF, None) or not self.available: return None, None media_data = await self._adb_screencap() @@ -582,35 +501,36 @@ async def async_select_source(self, source): await self.aftv.stop_app(self._app_name_to_id.get(source_, source_)) @adb_decorator() - async def adb_command(self, cmd): + async def adb_command(self, command): """Send an ADB command to an Android TV / Fire TV device.""" - key = self._keys.get(cmd) - if key: + if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return - if cmd == "GET_PROPERTIES": - self._adb_response = str(await self.aftv.get_properties_dict()) + if command == "GET_PROPERTIES": + self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = str( + await self.aftv.get_properties_dict() + ) self.async_write_ha_state() - return self._adb_response + return try: - response = await self.aftv.adb_shell(cmd) + response = await self.aftv.adb_shell(command) except UnicodeDecodeError: return if isinstance(response, str) and response.strip(): - self._adb_response = response.strip() + self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = response.strip() self.async_write_ha_state() - return self._adb_response + return @adb_decorator() async def learn_sendevent(self): """Translate a key press on a remote to ADB 'sendevent' commands.""" output = await self.aftv.learn_sendevent() if output: - self._adb_response = output + self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = output self.async_write_ha_state() msg = f"Output from service '{SERVICE_LEARN_SENDEVENT}' from {self.entity_id}: '{output}'" @@ -621,97 +541,69 @@ async def learn_sendevent(self): _LOGGER.info("%s", msg) @adb_decorator() - async def adb_pull(self, local_path, device_path): + async def service_download(self, device_path, local_path): """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + if not self.hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + await self.aftv.adb_pull(local_path, device_path) @adb_decorator() - async def adb_push(self, local_path, device_path): + async def service_upload(self, device_path, local_path): """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + if not self.hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + await self.aftv.adb_push(local_path, device_path) class AndroidTVDevice(ADBDevice): """Representation of an Android TV device.""" - def __init__( - self, - aftv, - name, - apps, - get_sources, - turn_on_command, - turn_off_command, - exclude_unnamed_apps, - screencap, - ): - """Initialize the Android TV device.""" - super().__init__( - aftv, - name, - apps, - get_sources, - turn_on_command, - turn_off_command, - exclude_unnamed_apps, - screencap, - ) - - self._is_volume_muted = None - self._volume_level = None + _attr_supported_features = SUPPORT_ANDROIDTV @adb_decorator(override_available=True) async def async_update(self): """Update the device state and, if necessary, re-connect.""" # Check if device is disconnected. - if not self._available: + if not self.available: # Try to connect - self._available = await self.aftv.adb_connect(always_log_errors=False) + self._attr_available = await self.aftv.adb_connect(always_log_errors=False) # If the ADB connection is not intact, don't update. - if not self._available: + if not self.available: return # Get the updated state and attributes. ( state, - self._current_app, + self._attr_app_id, running_apps, _, - self._is_volume_muted, - self._volume_level, - self._hdmi_input, + self._attr_is_volume_muted, + self._attr_volume_level, + self._attr_extra_state_attributes[ATTR_HDMI_INPUT], ) = await self.aftv.update(self._get_sources) - self._state = ANDROIDTV_STATES.get(state) - if self._state is None: - self._available = False + self._attr_state = ANDROIDTV_STATES.get(state) + if self._attr_state is None: + self._attr_available = False if running_apps: + self._attr_source = self._attr_app_name = self._app_id_to_name.get( + self._attr_app_id, self._attr_app_id + ) sources = [ self._app_id_to_name.get( app_id, app_id if not self._exclude_unnamed_apps else None ) for app_id in running_apps ] - self._sources = [source for source in sources if source] + self._attr_source_list = [source for source in sources if source] else: - self._sources = None - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._is_volume_muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_ANDROIDTV - - @property - def volume_level(self): - """Return the volume level.""" - return self._volume_level + self._attr_source_list = None @adb_decorator() async def async_media_stop(self): @@ -731,56 +623,56 @@ async def async_set_volume_level(self, volume): @adb_decorator() async def async_volume_down(self): """Send volume down command.""" - self._volume_level = await self.aftv.volume_down(self._volume_level) + self._attr_volume_level = await self.aftv.volume_down(self._attr_volume_level) @adb_decorator() async def async_volume_up(self): """Send volume up command.""" - self._volume_level = await self.aftv.volume_up(self._volume_level) + self._attr_volume_level = await self.aftv.volume_up(self._attr_volume_level) class FireTVDevice(ADBDevice): """Representation of a Fire TV device.""" + _attr_supported_features = SUPPORT_FIRETV + @adb_decorator(override_available=True) async def async_update(self): """Update the device state and, if necessary, re-connect.""" # Check if device is disconnected. - if not self._available: + if not self.available: # Try to connect - self._available = await self.aftv.adb_connect(always_log_errors=False) + self._attr_available = await self.aftv.adb_connect(always_log_errors=False) # If the ADB connection is not intact, don't update. - if not self._available: + if not self.available: return # Get the `state`, `current_app`, `running_apps` and `hdmi_input`. ( state, - self._current_app, + self._attr_app_id, running_apps, - self._hdmi_input, + self._attr_extra_state_attributes[ATTR_HDMI_INPUT], ) = await self.aftv.update(self._get_sources) - self._state = ANDROIDTV_STATES.get(state) - if self._state is None: - self._available = False + self._attr_state = ANDROIDTV_STATES.get(state) + if self._attr_state is None: + self._attr_available = False if running_apps: + self._attr_source = self._app_id_to_name.get( + self._attr_app_id, self._attr_app_id + ) sources = [ self._app_id_to_name.get( app_id, app_id if not self._exclude_unnamed_apps else None ) for app_id in running_apps ] - self._sources = [source for source in sources if source] + self._attr_source_list = [source for source in sources if source] else: - self._sources = None - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_FIRETV + self._attr_source_list = None @adb_decorator() async def async_media_stop(self): diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index 55b871ff58ffc..fef06266e52f8 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -3,15 +3,11 @@ adb_command: name: ADB command description: Send an ADB command to an Android TV / Fire TV device. + target: + entity: + integration: androidtv + domain: media_player fields: - entity_id: - description: Name(s) of Android TV / Fire TV entities. - required: true - example: "media_player.android_tv_living_room" - selector: - entity: - integration: androidtv - domain: media_player command: name: Command description: Either a key command or an ADB shell command. @@ -22,15 +18,11 @@ adb_command: download: name: Download description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + target: + entity: + integration: androidtv + domain: media_player fields: - entity_id: - description: Name of Android TV / Fire TV entity. - required: true - example: "media_player.android_tv_living_room" - selector: - entity: - integration: androidtv - domain: media_player device_path: name: Device path description: The filepath on the Android TV / Fire TV device. @@ -48,15 +40,11 @@ download: upload: name: Upload description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + target: + entity: + integration: androidtv + domain: media_player fields: - entity_id: - description: Name(s) of Android TV / Fire TV entities. - required: true - example: "media_player.android_tv_living_room" - selector: - entity: - integration: androidtv - domain: media_player device_path: name: Device path description: The filepath on the Android TV / Fire TV device. diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json new file mode 100644 index 0000000000000..1fd0231379f44 --- /dev/null +++ b/homeassistant/components/androidtv/strings.json @@ -0,0 +1,66 @@ +{ + "config": { + "step": { + "user": { + "title": "Android TV", + "description": "Set required parameters to connect to your Android TV device", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "adbkey": "Path to your ADB key file (leave empty to auto generate)", + "adb_server_ip": "IP address of the ADB server (leave empty to not use)", + "adb_server_port": "Port of the ADB server", + "device_class": "The type of device", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "adbkey_not_file": "ADB key file not found", + "key_and_server": "Only provide ADB Key or ADB Server", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_unique_id": "Impossible to determine a valid unique id for the device" + } + }, + "options": { + "step": { + "init": { + "title": "Android TV Options", + "data": { + "apps": "Configure applications list", + "get_sources": "Retrieve the running apps as the list of sources", + "exclude_unnamed_apps": "Exclude apps with unknown name from the sources list", + "screencap": "Use screen capture for album art", + "state_detection_rules": "Configure state detection rules", + "turn_off_command": "ADB shell turn off command (leave empty for default)", + "turn_on_command": "ADB shell turn on command (leave empty for default)" + } + }, + "apps": { + "title": "Configure Android TV Apps", + "description": "Configure application id {app_id}", + "data": { + "app_name": "Application Name", + "app_id": "Application ID", + "app_delete": "Check to delete this application" + } + }, + "rules": { + "title": "Configure Android TV state detection rules", + "description": "Configure detection rule for application id {rule_id}", + "data": { + "rule_id": "Application ID", + "rule_values": "List of state detection rules (see documentation)", + "rule_delete": "Check to delete this rule" + } + } + }, + "error": { + "invalid_det_rules": "Invalid state detection rules" + } + } +} diff --git a/homeassistant/components/androidtv/translations/bg.json b/homeassistant/components/androidtv/translations/bg.json new file mode 100644 index 0000000000000..9dd3bde62adb1 --- /dev/null +++ b/homeassistant/components/androidtv/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "Android TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/ca.json b/homeassistant/components/androidtv/translations/ca.json new file mode 100644 index 0000000000000..3fccdc45771f1 --- /dev/null +++ b/homeassistant/components/androidtv/translations/ca.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "invalid_unique_id": "No s'ha pogut determinar cap identificador \u00fanic v\u00e0lid del dispositiu" + }, + "error": { + "adbkey_not_file": "No s'ha trobat el fitxer de clau ADB", + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids", + "key_and_server": "Proporciona nom\u00e9s la clau ADB o el servidor ADB", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "Adre\u00e7a IP del servidor ADB (deixeu-ho en blanc per no utilitzar-la)", + "adb_server_port": "Port del servidor ADB", + "adbkey": "Ruta al fitxer de clau ADB (deixa-ho en blanc per generar-la autom\u00e0ticament)", + "device_class": "Tipus de dispositiu", + "host": "Amfitri\u00f3", + "port": "Port" + }, + "description": "Estableix els par\u00e0metres necessaris per connectar al teu dispositiu Android TV", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Regles de detecci\u00f3 d'estat no v\u00e0lides" + }, + "step": { + "apps": { + "data": { + "app_delete": "Marca-ho per eliminar aquesta aplicaci\u00f3", + "app_id": "ID de l'aplicaci\u00f3", + "app_name": "Nom de l'aplicaci\u00f3" + }, + "description": "Configura l'identificador d'aplicaci\u00f3 {app_id}", + "title": "Configuraci\u00f3 d'aplicacions d'Android TV" + }, + "init": { + "data": { + "apps": "Configura la llista d'aplicacions", + "exclude_unnamed_apps": "Exclou, de la llista de fonts, les aplicacions amb nom desconegut", + "get_sources": "Obt\u00e9 les aplicacions en execuci\u00f3 com a llista de fonts", + "screencap": "Utilitza la captura de pantalla per les imatges d'\u00e0lbum", + "state_detection_rules": "Configura les regles de detecci\u00f3 d'estat", + "turn_off_command": "Comanda d'apagada de shell ADB (deixa-ho buit per utilitzar la predeterminada)", + "turn_on_command": "Comanda d'engegada de shell ADB (deixa-ho buit per utilitzar la predeterminada)" + }, + "title": "Opcions d'Android TV" + }, + "rules": { + "data": { + "rule_delete": "Marca-ho per eliminar aquesta regla", + "rule_id": "ID de l'aplicaci\u00f3", + "rule_values": "Llista de regles de detecci\u00f3 d'estat (consulta la documentaci\u00f3)" + }, + "description": "Configura regla de detecci\u00f3 de l'identificador d'aplicaci\u00f3 {rule_id}", + "title": "Configuraci\u00f3 de regles de detecci\u00f3 d'estat d'Android TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/de.json b/homeassistant/components/androidtv/translations/de.json new file mode 100644 index 0000000000000..e69e9ac218361 --- /dev/null +++ b/homeassistant/components/androidtv/translations/de.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "invalid_unique_id": "Unm\u00f6glich, eine g\u00fcltige eindeutige Kennung f\u00fcr das Ger\u00e4t zu ermitteln" + }, + "error": { + "adbkey_not_file": "ADB-Schl\u00fcsseldatei nicht gefunden", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", + "key_and_server": "Nur ADB-Schl\u00fcssel oder ADB-Server bereitstellen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "IP-Adresse des ADB-Servers (leer lassen, um sie nicht zu verwenden)", + "adb_server_port": "Port des ADB-Servers", + "adbkey": "Pfad zu deiner ADB-Schl\u00fcsseldatei (zum automatischen Generieren leer lassen)", + "device_class": "Der Typ des Ger\u00e4ts", + "host": "Host", + "port": "Port" + }, + "description": "Stelle die erforderlichen Parameter f\u00fcr die Verbindung mit deinem Android TV-Ger\u00e4t ein", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Ung\u00fcltige Statuserkennungsregeln" + }, + "step": { + "apps": { + "data": { + "app_delete": "Aktiviere diese Option, um diese Anwendung zu l\u00f6schen", + "app_id": "Anwendungs-ID", + "app_name": "Anwendungsname" + }, + "description": "Anwendungs-ID {app_id} konfigurieren", + "title": "Android TV-Apps konfigurieren" + }, + "init": { + "data": { + "apps": "Anwendungsliste konfigurieren", + "exclude_unnamed_apps": "Apps mit unbekanntem Namen aus der Quellenliste ausschlie\u00dfen", + "get_sources": "Abrufen der laufenden Anwendungen als Liste der Quellen", + "screencap": "Bildschirmaufnahme als Albumcover verwenden", + "state_detection_rules": "Regeln zur Statuserkennung konfigurieren", + "turn_off_command": "ADB-Shell-Abschaltbefehl (f\u00fcr Standard leer lassen)", + "turn_on_command": "ADB-Shell-Einschaltbefehl (f\u00fcr Standard leer lassen)" + }, + "title": "Android TV-Optionen" + }, + "rules": { + "data": { + "rule_delete": "Aktiviere diese Option, um diese Regel zu l\u00f6schen", + "rule_id": "Anwendungs-ID", + "rule_values": "Liste der Statuserkennungsregeln (siehe Dokumentation)" + }, + "description": "Erkennungsregel f\u00fcr Anwendungs-ID {rule_id}", + "title": "Regeln f\u00fcr die Android TV-Zustandserkennung konfigurieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/en.json b/homeassistant/components/androidtv/translations/en.json new file mode 100644 index 0000000000000..f3232b4b2296e --- /dev/null +++ b/homeassistant/components/androidtv/translations/en.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "invalid_unique_id": "Impossible to determine a valid unique id for the device" + }, + "error": { + "adbkey_not_file": "ADB key file not found", + "cannot_connect": "Failed to connect", + "invalid_host": "Invalid hostname or IP address", + "key_and_server": "Only provide ADB Key or ADB Server", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "IP address of the ADB server (leave empty to not use)", + "adb_server_port": "Port of the ADB server", + "adbkey": "Path to your ADB key file (leave empty to auto generate)", + "device_class": "The type of device", + "host": "Host", + "port": "Port" + }, + "description": "Set required parameters to connect to your Android TV device", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Invalid state detection rules" + }, + "step": { + "apps": { + "data": { + "app_delete": "Check to delete this application", + "app_id": "Application ID", + "app_name": "Application Name" + }, + "description": "Configure application id {app_id}", + "title": "Configure Android TV Apps" + }, + "init": { + "data": { + "apps": "Configure applications list", + "exclude_unnamed_apps": "Exclude apps with unknown name from the sources list", + "get_sources": "Retrieve the running apps as the list of sources", + "screencap": "Use screen capture for album art", + "state_detection_rules": "Configure state detection rules", + "turn_off_command": "ADB shell turn off command (leave empty for default)", + "turn_on_command": "ADB shell turn on command (leave empty for default)" + }, + "title": "Android TV Options" + }, + "rules": { + "data": { + "rule_delete": "Check to delete this rule", + "rule_id": "Application ID", + "rule_values": "List of state detection rules (see documentation)" + }, + "description": "Configure detection rule for application id {rule_id}", + "title": "Configure Android TV state detection rules" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/et.json b/homeassistant/components/androidtv/translations/et.json new file mode 100644 index 0000000000000..292b01fa84958 --- /dev/null +++ b/homeassistant/components/androidtv/translations/et.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "invalid_unique_id": "Seadme jaoks ei ole v\u00f5imalik kehtivat kordumatut ID-d tuvastada" + }, + "error": { + "adbkey_not_file": "ADB v\u00f5tmefaili ei leitud", + "cannot_connect": "\u00dchendamine nurjus", + "invalid_host": "Sobimatu hosti v\u00f5i IP aadress", + "key_and_server": "Esita ainult ADB-v\u00f5ti v\u00f5i ADB-server", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "ADB-serveri IP-aadress (j\u00e4ta t\u00fchjaks kui ei kasuta)", + "adb_server_port": "ADB serveri port", + "adbkey": "ADB v\u00f5tmefaili tee (automaatse genereerimise korral j\u00e4ta t\u00fchjaks)", + "device_class": "Seadme t\u00fc\u00fcp", + "host": "Host", + "port": "Port" + }, + "description": "Seadista Android TV seadmega \u00fchenduse loomiseks vajalikud parameetrid", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Kehtetud olekutuvastusreeglid" + }, + "step": { + "apps": { + "data": { + "app_delete": "M\u00e4rgi selle rakenduse kustutamiseks", + "app_id": "Rakenduse ID", + "app_name": "Rakenduse nimi" + }, + "description": "Rakenduse ID {app_id} seadistamine", + "title": "Seadista Android TV rakendused" + }, + "init": { + "data": { + "apps": "Rakenduste loendi seadistamine", + "exclude_unnamed_apps": "V\u00e4lista allikate loendist tundmatu nimega rakendused", + "get_sources": "Jooksvate rakenduste toomine allikate loendina", + "screencap": "Kasutage albumipildi jaoks ekraanit\u00f5mmist", + "state_detection_rules": "M\u00e4\u00e4ra oleku tuvastamise reeglid", + "turn_off_command": "ADB shell turn off k\u00e4sk (vaikimisi j\u00e4ta t\u00fchjaks)", + "turn_on_command": "ADB shell turn on k\u00e4sk (vaikimisi j\u00e4ta t\u00fchjaks)" + }, + "title": "Android TV suvandid" + }, + "rules": { + "data": { + "rule_delete": "M\u00e4rgi selle reegli kustutamiseks", + "rule_id": "Rakenduse ID", + "rule_values": "Oleku tuvastamise reeglite loend (vt dokumentatsiooni)" + }, + "description": "Seadista rakenduse ID {rule_id} tuvastusreegel.", + "title": "Seadista Android TV oleku tuvastamise reeglid" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/he.json b/homeassistant/components/androidtv/translations/he.json new file mode 100644 index 0000000000000..298416bccc53f --- /dev/null +++ b/homeassistant/components/androidtv/translations/he.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "invalid_unique_id": "\u05d1\u05dc\u05ea\u05d9 \u05d0\u05e4\u05e9\u05e8\u05d9 \u05dc\u05e7\u05d1\u05d5\u05e2 \u05de\u05d6\u05d4\u05d4 \u05d9\u05d9\u05d7\u05d5\u05d3\u05d9 \u05d7\u05d5\u05e7\u05d9 \u05e2\u05d1\u05d5\u05e8 \u05d4\u05d4\u05ea\u05e7\u05df" + }, + "error": { + "adbkey_not_file": "\u05e7\u05d5\u05d1\u05e5 \u05d4\u05de\u05e4\u05ea\u05d7 \u05e9\u05dc ADB \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", + "key_and_server": "\u05e1\u05e4\u05e7 \u05e8\u05e7 \u05de\u05e4\u05ea\u05d7 ADB \u05d0\u05d5 \u05e9\u05e8\u05ea ADB", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "\u05db\u05ea\u05d5\u05d1\u05ea IP \u05e9\u05dc \u05e9\u05e8\u05ea ADB (\u05d4\u05e9\u05d0\u05e8 \u05e8\u05d9\u05e7 \u05db\u05d3\u05d9 \u05dc\u05d0 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9)", + "adb_server_port": "\u05d9\u05e6\u05d9\u05d0\u05d4 \u05e9\u05dc \u05e9\u05e8\u05ea ADB", + "adbkey": "\u05e0\u05ea\u05d9\u05d1 \u05dc\u05e7\u05d5\u05d1\u05e5 \u05d4\u05de\u05e4\u05ea\u05d7 \u05e9\u05dc ADB (\u05d4\u05e9\u05d0\u05e8 \u05e8\u05d9\u05e7 \u05dc\u05d9\u05e6\u05d9\u05e8\u05d4 \u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9\u05ea)", + "device_class": "\u05e1\u05d5\u05d2 \u05d4\u05d4\u05ea\u05e7\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + }, + "description": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05d4\u05e4\u05e8\u05de\u05d8\u05e8\u05d9\u05dd \u05d4\u05e0\u05d3\u05e8\u05e9\u05d9\u05dd \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05d8\u05dc\u05d5\u05d5\u05d9\u05d6\u05d9\u05d9\u05ea \u05d4\u05d0\u05e0\u05d3\u05e8\u05d5\u05d0\u05d9\u05d3 \u05e9\u05dc\u05da", + "title": "\u05d8\u05dc\u05d5\u05d5\u05d9\u05d6\u05d9\u05ea \u05d0\u05e0\u05d3\u05e8\u05d5\u05d0\u05d9\u05d3" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "\u05db\u05dc\u05dc\u05d9 \u05d6\u05d9\u05d4\u05d5\u05d9 \u05de\u05e6\u05d1 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "step": { + "apps": { + "data": { + "app_delete": "\u05e1\u05de\u05df \u05db\u05d3\u05d9 \u05dc\u05de\u05d7\u05d5\u05e7 \u05d9\u05d9\u05e9\u05d5\u05dd \u05d6\u05d4", + "app_id": "\u05de\u05d6\u05d4\u05d4 \u05d9\u05d9\u05e9\u05d5\u05dd", + "app_name": "\u05e9\u05dd \u05d9\u05d9\u05e9\u05d5\u05dd" + }, + "description": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05d6\u05d4\u05d4 \u05d4\u05d9\u05d9\u05e9\u05d5\u05dd {app_id}", + "title": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d9\u05d9\u05e9\u05d5\u05de\u05d9 \u05d8\u05dc\u05d5\u05d5\u05d9\u05d6\u05d9\u05ea \u05d0\u05e0\u05d3\u05e8\u05d5\u05d0\u05d9\u05d3" + }, + "init": { + "data": { + "apps": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05e8\u05e9\u05d9\u05de\u05ea \u05d9\u05d9\u05e9\u05d5\u05de\u05d9\u05dd", + "exclude_unnamed_apps": "\u05d0\u05dc \u05ea\u05db\u05dc\u05d5\u05dc \u05d9\u05d9\u05e9\u05d5\u05dd \u05e2\u05dd \u05e9\u05dd \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2", + "get_sources": "\u05d4\u05d0\u05dd \u05dc\u05d0\u05d7\u05d6\u05e8 \u05d0\u05ea \u05d4\u05d9\u05d9\u05e9\u05d5\u05de\u05d9\u05dd \u05d4\u05e4\u05d5\u05e2\u05dc\u05d9\u05dd \u05db\u05e8\u05e9\u05d9\u05de\u05ea \u05d4\u05de\u05e7\u05d5\u05e8\u05d5\u05ea", + "screencap": "\u05e7\u05d5\u05d1\u05e2 \u05d0\u05dd \u05d9\u05e9 \u05dc\u05de\u05e9\u05d5\u05da \u05d0\u05ea \u05ea\u05de\u05d5\u05e0\u05ea \u05d4\u05d0\u05dc\u05d1\u05d5\u05dd \u05de\u05de\u05d4 \u05e9\u05de\u05d5\u05e6\u05d2 \u05e2\u05dc \u05d4\u05de\u05e1\u05da", + "state_detection_rules": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05db\u05dc\u05dc\u05d9 \u05d6\u05d9\u05d4\u05d5\u05d9 \u05de\u05e6\u05d1\u05d9\u05dd", + "turn_off_command": "\u05e4\u05e7\u05d5\u05d3\u05ea \u05de\u05e2\u05d8\u05e4\u05ea ADB \u05db\u05d3\u05d9 \u05dc\u05e2\u05e7\u05d5\u05e3 \u05d0\u05ea \u05e4\u05e7\u05d5\u05d3\u05ea \u05d1\u05e8\u05d9\u05e8\u05ea \u05d4\u05de\u05d7\u05d3\u05dc turn_off", + "turn_on_command": "\u05e4\u05e7\u05d5\u05d3\u05ea \u05de\u05e2\u05d8\u05e4\u05ea ADB \u05db\u05d3\u05d9 \u05dc\u05e2\u05e7\u05d5\u05e3 \u05d0\u05ea \u05e4\u05e7\u05d5\u05d3\u05ea \u05d1\u05e8\u05d9\u05e8\u05ea \u05d4\u05de\u05d7\u05d3\u05dc turn_on" + }, + "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05d8\u05dc\u05d5\u05d5\u05d9\u05d6\u05d9\u05ea \u05d0\u05e0\u05d3\u05e8\u05d5\u05d0\u05d9\u05d3" + }, + "rules": { + "data": { + "rule_delete": "\u05e1\u05de\u05df \u05db\u05d3\u05d9 \u05dc\u05de\u05d7\u05d5\u05e7 \u05db\u05dc\u05dc \u05d6\u05d4", + "rule_id": "\u05de\u05d6\u05d4\u05d4 \u05d9\u05d9\u05e9\u05d5\u05dd", + "rule_values": "\u05e8\u05e9\u05d9\u05de\u05ea \u05db\u05dc\u05dc\u05d9 \u05d6\u05d9\u05d4\u05d5\u05d9 \u05de\u05e6\u05d1\u05d9\u05dd (\u05e8\u05d0\u05d4 \u05ea\u05d9\u05e2\u05d5\u05d3)" + }, + "description": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05db\u05dc\u05dc \u05d6\u05d9\u05d4\u05d5\u05d9 \u05e2\u05d1\u05d5\u05e8 \u05de\u05d6\u05d4\u05d4 \u05d4\u05d9\u05d9\u05e9\u05d5\u05dd {rule_id}", + "title": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05db\u05dc\u05dc\u05d9 \u05d6\u05d9\u05d4\u05d5\u05d9 \u05de\u05e6\u05d1 \u05d8\u05dc\u05d5\u05d5\u05d9\u05d6\u05d9\u05ea \u05d0\u05e0\u05d3\u05e8\u05d5\u05d0\u05d9\u05d3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/hu.json b/homeassistant/components/androidtv/translations/hu.json new file mode 100644 index 0000000000000..c8d8c0fd97b3c --- /dev/null +++ b/homeassistant/components/androidtv/translations/hu.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "invalid_unique_id": "Lehetetlen \u00e9rv\u00e9nyes egyedi azonos\u00edt\u00f3t meghat\u00e1rozni az eszk\u00f6zh\u00f6z" + }, + "error": { + "adbkey_not_file": "ADB kulcsf\u00e1jl nem tal\u00e1lhat\u00f3", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", + "key_and_server": "Csak ADB-kulcsot vagy ADB-kiszolg\u00e1l\u00f3t adjon meg", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "Az ADB szerver IP-c\u00edme (ha nem szeretn\u00e9 haszn\u00e1lni, hagyja \u00fcresen)", + "adb_server_port": "Az ADB szerver portja", + "adbkey": "Az ADB-kulcsf\u00e1jl el\u00e9r\u00e9si \u00fatja (az automatikus gener\u00e1l\u00e1shoz hagyja \u00fcresen)", + "device_class": "Az eszk\u00f6z t\u00edpusa", + "host": "C\u00edm", + "port": "Port" + }, + "description": "Az Android TV k\u00e9sz\u00fcl\u00e9khez val\u00f3 csatlakoz\u00e1shoz sz\u00fcks\u00e9ges param\u00e9terek be\u00e1ll\u00edt\u00e1sa", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "\u00c9rv\u00e9nytelen \u00e1llapotfelismer\u00e9si szab\u00e1lyok" + }, + "step": { + "apps": { + "data": { + "app_delete": "Jel\u00f6lje be az alkalmaz\u00e1s t\u00f6rl\u00e9s\u00e9hez", + "app_id": "Alkalmaz\u00e1s azonos\u00edt\u00f3ja", + "app_name": "Alkalmaz\u00e1s neve" + }, + "description": "Alkalmaz\u00e1sazonos\u00edt\u00f3 konfigur\u00e1l\u00e1sa: {app_id}", + "title": "Android TV alkalmaz\u00e1sok konfigur\u00e1l\u00e1sa" + }, + "init": { + "data": { + "apps": "Alkalmaz\u00e1slista konfigur\u00e1l\u00e1sa", + "exclude_unnamed_apps": "Ismeretlen nev\u0171 alkalmaz\u00e1s kiz\u00e1r\u00e1sa", + "get_sources": "A fut\u00f3 alkalmaz\u00e1sok forr\u00e1slist\u00e1jak\u00e9nt val\u00f3 megjelen\u00edt\u00e9se", + "screencap": "A k\u00e9perny\u0151n megjelen\u0151 k\u00e9p legyen-e az albumbor\u00edt\u00f3", + "state_detection_rules": "\u00c1llapotfelismer\u00e9si szab\u00e1lyok konfigur\u00e1l\u00e1sa", + "turn_off_command": "ADB shell parancs az alap\u00e9rtelmezett kikapcsol\u00e1si (turn_off) parancs fel\u00fcl\u00edr\u00e1s\u00e1ra", + "turn_on_command": "ADB shell parancs az alap\u00e9rtelmezett bekapcsol\u00e1si (turn_on) parancs fel\u00fcl\u00edr\u00e1s\u00e1ra" + }, + "title": "Android TV be\u00e1ll\u00edt\u00e1sok" + }, + "rules": { + "data": { + "rule_delete": "Jel\u00f6lje be a szab\u00e1ly t\u00f6rl\u00e9s\u00e9hez", + "rule_id": "Alkalmaz\u00e1s azonos\u00edt\u00f3ja", + "rule_values": "Az \u00e1llapotfelismer\u0151 szab\u00e1lyok list\u00e1ja (l\u00e1sd a dokument\u00e1ci\u00f3t)" + }, + "description": "\u00c1llapotfelismer\u00e9si szab\u00e1ly konfigur\u00e1l\u00e1sa: {rule_id}", + "title": "Az Android TV \u00e1llapotfelismer\u00e9si szab\u00e1lyainak konfigur\u00e1l\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/id.json b/homeassistant/components/androidtv/translations/id.json new file mode 100644 index 0000000000000..86b34f3a3e554 --- /dev/null +++ b/homeassistant/components/androidtv/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "invalid_unique_id": "Penentuan ID unik yang valid untuk perangkat tidak dimungkinkan" + }, + "error": { + "adbkey_not_file": "File kunci ADB tidak ditemukan", + "cannot_connect": "Gagal terhubung", + "invalid_host": "Nama host atau alamat IP tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Android TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/it.json b/homeassistant/components/androidtv/translations/it.json new file mode 100644 index 0000000000000..c1bee374a7904 --- /dev/null +++ b/homeassistant/components/androidtv/translations/it.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "invalid_unique_id": "Impossibile determinare un ID univoco valido per il dispositivo" + }, + "error": { + "adbkey_not_file": "File della chiave ADB non trovato", + "cannot_connect": "Impossibile connettersi", + "invalid_host": "Nome host o indirizzo IP non valido", + "key_and_server": "Fornisci solo chiave ADB o il server ADB", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "Indirizzo IP del server ADB (lascia vuoto per non utilizzarlo)", + "adb_server_port": "Porta del server ADB", + "adbkey": "Percorso del file chiave ADB (lascia vuoto per generare automaticamente)", + "device_class": "Il tipo di dispositivo", + "host": "Host", + "port": "Porta" + }, + "description": "Imposta i parametri richiesti per connetterti al tuo dispositivo Android TV", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Regole di rilevamento dello stato non valide" + }, + "step": { + "apps": { + "data": { + "app_delete": "Marca per eliminare questa applicazione", + "app_id": "ID applicazione", + "app_name": "Nome applicazione" + }, + "description": "Configura ID applicazione {app_id}", + "title": "Configura le applicazioni Android TV" + }, + "init": { + "data": { + "apps": "Configura l'elenco delle applicazioni", + "exclude_unnamed_apps": "Escludi app con nome sconosciuto", + "get_sources": "Decidi se recuperare o meno le app in esecuzione come elenco di fonti", + "screencap": "Determina se la copertina dell'album deve essere estratta da ci\u00f2 che viene mostrato sullo schermo", + "state_detection_rules": "Configura le regole di rilevamento dello stato", + "turn_off_command": "Comando shell ADB per sovrascrivere il comando turn_off predefinito", + "turn_on_command": "Comando shell ADB per sovrascrivere il comando turn_on predefinito" + }, + "title": "Opzioni Android TV" + }, + "rules": { + "data": { + "rule_delete": "Marca per eliminare questa regola", + "rule_id": "ID applicazione", + "rule_values": "Elenco delle regole di rilevamento dello stato (vedi documentazione)" + }, + "description": "Configura la regola di rilevamento per l'ID applicazione {rule_id}", + "title": "Configura le regole di rilevamento dello stato di Android TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/ja.json b/homeassistant/components/androidtv/translations/ja.json new file mode 100644 index 0000000000000..f3139df9819b5 --- /dev/null +++ b/homeassistant/components/androidtv/translations/ja.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_unique_id": "\u30c7\u30d0\u30a4\u30b9\u306e\u6709\u52b9\u306a\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)ID\u3092\u6c7a\u5b9a\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093" + }, + "error": { + "adbkey_not_file": "ADB\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "key_and_server": "ADB\u30ad\u30fc\u307e\u305f\u306fADB\u30b5\u30fc\u30d0\u30fc\u306e\u307f\u3092\u63d0\u4f9b\u3059\u308b", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "ADB\u30b5\u30fc\u30d0\u30fc\u306eIP\u30a2\u30c9\u30ec\u30b9(\u4f7f\u7528\u3057\u306a\u3044\u5834\u5408\u306f\u7a7a\u306e\u307e\u307e\u306b\u3057\u3066\u304f\u3060\u3055\u3044)", + "adb_server_port": "ADB\u30b5\u30fc\u30d0\u30fc\u306e\u30dd\u30fc\u30c8", + "adbkey": "ADB\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u3078\u306e\u30d1\u30b9(\u7a7a\u306b\u3059\u308b\u3068\u81ea\u52d5\u751f\u6210\u3055\u308c\u307e\u3059)", + "device_class": "\u30c7\u30d0\u30a4\u30b9\u306e\u7a2e\u985e", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Android TV\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a\u3059\u308b\u305f\u3081\u306b\u5fc5\u8981\u306a\u30d1\u30e9\u30e1\u30fc\u30bf\u30fc\u3092\u8a2d\u5b9a\u3057\u307e\u3059", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "\u7121\u52b9\u306a\u72b6\u614b\u691c\u51fa\u30eb\u30fc\u30eb" + }, + "step": { + "apps": { + "data": { + "app_delete": "\u3053\u306e\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u524a\u9664\u3059\u308b\u306b\u306f\u3001\u30c1\u30a7\u30c3\u30af\u3092\u5165\u308c\u307e\u3059", + "app_id": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3ID", + "app_name": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u540d" + }, + "description": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3ID {app_id} \u306e\u8a2d\u5b9a", + "title": "Android TV\u30a2\u30d7\u30ea\u306e\u8a2d\u5b9a" + }, + "init": { + "data": { + "apps": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30ea\u30b9\u30c8\u306e\u8a2d\u5b9a", + "exclude_unnamed_apps": "\u540d\u524d\u304c\u4e0d\u660e\u306a\u30a2\u30d7\u30ea\u3092\u9664\u5916\u3059\u308b", + "get_sources": "\u5b9f\u884c\u4e2d\u306e\u30a2\u30d7\u30ea\u3092\u30bd\u30fc\u30b9\u306e\u30ea\u30b9\u30c8\u3068\u3057\u3066\u53d6\u5f97\u3059\u308b\u304b\u3069\u3046\u304b", + "screencap": "\u753b\u9762\u306b\u8868\u793a\u4e2d\u306e\u3082\u306e\u304b\u3089\u3001\u30a2\u30eb\u30d0\u30e0\u30a2\u30fc\u30c8\u3092\u62bd\u51fa\u3059\u308b\u304b\u3069\u3046\u304b\u3092\u6c7a\u5b9a\u3057\u307e\u3059", + "state_detection_rules": "\u72b6\u614b\u691c\u51fa\u30eb\u30fc\u30eb\u3092\u8a2d\u5b9a", + "turn_off_command": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306eturn_off\u30b3\u30de\u30f3\u30c9\u3092\u4e0a\u66f8\u304d\u3059\u308bADB\u30b7\u30a7\u30eb\u30b3\u30de\u30f3\u30c9", + "turn_on_command": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306eturn_on\u30b3\u30de\u30f3\u30c9\u3092\u4e0a\u66f8\u304d\u3059\u308bADB\u30b7\u30a7\u30eb\u30b3\u30de\u30f3\u30c9" + }, + "title": "Android TV\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "rules": { + "data": { + "rule_delete": "\u3053\u306e\u30eb\u30fc\u30eb\u3092\u524a\u9664\u3059\u308b\u306b\u306f\u3001\u30c1\u30a7\u30c3\u30af\u3092\u5165\u308c\u307e\u3059", + "rule_id": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3ID", + "rule_values": "\u72b6\u614b\u691c\u51fa\u30eb\u30fc\u30eb\u306e\u30ea\u30b9\u30c8(\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167)" + }, + "description": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3ID {rule_id} \u306e\u691c\u51fa\u30eb\u30fc\u30eb\u3092\u69cb\u6210\u3057\u307e\u3059", + "title": "Android TV\u306e\u72b6\u614b\u691c\u51fa\u30eb\u30fc\u30eb\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/nl.json b/homeassistant/components/androidtv/translations/nl.json new file mode 100644 index 0000000000000..38a4bd8005d33 --- /dev/null +++ b/homeassistant/components/androidtv/translations/nl.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "invalid_unique_id": "Onmogelijk om een geldige unieke id voor het apparaat te bepalen" + }, + "error": { + "adbkey_not_file": "ADB key file niet gevonden", + "cannot_connect": "Kan geen verbinding maken", + "invalid_host": "Ongeldige hostnaam of IP-adres", + "key_and_server": "Geef alleen ADB-sleutel of ADB-server op", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "IP-adres van de ADB-server (leeg laten om niet te gebruiken)", + "adb_server_port": "Poort van de ADB-server", + "adbkey": "Pad naar uw ADB-key file (leeg laten om automatisch te genereren)", + "device_class": "Het type apparaat", + "host": "Host" + }, + "description": "Stel de vereiste parameters in om verbinding te maken met uw Android TV-apparaat", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Ongeldige statusdetectieregels" + }, + "step": { + "apps": { + "data": { + "app_delete": "Vink aan om deze applicatie te verwijderen", + "app_id": "Applicatie ID", + "app_name": "Applicatienaam" + }, + "description": "Configureer applicatie id {app_id}", + "title": "Configureer Android TV Apps" + }, + "init": { + "data": { + "apps": "Configureer applicaties lijst", + "exclude_unnamed_apps": "App met onbekende naam uitsluiten", + "get_sources": "Of de actieve apps wel of niet moeten worden opgehaald als de lijst met bronnen", + "screencap": "Bepaalt of albumhoezen moeten worden opgehaald van wat op het scherm wordt weergegeven", + "state_detection_rules": "Regels voor statusdetectie van Android TV configureren", + "turn_off_command": "ADB shell commando om standaard turn_off commando te overschrijven", + "turn_on_command": "ADB shell commando om standaard turn_on commando te overschrijven" + }, + "title": "Android TV-opties" + }, + "rules": { + "data": { + "rule_delete": "Vink aan om deze regel te verwijderen", + "rule_id": "Application ID", + "rule_values": "Lijst met statusdetectieregels (zie documentatie)" + }, + "description": "Detectieregel configureren voor applicatie-ID {rule_id}", + "title": "Regels voor statusdetectie van Android TV configureren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/no.json b/homeassistant/components/androidtv/translations/no.json new file mode 100644 index 0000000000000..a70de366fcaef --- /dev/null +++ b/homeassistant/components/androidtv/translations/no.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "invalid_unique_id": "Umulig \u00e5 bestemme en gyldig unik ID for enheten" + }, + "error": { + "adbkey_not_file": "Finner ikke ADB-n\u00f8kkelfil", + "cannot_connect": "Tilkobling mislyktes", + "invalid_host": "Ugyldig vertsnavn eller IP-adresse", + "key_and_server": "Oppgi kun ADB-n\u00f8kkel eller ADB-server", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "IP-adressen til ADB-serveren (la den st\u00e5 tom for ikke \u00e5 bruke)", + "adb_server_port": "Port til ADB-serveren", + "adbkey": "Bane til ADB-n\u00f8kkelfilen (la den st\u00e5 tom for automatisk generering)", + "device_class": "Type enhet", + "host": "Vert", + "port": "Port" + }, + "description": "Angi n\u00f8dvendige parametere for \u00e5 koble til Android TV-enheten din", + "title": "" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Ugyldige regler for tilstandsgjenkjenning" + }, + "step": { + "apps": { + "data": { + "app_delete": "Merk av for \u00e5 slette denne applikasjonen", + "app_id": "Applikasjons-ID", + "app_name": "Navn p\u00e5 applikasjon" + }, + "description": "Konfigurer app-ID {app_id}", + "title": "Konfigurer Android TV-apper" + }, + "init": { + "data": { + "apps": "Konfigurer applikasjonsliste", + "exclude_unnamed_apps": "Ekskluder app med ukjent navn", + "get_sources": "Om de kj\u00f8rende appene skal hentes eller ikke som kildeliste", + "screencap": "Avgj\u00f8r om albumgrafikk skal hentes fra det som vises p\u00e5 skjermen", + "state_detection_rules": "Konfigurere regler for tilstandsgjenkjenning", + "turn_off_command": "ADB-skallkommando for \u00e5 overstyre standard turn_off-kommando", + "turn_on_command": "ADB-skallkommando for \u00e5 overstyre standard turn_on-kommando" + }, + "title": "Android TV-alternativer" + }, + "rules": { + "data": { + "rule_delete": "Merk av for \u00e5 slette denne regelen", + "rule_id": "Applikasjons-ID", + "rule_values": "Liste over regler for tilstandsgjenkjenning (se dokumentasjon)" + }, + "description": "Konfigurer gjenkjenningsregel for app-ID {rule_id}", + "title": "Konfigurer Android TV-statusgjenkjenningsregler" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/ru.json b/homeassistant/components/androidtv/translations/ru.json new file mode 100644 index 0000000000000..0803a16c98697 --- /dev/null +++ b/homeassistant/components/androidtv/translations/ru.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "invalid_unique_id": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439 ID \u0434\u043b\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430." + }, + "error": { + "adbkey_not_file": "\u0424\u0430\u0439\u043b \u043a\u043b\u044e\u0447\u0430 ADB \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441.", + "key_and_server": "\u041d\u0443\u0436\u043d\u043e \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043a\u043b\u044e\u0447 ADB \u0438\u043b\u0438 \u0441\u0435\u0440\u0432\u0435\u0440 ADB.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "adb_server_ip": "IP-\u0430\u0434\u0440\u0435\u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 ADB (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0447\u0442\u043e\u0431\u044b \u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c)", + "adb_server_port": "\u041f\u043e\u0440\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 ADB", + "adbkey": "\u041f\u0443\u0442\u044c \u043a \u0444\u0430\u0439\u043b\u0443 \u043a\u043b\u044e\u0447\u0430 ADB (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f)", + "device_class": "\u0422\u0438\u043f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Android TV.", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "\u041f\u0440\u0430\u0432\u0438\u043b\u0430 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0439" + }, + "step": { + "apps": { + "data": { + "app_delete": "\u041e\u0442\u043c\u0435\u0442\u044c\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c \u044d\u0442\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "app_id": "ID \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "app_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f {app_id}", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 Android TV" + }, + "init": { + "data": { + "apps": "\u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0441\u043f\u0438\u0441\u043e\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", + "exclude_unnamed_apps": "\u0418\u0441\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0431\u0435\u0437 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0439", + "get_sources": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u0437\u0430\u043f\u0443\u0449\u0435\u043d\u043d\u044b\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043a\u0430\u043a \u0441\u043f\u0438\u0441\u043e\u043a \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432", + "screencap": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0441\u043d\u0438\u043c\u043e\u043a \u044d\u043a\u0440\u0430\u043d\u0430 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u043e\u0431\u043b\u043e\u0436\u043a\u0438 \u0430\u043b\u044c\u0431\u043e\u043c\u0430", + "state_detection_rules": "\u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u0440\u0430\u0432\u0438\u043b\u0430 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0439", + "turn_off_command": "\u041a\u043e\u043c\u0430\u043d\u0434\u0430 \u043e\u0431\u043e\u043b\u043e\u0447\u043a\u0438 ADB \u0434\u043b\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \u043f\u0443\u0441\u0442\u044b\u043c)", + "turn_on_command": "\u041a\u043e\u043c\u0430\u043d\u0434\u0430 \u043e\u0431\u043e\u043b\u043e\u0447\u043a\u0438 ADB \u0434\u043b\u044f \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \u043f\u0443\u0441\u0442\u044b\u043c)" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Android TV" + }, + "rules": { + "data": { + "rule_delete": "\u041e\u0442\u043c\u0435\u0442\u044c\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c \u044d\u0442\u043e \u043f\u0440\u0430\u0432\u0438\u043b\u043e", + "rule_id": "ID \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "rule_values": "\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u0440\u0430\u0432\u0438\u043b \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f (\u0441\u043c. \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044e)" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0440\u0430\u0432\u0438\u043b\u0430 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0434\u043b\u044f ID \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f {rule_id}", + "title": "\u041f\u0440\u0430\u0432\u0438\u043b\u0430 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f Android TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/tr.json b/homeassistant/components/androidtv/translations/tr.json new file mode 100644 index 0000000000000..bb77c35ed1b96 --- /dev/null +++ b/homeassistant/components/androidtv/translations/tr.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "invalid_unique_id": "Cihaz i\u00e7in ge\u00e7erli bir benzersiz kimlik belirlemek imkans\u0131z" + }, + "error": { + "adbkey_not_file": "ADB anahtar dosyas\u0131 bulunamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", + "key_and_server": "Yaln\u0131zca ADB Anahtar\u0131 veya ADB Sunucusu sa\u011flay\u0131n", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "ADB sunucusunun IP adresi (kullanmamak i\u00e7in bo\u015f b\u0131rak\u0131n)", + "adb_server_port": "ADB sunucusunun ba\u011flant\u0131 noktas\u0131", + "adbkey": "ADB anahtar dosyan\u0131z\u0131n yolu (otomatik olu\u015fturmak i\u00e7in bo\u015f b\u0131rak\u0131n)", + "device_class": "Cihaz\u0131n t\u00fcr\u00fc", + "host": "Sunucu", + "port": "Port" + }, + "description": "Android TV cihaz\u0131n\u0131za ba\u011flanmak i\u00e7in gerekli parametreleri ayarlay\u0131n", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "Ge\u00e7ersiz durum alg\u0131lama kurallar\u0131" + }, + "step": { + "apps": { + "data": { + "app_delete": "Bu uygulamay\u0131 silmek i\u00e7in i\u015faretleyin", + "app_id": "Uygulama Kimli\u011fi", + "app_name": "Uygulama Ad\u0131" + }, + "description": "{app_id} uygulama kimli\u011fini yap\u0131land\u0131r\u0131n", + "title": "Android TV Uygulamalar\u0131n\u0131 Yap\u0131land\u0131r\u0131n" + }, + "init": { + "data": { + "apps": "Uygulamalar listesini yap\u0131land\u0131r", + "exclude_unnamed_apps": "Kaynak listesinden bilinmeyen ada sahip uygulamalar\u0131 hari\u00e7 tutun", + "get_sources": "\u00c7al\u0131\u015fan uygulamalar\u0131 kaynak listesi olarak al\u0131n", + "screencap": "Alb\u00fcm resmi i\u00e7in ekran g\u00f6r\u00fcnt\u00fcs\u00fcn\u00fc kullan\u0131n", + "state_detection_rules": "Durum alg\u0131lama kurallar\u0131n\u0131 yap\u0131land\u0131r\u0131n", + "turn_off_command": "ADB kabu\u011fu kapatma komutu (varsay\u0131lan olarak bo\u015f b\u0131rak\u0131n)", + "turn_on_command": "ADB kabu\u011fu a\u00e7ma komutu (varsay\u0131lan olarak bo\u015f b\u0131rak\u0131n)" + }, + "title": "Android TV Se\u00e7enekleri" + }, + "rules": { + "data": { + "rule_delete": "Bu kural\u0131 silmek i\u00e7in i\u015faretleyin", + "rule_id": "Uygulama Kimli\u011fi", + "rule_values": "Durum alg\u0131lama kurallar\u0131n\u0131n listesi (belgelere bak\u0131n)" + }, + "description": "{rule_id} uygulama kimli\u011fi i\u00e7in alg\u0131lama kural\u0131n\u0131 yap\u0131land\u0131r\u0131n", + "title": "Android TV durum alg\u0131lama kurallar\u0131n\u0131 yap\u0131land\u0131r\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/zh-Hans.json b/homeassistant/components/androidtv/translations/zh-Hans.json new file mode 100644 index 0000000000000..454725d52dad9 --- /dev/null +++ b/homeassistant/components/androidtv/translations/zh-Hans.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", + "invalid_unique_id": "\u65e0\u6cd5\u786e\u5b9a\u8bbe\u5907\u7684\u6709\u6548 unique ID" + }, + "error": { + "adbkey_not_file": "\u672a\u627e\u5230 ADB \u5bc6\u94a5\u6587\u4ef6", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "key_and_server": "\u53ea\u63d0\u4f9b\u4e86 ADB \u79d8\u94a5 \u6216 ADB \u670d\u52a1\u5668", + "unknown": "\u975e\u9884\u671f\u7684\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "ADB \u670d\u52a1\u5668 IP \u5730\u5740\uff08\u7559\u7a7a\u4e0d\u4f7f\u7528\uff09", + "adb_server_port": "ADB \u670d\u52a1\u5668\u7aef\u53e3", + "adbkey": "ADB \u5bc6\u94a5\u6587\u4ef6\u8def\u5f84\uff08\u7559\u7a7a\u4ee5\u81ea\u52a8\u751f\u6210\uff09", + "device_class": "\u8bbe\u5907\u7c7b\u578b" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "exclude_unnamed_apps": "\u4ece\u4fe1\u53f7\u6e90\u5217\u8868\u4e2d\u6392\u9664\u672a\u77e5\u540d\u79f0\u7684\u5e94\u7528", + "get_sources": "\u83b7\u53d6\u8fd0\u884c\u4e2d\u7684\u5e94\u7528\u4f5c\u4e3a\u4fe1\u53f7\u6e90\u5217\u8868", + "screencap": "\u4f7f\u7528\u5c4f\u5e55\u622a\u56fe\u4f5c\u4e3a\u4e13\u8f91\u5c01\u9762", + "turn_off_command": "ADB shell \u5173\u673a\u547d\u4ee4\uff08\u7559\u7a7a\u4ee5\u4f7f\u7528\u9ed8\u8ba4\u503c\uff09", + "turn_on_command": "ADB shell \u5f00\u673a\u547d\u4ee4\uff08\u7559\u7a7a\u4ee5\u4f7f\u7528\u9ed8\u8ba4\u503c\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/translations/zh-Hant.json b/homeassistant/components/androidtv/translations/zh-Hant.json new file mode 100644 index 0000000000000..6f6e6fd818078 --- /dev/null +++ b/homeassistant/components/androidtv/translations/zh-Hant.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "invalid_unique_id": "\u7121\u6cd5\u78ba\u8a8d\u88dd\u7f6e\u6709\u6548\u552f\u4e00 ID" + }, + "error": { + "adbkey_not_file": "\u627e\u4e0d\u5230 ADB \u91d1\u9470\u6a94\u6848", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740", + "key_and_server": "\u50c5\u63d0\u4f9b ADB \u91d1\u9470\u6216 ADB \u4f3a\u670d\u5668", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "adb_server_ip": "ADB \u4f3a\u670d\u5668 IP \u4f4d\u5740\uff08\u4fdd\u7559\u7a7a\u767d\u70ba\u4e0d\u4f7f\u7528\uff09", + "adb_server_port": "ADB \u4f3a\u670d\u5668\u901a\u8a0a\u57e0", + "adbkey": "ADB \u91d1\u9470\u6a94\u6848\u8def\u5f91\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u6703\u81ea\u52d5\u7522\u751f\uff09", + "device_class": "\u88dd\u7f6e\u985e\u578b", + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u8a2d\u5b9a\u9023\u7dda\u81f3 Android TV \u88dd\u7f6e\u6240\u9700\u53c3\u6578", + "title": "Android TV" + } + } + }, + "options": { + "error": { + "invalid_det_rules": "\u72c0\u614b\u5075\u6e2c\u898f\u5247\u7121\u6548" + }, + "step": { + "apps": { + "data": { + "app_delete": "\u6aa2\u67e5\u4ee5\u522a\u9664\u6b64\u61c9\u7528\u7a0b\u5f0f", + "app_id": "\u61c9\u7528\u7a0b\u5f0f ID", + "app_name": "\u61c9\u7528\u7a0b\u5f0f\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a\u61c9\u7528\u7a0b\u5f0f ID {app_id}", + "title": "\u8a2d\u5b9a Android TV App" + }, + "init": { + "data": { + "apps": "\u8a2d\u5b9a\u61c9\u7528\u7a0b\u5f0f\u5217\u8868", + "exclude_unnamed_apps": "\u7531\u4f86\u6e90\u5217\u8868\u6392\u9664\u672a\u77e5\u540d\u7a31\u61c9\u7528\u7a0b\u5f0f", + "get_sources": "\u7531\u4f86\u6e90\u5217\u8868\u53d6\u5f97\u57f7\u884c\u7a0b\u5f0f\u5217\u8868", + "screencap": "\u4f7f\u7528\u756b\u9762\u64f7\u53d6\u4f5c\u70ba\u5c01\u9762", + "state_detection_rules": "\u8a2d\u5b9a\u72c0\u614b\u5075\u6e2c\u898f\u5247", + "turn_off_command": "ADB shell turn off \u6307\u4ee4\uff08\u9810\u8a2d\u7a7a\u767d\uff09", + "turn_on_command": "ADB shell turn on \u6307\u4ee4\uff08\u9810\u8a2d\u7a7a\u767d\uff09" + }, + "title": "Android TV \u9078\u9805" + }, + "rules": { + "data": { + "rule_delete": "\u6aa2\u67e5\u4ee5\u522a\u9664\u6b64\u898f\u5247", + "rule_id": "\u61c9\u7528\u7a0b\u5f0f ID", + "rule_values": "\u72c0\u614b\u5075\u6e2c\u898f\u5247\u5217\u8868\uff08\u8acb\u53c3\u95b1\u6587\u4ef6\uff09" + }, + "description": "\u8a2d\u5b9a\u61c9\u7528\u7a0b\u5f0f ID {rule_id} \u5075\u6e2c\u898f\u5247", + "title": "\u8a2d\u5b9a Android TV \u72c0\u614b\u5075\u6e2c\u898f\u5247" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 0669a3bb6c6a4..2f4ce0ee7dbb0 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -65,25 +65,13 @@ def __init__(self, port, parent_device): """Initialize the PwrCtrl switch.""" self._port = port self._parent_device = parent_device - - @property - def unique_id(self): - """Return the unique ID of the device.""" - return f"{self._port.device.host}-{self._port.get_index()}" - - @property - def name(self): - """Return the name of the device.""" - return self._port.label - - @property - def is_on(self): - """Return true if the device is on.""" - return self._port.get_state() + self._attr_unique_id = f"{port.device.host}-{port.get_index()}" + self._attr_name = port.label def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() + self._attr_is_on = self._port.get_state() def turn_on(self, **kwargs): """Turn the switch on.""" diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json index 3e11675fa1f86..078ecaae0da1e 100644 --- a/homeassistant/components/anthemav/manifest.json +++ b/homeassistant/components/anthemav/manifest.json @@ -2,7 +2,7 @@ "domain": "anthemav", "name": "Anthem A/V Receivers", "documentation": "https://www.home-assistant.io/integrations/anthemav", - "requirements": ["anthemav==1.1.10"], + "requirements": ["anthemav==1.2.0"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 788fa8db7eb3a..18b2e7045389f 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -82,11 +82,14 @@ def async_anthemav_update_callback(message): class AnthemAVR(MediaPlayerEntity): """Entity reading values from Anthem AVR protocol.""" + _attr_should_poll = False + _attr_supported_features = SUPPORT_ANTHEMAV + def __init__(self, avr, name): """Initialize entity with transport.""" super().__init__() self.avr = avr - self._name = name + self._attr_name = name or self._lookup("model") def _lookup(self, propname, dval=None): return getattr(self.avr.protocol, propname, dval) @@ -97,21 +100,6 @@ async def async_added_to_hass(self): async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_ANTHEMAV - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return name of device.""" - return self._name or self._lookup("model") - @property def state(self): """Return state of power on/off.""" diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index daf9592f3e6e9..8a1f98329bb65 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -25,20 +25,9 @@ class OnlineStatus(BinarySensorEntity): def __init__(self, config, data): """Initialize the APCUPSd binary device.""" - self._config = config self._data = data - self._state = None - - @property - def name(self): - """Return the name of the UPS online status sensor.""" - return self._config[CONF_NAME] - - @property - def is_on(self): - """Return true if the UPS is online, else false.""" - return self._state & VALUE_ONLINE > 0 + self._attr_name = config[CONF_NAME] def update(self): """Get the status report from APCUPSd and set this entity's state.""" - self._state = int(self._data.status[KEY_STATUS], 16) + self._attr_is_on = int(self._data.status[KEY_STATUS], 16) & VALUE_ONLINE > 0 diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 36dc1155b7fd7..faa1f5a09e21c 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -1,21 +1,28 @@ """Support for APCUPSd sensors.""" +from __future__ import annotations + import logging from apcaccess.status import ALL_UNITS import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_RESOURCES, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_HERTZ, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, TEMP_CELSIUS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) import homeassistant.helpers.config_validation as cv @@ -24,83 +31,369 @@ _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = "UPS " -SENSOR_TYPES = { - "alarmdel": ["Alarm Delay", "", "mdi:alarm"], - "ambtemp": ["Ambient Temperature", "", "mdi:thermometer"], - "apc": ["Status Data", "", "mdi:information-outline"], - "apcmodel": ["Model", "", "mdi:information-outline"], - "badbatts": ["Bad Batteries", "", "mdi:information-outline"], - "battdate": ["Battery Replaced", "", "mdi:calendar-clock"], - "battstat": ["Battery Status", "", "mdi:information-outline"], - "battv": ["Battery Voltage", VOLT, "mdi:flash"], - "bcharge": ["Battery", PERCENTAGE, "mdi:battery"], - "cable": ["Cable Type", "", "mdi:ethernet-cable"], - "cumonbatt": ["Total Time on Battery", "", "mdi:timer-outline"], - "date": ["Status Date", "", "mdi:calendar-clock"], - "dipsw": ["Dip Switch Settings", "", "mdi:information-outline"], - "dlowbatt": ["Low Battery Signal", "", "mdi:clock-alert"], - "driver": ["Driver", "", "mdi:information-outline"], - "dshutd": ["Shutdown Delay", "", "mdi:timer-outline"], - "dwake": ["Wake Delay", "", "mdi:timer-outline"], - "endapc": ["Date and Time", "", "mdi:calendar-clock"], - "extbatts": ["External Batteries", "", "mdi:information-outline"], - "firmware": ["Firmware Version", "", "mdi:information-outline"], - "hitrans": ["Transfer High", VOLT, "mdi:flash"], - "hostname": ["Hostname", "", "mdi:information-outline"], - "humidity": ["Ambient Humidity", PERCENTAGE, "mdi:water-percent"], - "itemp": ["Internal Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "lastxfer": ["Last Transfer", "", "mdi:transfer"], - "linefail": ["Input Voltage Status", "", "mdi:information-outline"], - "linefreq": ["Line Frequency", FREQUENCY_HERTZ, "mdi:information-outline"], - "linev": ["Input Voltage", VOLT, "mdi:flash"], - "loadpct": ["Load", PERCENTAGE, "mdi:gauge"], - "loadapnt": ["Load Apparent Power", PERCENTAGE, "mdi:gauge"], - "lotrans": ["Transfer Low", VOLT, "mdi:flash"], - "mandate": ["Manufacture Date", "", "mdi:calendar"], - "masterupd": ["Master Update", "", "mdi:information-outline"], - "maxlinev": ["Input Voltage High", VOLT, "mdi:flash"], - "maxtime": ["Battery Timeout", "", "mdi:timer-off-outline"], - "mbattchg": ["Battery Shutdown", PERCENTAGE, "mdi:battery-alert"], - "minlinev": ["Input Voltage Low", VOLT, "mdi:flash"], - "mintimel": ["Shutdown Time", "", "mdi:timer-outline"], - "model": ["Model", "", "mdi:information-outline"], - "nombattv": ["Battery Nominal Voltage", VOLT, "mdi:flash"], - "nominv": ["Nominal Input Voltage", VOLT, "mdi:flash"], - "nomoutv": ["Nominal Output Voltage", VOLT, "mdi:flash"], - "nompower": ["Nominal Output Power", POWER_WATT, "mdi:flash"], - "nomapnt": ["Nominal Apparent Power", ELECTRICAL_VOLT_AMPERE, "mdi:flash"], - "numxfers": ["Transfer Count", "", "mdi:counter"], - "outcurnt": ["Output Current", ELECTRICAL_CURRENT_AMPERE, "mdi:flash"], - "outputv": ["Output Voltage", VOLT, "mdi:flash"], - "reg1": ["Register 1 Fault", "", "mdi:information-outline"], - "reg2": ["Register 2 Fault", "", "mdi:information-outline"], - "reg3": ["Register 3 Fault", "", "mdi:information-outline"], - "retpct": ["Restore Requirement", PERCENTAGE, "mdi:battery-alert"], - "selftest": ["Last Self Test", "", "mdi:calendar-clock"], - "sense": ["Sensitivity", "", "mdi:information-outline"], - "serialno": ["Serial Number", "", "mdi:information-outline"], - "starttime": ["Startup Time", "", "mdi:calendar-clock"], - "statflag": ["Status Flag", "", "mdi:information-outline"], - "status": ["Status", "", "mdi:information-outline"], - "stesti": ["Self Test Interval", "", "mdi:information-outline"], - "timeleft": ["Time Left", "", "mdi:clock-alert"], - "tonbatt": ["Time on Battery", "", "mdi:timer-outline"], - "upsmode": ["Mode", "", "mdi:information-outline"], - "upsname": ["Name", "", "mdi:information-outline"], - "version": ["Daemon Info", "", "mdi:information-outline"], - "xoffbat": ["Transfer from Battery", "", "mdi:transfer"], - "xoffbatt": ["Transfer from Battery", "", "mdi:transfer"], - "xonbatt": ["Transfer to Battery", "", "mdi:transfer"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="alarmdel", + name="Alarm Delay", + icon="mdi:alarm", + ), + SensorEntityDescription( + key="ambtemp", + name="Ambient Temperature", + icon="mdi:thermometer", + ), + SensorEntityDescription( + key="apc", + name="Status Data", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="apcmodel", + name="Model", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="badbatts", + name="Bad Batteries", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="battdate", + name="Battery Replaced", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="battstat", + name="Battery Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="battv", + name="Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="bcharge", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + SensorEntityDescription( + key="cable", + name="Cable Type", + icon="mdi:ethernet-cable", + ), + SensorEntityDescription( + key="cumonbatt", + name="Total Time on Battery", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="date", + name="Status Date", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="dipsw", + name="Dip Switch Settings", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="dlowbatt", + name="Low Battery Signal", + icon="mdi:clock-alert", + ), + SensorEntityDescription( + key="driver", + name="Driver", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="dshutd", + name="Shutdown Delay", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="dwake", + name="Wake Delay", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="end apc", + name="Date and Time", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="extbatts", + name="External Batteries", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="firmware", + name="Firmware Version", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="hitrans", + name="Transfer High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="hostname", + name="Hostname", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="humidity", + name="Ambient Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="itemp", + name="Internal Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="lastxfer", + name="Last Transfer", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="linefail", + name="Input Voltage Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="linefreq", + name="Line Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="linev", + name="Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="loadpct", + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="loadapnt", + name="Load Apparent Power", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="lotrans", + name="Transfer Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="mandate", + name="Manufacture Date", + icon="mdi:calendar", + ), + SensorEntityDescription( + key="masterupd", + name="Master Update", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="maxlinev", + name="Input Voltage High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="maxtime", + name="Battery Timeout", + icon="mdi:timer-off-outline", + ), + SensorEntityDescription( + key="mbattchg", + name="Battery Shutdown", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-alert", + ), + SensorEntityDescription( + key="minlinev", + name="Input Voltage Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="mintimel", + name="Shutdown Time", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="model", + name="Model", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="nombattv", + name="Battery Nominal Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nominv", + name="Nominal Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nomoutv", + name="Nominal Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nompower", + name="Nominal Output Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nomapnt", + name="Nominal Apparent Power", + native_unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + ), + SensorEntityDescription( + key="numxfers", + name="Transfer Count", + icon="mdi:counter", + ), + SensorEntityDescription( + key="outcurnt", + name="Output Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + ), + SensorEntityDescription( + key="outputv", + name="Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="reg1", + name="Register 1 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="reg2", + name="Register 2 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="reg3", + name="Register 3 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="retpct", + name="Restore Requirement", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-alert", + ), + SensorEntityDescription( + key="selftest", + name="Last Self Test", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="sense", + name="Sensitivity", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="serialno", + name="Serial Number", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="starttime", + name="Startup Time", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="statflag", + name="Status Flag", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="status", + name="Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="stesti", + name="Self Test Interval", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="timeleft", + name="Time Left", + icon="mdi:clock-alert", + ), + SensorEntityDescription( + key="tonbatt", + name="Time on Battery", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="upsmode", + name="Mode", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="upsname", + name="Name", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="version", + name="Daemon Info", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="xoffbat", + name="Transfer from Battery", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="xoffbatt", + name="Transfer from Battery", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="xonbatt", + name="Transfer to Battery", + icon="mdi:transfer", + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} INFERRED_UNITS = { " Minutes": TIME_MINUTES, " Seconds": TIME_SECONDS, " Percent": PERCENTAGE, - " Volts": VOLT, - " Ampere": ELECTRICAL_CURRENT_AMPERE, - " Volt-Ampere": ELECTRICAL_VOLT_AMPERE, + " Volts": ELECTRIC_POTENTIAL_VOLT, + " Ampere": ELECTRIC_CURRENT_AMPERE, + " Volt-Ampere": POWER_VOLT_AMPERE, " Watts": POWER_WATT, " Hz": FREQUENCY_HERTZ, " C": TEMP_CELSIUS, @@ -110,7 +403,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -119,25 +412,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the APCUPSd sensors.""" apcups_data = hass.data[DOMAIN] - entities = [] + resources = config[CONF_RESOURCES] - for resource in config[CONF_RESOURCES]: - sensor_type = resource.lower() - - if sensor_type not in SENSOR_TYPES: - SENSOR_TYPES[sensor_type] = [ - sensor_type.title(), - "", - "mdi:information-outline", - ] - - if sensor_type.upper() not in apcups_data.status: + for resource in resources: + if resource.upper() not in apcups_data.status: _LOGGER.warning( "Sensor type: %s does not appear in the APCUPSd status output", - sensor_type, + resource, ) - entities.append(APCUPSdSensor(apcups_data, sensor_type)) + entities = [ + APCUPSdSensor(apcups_data, description) + for description in SENSOR_TYPES + if description.key in resources + ] add_entities(entities, True) @@ -158,43 +446,18 @@ def infer_unit(value): class APCUPSdSensor(SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" - def __init__(self, data, sensor_type): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._data = data - self.type = sensor_type - self._name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] - self._unit = SENSOR_TYPES[sensor_type][1] - self._inferred_unit = None - self._state = None - - @property - def name(self): - """Return the name of the UPS sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def state(self): - """Return true if the UPS is online, else False.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - if not self._unit: - return self._inferred_unit - return self._unit + self._attr_name = f"{SENSOR_PREFIX}{description.name}" def update(self): """Get the latest status and use it to update our sensor state.""" - if self.type.upper() not in self._data.status: - self._state = None - self._inferred_unit = None + key = self.entity_description.key.upper() + if key not in self._data.status: + self._attr_native_value = None else: - self._state, self._inferred_unit = infer_unit( - self._data.status[self.type.upper()] - ) + self._attr_native_value, inferred_unit = infer_unit(self._data.status[key]) + if not self.native_unit_of_measurement: + self._attr_native_unit_of_measurement = inferred_unit diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a91d85402866c..d2201ccace04e 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,6 +1,6 @@ """Rest API for Home Assistant.""" import asyncio -from contextlib import suppress +from http import HTTPStatus import json import logging @@ -15,10 +15,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, - HTTP_BAD_REQUEST, - HTTP_CREATED, - HTTP_NOT_FOUND, - HTTP_OK, MATCH_ALL, URL_API, URL_API_COMPONENTS, @@ -30,15 +26,12 @@ URL_API_STATES, URL_API_STREAM, URL_API_TEMPLATE, - __version__, ) import homeassistant.core as ha from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_get_all_descriptions -from homeassistant.helpers.system_info import async_get_system_info _LOGGER = logging.getLogger(__name__) @@ -97,14 +90,14 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() hass = request.app["hass"] stop_obj = object() to_write = asyncio.Queue() - restrict = request.query.get("restrict") - if restrict: + if restrict := request.query.get("restrict"): restrict = restrict.split(",") + [EVENT_HOMEASSISTANT_STOP] async def forward_events(event): @@ -138,7 +131,7 @@ async def forward_events(event): while True: try: - with async_timeout.timeout(STREAM_PING_INTERVAL): + async with async_timeout.timeout(STREAM_PING_INTERVAL): payload = await to_write.get() if payload is stop_obj: @@ -173,7 +166,11 @@ def get(self, request): class APIDiscoveryView(HomeAssistantView): - """View to provide Discovery information.""" + """ + View to provide Discovery information. + + DEPRECATED: To be removed in 2022.1 + """ requires_auth = False url = URL_API_DISCOVERY_INFO @@ -181,32 +178,18 @@ class APIDiscoveryView(HomeAssistantView): async def get(self, request): """Get discovery information.""" - hass = request.app["hass"] - uuid = await hass.helpers.instance_id.async_get() - system_info = await async_get_system_info(hass) - - data = { - ATTR_UUID: uuid, - ATTR_BASE_URL: None, - ATTR_EXTERNAL_URL: None, - ATTR_INTERNAL_URL: None, - ATTR_LOCATION_NAME: hass.config.location_name, - ATTR_INSTALLATION_TYPE: system_info[ATTR_INSTALLATION_TYPE], - # always needs authentication - ATTR_REQUIRES_API_PASSWORD: True, - ATTR_VERSION: __version__, - } - - with suppress(NoURLAvailableError): - data["external_url"] = get_url(hass, allow_internal=False) - - with suppress(NoURLAvailableError): - data["internal_url"] = get_url(hass, allow_external=False) - - # Set old base URL based on external or internal - data["base_url"] = data["external_url"] or data["internal_url"] - - return self.json(data) + return self.json( + { + ATTR_UUID: "", + ATTR_BASE_URL: "", + ATTR_EXTERNAL_URL: "", + ATTR_INTERNAL_URL: "", + ATTR_LOCATION_NAME: "", + ATTR_INSTALLATION_TYPE: "", + ATTR_REQUIRES_API_PASSWORD: True, + ATTR_VERSION: "", + } + ) class APIStatesView(HomeAssistantView): @@ -241,10 +224,9 @@ def get(self, request, entity_id): if not user.permissions.check_entity(entity_id, POLICY_READ): raise Unauthorized(entity_id=entity_id) - state = request.app["hass"].states.get(entity_id) - if state: + if state := request.app["hass"].states.get(entity_id): return self.json(state) - return self.json_message("Entity not found.", HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) async def post(self, request, entity_id): """Update state of entity.""" @@ -254,12 +236,10 @@ async def post(self, request, entity_id): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified.", HTTP_BAD_REQUEST) - - new_state = data.get("state") + return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) - if new_state is None: - return self.json_message("No state specified.", HTTP_BAD_REQUEST) + if (new_state := data.get("state")) is None: + return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST) attributes = data.get("attributes") force_update = data.get("force_update", False) @@ -272,7 +252,7 @@ async def post(self, request, entity_id): ) # Read the state back for our response - status_code = HTTP_CREATED if is_new_state else HTTP_OK + status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK resp = self.json(hass.states.get(entity_id), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") @@ -286,7 +266,7 @@ def delete(self, request, entity_id): raise Unauthorized(entity_id=entity_id) if request.app["hass"].states.async_remove(entity_id): return self.json_message("Entity removed.") - return self.json_message("Entity not found.", HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) class APIEventListenersView(HomeAssistantView): @@ -316,12 +296,12 @@ async def post(self, request, event_type): event_data = json.loads(body) if body else None except ValueError: return self.json_message( - "Event data should be valid JSON.", HTTP_BAD_REQUEST + "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST ) if event_data is not None and not isinstance(event_data, dict): return self.json_message( - "Event data should be a JSON object", HTTP_BAD_REQUEST + "Event data should be a JSON object", HTTPStatus.BAD_REQUEST ) # Special case handling for event STATE_CHANGED @@ -368,7 +348,9 @@ async def post(self, request, domain, service): try: data = json.loads(body) if body else None except ValueError: - return self.json_message("Data should be valid JSON.", HTTP_BAD_REQUEST) + return self.json_message( + "Data should be valid JSON.", HTTPStatus.BAD_REQUEST + ) context = self.context(request) @@ -416,7 +398,7 @@ async def post(self, request): return tpl.async_render(variables=data.get("variables"), parse_result=False) except (ValueError, TemplateError) as ex: return self.json_message( - f"Error rendering template: {ex}", HTTP_BAD_REQUEST + f"Error rendering template: {ex}", HTTPStatus.BAD_REQUEST ) @@ -428,6 +410,7 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py index c9e12a2086359..e0287897e9680 100644 --- a/homeassistant/components/apns/notify.py +++ b/homeassistant/components/apns/notify.py @@ -184,7 +184,7 @@ def device_state_changed_listener(self, entity_id, from_s, to_s): def write_devices(self): """Write all known devices to file.""" - with open(self.yaml_path, "w+") as out: + with open(self.yaml_path, "w+", encoding="utf8") as out: for device in self.devices.values(): _write_device(out, device) @@ -202,7 +202,7 @@ def register(self, call): if current_device is None: self.devices[push_id] = device - with open(self.yaml_path, "a") as out: + with open(self.yaml_path, "a", encoding="utf8") as out: _write_device(out, device) return True @@ -220,9 +220,7 @@ def send_message(self, message=None, **kwargs): ) device_state = kwargs.get(ATTR_TARGET) - message_data = kwargs.get(ATTR_DATA) - - if message_data is None: + if (message_data := kwargs.get(ATTR_DATA)) is None: message_data = {} if isinstance(message, str): diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index a1bd50ab2217c..200984ca8831a 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -4,15 +4,21 @@ from random import randrange from pyatv import connect, exceptions, scan -from pyatv.const import Protocol +from pyatv.const import DeviceModel, Protocol +from pyatv.convert import model_str from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SUGGESTED_AREA, + ATTR_SW_VERSION, CONF_ADDRESS, CONF_NAME, - CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback @@ -22,19 +28,17 @@ async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import CONF_CREDENTIALS, CONF_IDENTIFIER, CONF_START_OFF, DOMAIN +from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Apple TV" +BACKOFF_TIME_LOWER_LIMIT = 15 # seconds BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes -NOTIFICATION_TITLE = "Apple TV Notification" -NOTIFICATION_ID = "apple_tv_notification" - SIGNAL_CONNECTED = "apple_tv_connected" SIGNAL_DISCONNECTED = "apple_tv_disconnected" @@ -57,10 +61,10 @@ async def on_hass_stop(event): async def setup_platforms(): """Set up platforms and initiate connection.""" await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) await manager.init() @@ -83,12 +87,15 @@ async def async_unload_entry(hass, entry): class AppleTVEntity(Entity): """Device that sends commands to an Apple TV.""" + _attr_should_poll = False + def __init__(self, name, identifier, manager): """Initialize device.""" self.atv = None self.manager = manager - self._name = name - self._identifier = identifier + self._attr_name = name + self._attr_unique_id = identifier + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, identifier)}) async def async_added_to_hass(self): """Handle when an entity is about to be added to Home Assistant.""" @@ -109,13 +116,13 @@ def _async_disconnected(): self.async_on_remove( async_dispatcher_connect( - self.hass, f"{SIGNAL_CONNECTED}_{self._identifier}", _async_connected + self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - f"{SIGNAL_DISCONNECTED}_{self._identifier}", + f"{SIGNAL_DISCONNECTED}_{self.unique_id}", _async_disconnected, ) ) @@ -126,28 +133,6 @@ def async_device_connected(self, atv): def async_device_disconnected(self): """Handle when connection was lost to device.""" - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._identifier - - @property - def should_poll(self): - """No polling needed for Apple TV.""" - return False - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self._identifier)}, - } - class AppleTVManager: """Connection and power manager for an Apple TV. @@ -241,7 +226,12 @@ async def _connect_loop(self): if conf: await self._connect(conf) except exceptions.AuthenticationError: - self._auth_problem() + self.config_entry.async_start_reauth(self.hass) + asyncio.create_task(self.disconnect()) + _LOGGER.exception( + "Authentication failed for %s, try reconfiguring device", + self.config_entry.data[CONF_NAME], + ) break except asyncio.CancelledError: pass @@ -252,7 +242,11 @@ async def _connect_loop(self): if self.atv is None: self._connection_attempts += 1 backoff = min( - randrange(2 ** self._connection_attempts), BACKOFF_TIME_UPPER_LIMIT + max( + BACKOFF_TIME_LOWER_LIMIT, + randrange(2 ** self._connection_attempts), + ), + BACKOFF_TIME_UPPER_LIMIT, ) _LOGGER.debug("Reconnecting in %d seconds", backoff) @@ -261,57 +255,33 @@ async def _connect_loop(self): _LOGGER.debug("Connect loop ended") self._task = None - def _auth_problem(self): - """Problem to authenticate occurred that needs intervention.""" - _LOGGER.debug("Authentication error, reconfigure integration") - - name = self.config_entry.data[CONF_NAME] - identifier = self.config_entry.unique_id - - self.hass.components.persistent_notification.create( - "An irrecoverable connection problem occurred when connecting to " - f"`f{name}`. Please go to the Integrations page and reconfigure it", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - - # Add to event queue as this function is called from a task being - # cancelled from disconnect - asyncio.create_task(self.disconnect()) - - self.hass.async_create_task( - self.hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, - data={CONF_NAME: name, CONF_IDENTIFIER: identifier}, - ) - ) - async def _scan(self): """Try to find device by scanning for it.""" - identifier = self.config_entry.unique_id + identifiers = set( + self.config_entry.data.get(CONF_IDENTIFIERS, [self.config_entry.unique_id]) + ) address = self.config_entry.data[CONF_ADDRESS] - protocol = Protocol(self.config_entry.data[CONF_PROTOCOL]) - _LOGGER.debug("Discovering device %s", identifier) + # Only scan for and set up protocols that was successfully paired + protocols = { + Protocol(int(protocol)) + for protocol in self.config_entry.data[CONF_CREDENTIALS] + } + + _LOGGER.debug("Discovering device %s", self.config_entry.title) atvs = await scan( - self.hass.loop, identifier=identifier, protocol=protocol, hosts=[address] + self.hass.loop, identifier=identifiers, protocol=protocols, hosts=[address] ) if atvs: return atvs[0] _LOGGER.debug( - "Failed to find device %s with address %s, trying to scan", - identifier, + "Failed to find device %s with address %s", + self.config_entry.title, address, ) - - atvs = await scan(self.hass.loop, identifier=identifier, protocol=protocol) - if atvs: - return atvs[0] - - _LOGGER.debug("Failed to find device %s, trying later", identifier) - + # We no longer multicast scan for the device since as soon as async_step_zeroconf runs, + # it will update the address and reload the config entry when the device is found. return None async def _connect(self, conf): @@ -319,8 +289,16 @@ async def _connect(self, conf): credentials = self.config_entry.data[CONF_CREDENTIALS] session = async_get_clientsession(self.hass) - for protocol, creds in credentials.items(): - conf.set_credentials(Protocol(int(protocol)), creds) + for protocol_int, creds in credentials.items(): + protocol = Protocol(int(protocol_int)) + if conf.get_service(protocol) is not None: + conf.set_credentials(protocol, creds) + else: + _LOGGER.warning( + "Protocol %s not found for %s, functionality will be reduced", + protocol.name, + self.config_entry.data[CONF_NAME], + ) _LOGGER.debug("Connecting to device %s", self.config_entry.data[CONF_NAME]) self.atv = await connect(conf, self.hass.loop, session=session) @@ -329,39 +307,44 @@ async def _connect(self, conf): self._dispatch_send(SIGNAL_CONNECTED, self.atv) self._address_updated(str(conf.address)) - await self._async_setup_device_registry() + self._async_setup_device_registry() self._connection_attempts = 0 if self._connection_was_lost: _LOGGER.info( - 'Connection was re-established to Apple TV "%s"', + 'Connection was re-established to device "%s"', self.config_entry.data[CONF_NAME], ) self._connection_was_lost = False - async def _async_setup_device_registry(self): + @callback + def _async_setup_device_registry(self): attrs = { - "identifiers": {(DOMAIN, self.config_entry.unique_id)}, - "manufacturer": "Apple", - "name": self.config_entry.data[CONF_NAME], + ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)}, + ATTR_MANUFACTURER: "Apple", + ATTR_NAME: self.config_entry.data[CONF_NAME], } - area = attrs["name"] + area = attrs[ATTR_NAME] name_trailer = f" {DEFAULT_NAME}" if area.endswith(name_trailer): area = area[: -len(name_trailer)] - attrs["suggested_area"] = area + attrs[ATTR_SUGGESTED_AREA] = area if self.atv: dev_info = self.atv.device_info - attrs["model"] = DEFAULT_NAME + " " + dev_info.model.name.replace("Gen", "") - attrs["sw_version"] = dev_info.version + attrs[ATTR_MODEL] = ( + dev_info.raw_model + if dev_info.model == DeviceModel.Unknown and dev_info.raw_model + else model_str(dev_info.model) + ) + attrs[ATTR_SW_VERSION] = dev_info.version if dev_info.mac: - attrs["connections"] = {(dr.CONNECTION_NETWORK_MAC, dev_info.mac)} + attrs[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, dev_info.mac)} - device_registry = await dr.async_get_registry(self.hass) + device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, **attrs ) diff --git a/homeassistant/components/apple_tv/browse_media.py b/homeassistant/components/apple_tv/browse_media.py new file mode 100644 index 0000000000000..3c0eee8b6add0 --- /dev/null +++ b/homeassistant/components/apple_tv/browse_media.py @@ -0,0 +1,44 @@ +"""Support for media browsing.""" + +from homeassistant.components.media_player import BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_APP, + MEDIA_CLASS_DIRECTORY, + MEDIA_TYPE_APP, + MEDIA_TYPE_APPS, +) + + +def build_app_list(app_list): + """Create response payload for app list.""" + app_list = [ + {"app_id": app_id, "title": app_name, "type": MEDIA_TYPE_APP} + for app_name, app_id in app_list.items() + ] + + return BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=None, + media_content_type=MEDIA_TYPE_APPS, + title="Apps", + can_play=True, + can_expand=False, + children=[item_payload(item) for item in app_list], + children_media_class=MEDIA_CLASS_APP, + ) + + +def item_payload(item): + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + return BrowseMedia( + title=item["title"], + media_class=MEDIA_CLASS_APP, + media_content_type=MEDIA_TYPE_APP, + media_content_id=item["app_id"], + can_play=False, + can_expand=False, + ) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 9afcb7a61caba..4b630fc777b52 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -1,27 +1,26 @@ """Config flow for Apple TV integration.""" +from __future__ import annotations + +import asyncio +from collections import deque from ipaddress import ip_address import logging from random import randrange from pyatv import exceptions, pair, scan -from pyatv.const import Protocol -from pyatv.convert import protocol_str +from pyatv.const import DeviceModel, PairingRequirement, Protocol +from pyatv.convert import model_str, protocol_str +from pyatv.helpers import get_unique_id import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import ( - CONF_ADDRESS, - CONF_NAME, - CONF_PIN, - CONF_PROTOCOL, - CONF_TYPE, -) +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_CREDENTIALS, CONF_IDENTIFIER, CONF_START_OFF, DOMAIN +from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -30,10 +29,11 @@ INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=None): int}) DEFAULT_START_OFF = False -PROTOCOL_PRIORITY = [Protocol.MRP, Protocol.DMAP, Protocol.AirPlay] + +DISCOVERY_AGGREGATION_TIME = 15 # seconds -async def device_scan(identifier, loop, cache=None): +async def device_scan(identifier, loop): """Scan for a specific device using identifier as filter.""" def _filter_device(dev): @@ -51,27 +51,15 @@ def _host_filter(): except ValueError: return None - if cache: - matches = [atv for atv in cache if _filter_device(atv)] - if matches: - return cache, matches[0] - - for hosts in [_host_filter(), None]: - scan_result = await scan(loop, timeout=3, hosts=hosts) - matches = [atv for atv in scan_result if _filter_device(atv)] - - if matches: - return scan_result, matches[0] + # If we have an address, only probe that address to avoid + # broadcast traffic on the network + scan_result = await scan(loop, timeout=3, hosts=_host_filter()) + matches = [atv for atv in scan_result if _filter_device(atv)] - return scan_result, None + if matches: + return matches[0], matches[0].all_identifiers - -def is_valid_credentials(credentials): - """Verify that credentials are valid for establishing a connection.""" - return ( - credentials.get(Protocol.MRP.value) is not None - or credentials.get(Protocol.DMAP.value) is not None - ) + return None, None class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -87,19 +75,52 @@ def async_get_options_flow(config_entry): def __init__(self): """Initialize a new AppleTVConfigFlow.""" - self.target_device = None - self.scan_result = None + self.scan_filter = None self.atv = None + self.atv_identifiers = None self.protocol = None self.pairing = None self.credentials = {} # Protocol -> credentials + self.protocols_to_pair = deque() + + @property + def device_identifier(self): + """Return a identifier for the config entry. + + A device has multiple unique identifiers, but Home Assistant only supports one + per config entry. Normally, a "main identifier" is determined by pyatv by + first collecting all identifiers and then picking one in a pre-determine order. + Under normal circumstances, this works fine but if a service is missing or + removed due to deprecation (which happened with MRP), then another identifier + will be calculated instead. To fix this, all identifiers belonging to a device + is stored with the config entry and one of them (could be random) is used as + unique_id for said entry. When a new (zeroconf) service or device is + discovered, the identifier is first used to look up if it belongs to an + existing config entry. If that's the case, the unique_id from that entry is + re-used, otherwise the newly discovered identifier is used instead. + """ + all_identifiers = set(self.atv.all_identifiers) + if unique_id := self._entry_unique_id_from_identifers(all_identifiers): + return unique_id + return self.atv.identifier - async def async_step_reauth(self, info): - """Handle initial step when updating invalid credentials.""" - await self.async_set_unique_id(info[CONF_IDENTIFIER]) - self.target_device = info[CONF_IDENTIFIER] + @callback + def _entry_unique_id_from_identifers(self, all_identifiers: set[str]) -> str | None: + """Search existing entries for an identifier and return the unique id.""" + for entry in self._async_current_entries(): + if all_identifiers.intersection( + entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) + ): + return entry.unique_id + return None - self.context["title_placeholders"] = {"name": info[CONF_NAME]} + async def async_step_reauth(self, user_input=None): + """Handle initial step when updating invalid credentials.""" + self.context["title_placeholders"] = { + "name": user_input[CONF_NAME], + "type": "Apple TV", + } + self.scan_filter = self.unique_id self.context["identifier"] = self.unique_id return await self.async_step_reconfigure() @@ -107,68 +128,135 @@ async def async_step_reconfigure(self, user_input=None): """Inform user that reconfiguration is about to start.""" if user_input is not None: return await self.async_find_device_wrapper( - self.async_begin_pairing, allow_exist=True + self.async_pair_next_protocol, allow_exist=True ) return self.async_show_form(step_id="reconfigure") async def async_step_user(self, user_input=None): """Handle the initial step.""" - # Be helpful to the user and look for devices - if self.scan_result is None: - self.scan_result, _ = await device_scan(None, self.hass.loop) - errors = {} - default_suggestion = self._prefill_identifier() if user_input is not None: - self.target_device = user_input[DEVICE_INPUT] + self.scan_filter = user_input[DEVICE_INPUT] try: await self.async_find_device() except DeviceNotFound: errors["base"] = "no_devices_found" except DeviceAlreadyConfigured: errors["base"] = "already_configured" - except exceptions.NoServiceError: - errors["base"] = "no_usable_service" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( - self.atv.identifier, raise_on_progress=False + self.device_identifier, raise_on_progress=False ) + self.context["all_identifiers"] = self.atv.all_identifiers return await self.async_step_confirm() return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(DEVICE_INPUT, default=default_suggestion): str} - ), + data_schema=vol.Schema({vol.Required(DEVICE_INPUT): str}), errors=errors, - description_placeholders={"devices": self._devices_str()}, ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> data_entry_flow.FlowResult: """Handle device found via zeroconf.""" - service_type = discovery_info[CONF_TYPE] - properties = discovery_info["properties"] - - if service_type == "_mediaremotetv._tcp.local.": - identifier = properties["UniqueIdentifier"] - name = properties["Name"] - elif service_type == "_touch-able._tcp.local.": - identifier = discovery_info["name"].split(".")[0] - name = properties["CtlN"] - else: + host = discovery_info.host + self._async_abort_entries_match({CONF_ADDRESS: host}) + service_type = discovery_info.type[:-1] # Remove leading . + name = discovery_info.name.replace(f".{service_type}.", "") + properties = discovery_info.properties + + # Extract unique identifier from service + unique_id = get_unique_id(service_type, name, properties) + if unique_id is None: return self.async_abort(reason="unknown") - await self.async_set_unique_id(identifier) - self._abort_if_unique_id_configured() + if existing_unique_id := self._entry_unique_id_from_identifers({unique_id}): + await self.async_set_unique_id(existing_unique_id) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: host}) + + self._async_abort_entries_match({CONF_ADDRESS: host}) + await self._async_aggregate_discoveries(host, unique_id) + # Scan for the device in order to extract _all_ unique identifiers assigned to + # it. Not doing it like this will yield multiple config flows for the same + # device, one per protocol, which is undesired. + self.scan_filter = host + return await self.async_find_device_wrapper(self.async_found_zeroconf_device) + + async def _async_aggregate_discoveries(self, host: str, unique_id: str) -> None: + """Wait for multiple zeroconf services to be discovered an aggregate them.""" + # + # Suppose we have a device with three services: A, B and C. Let's assume + # service A is discovered by Zeroconf, triggering a device scan that also finds + # service B but *not* C. An identifier is picked from one of the services and + # used as unique_id. The select process is deterministic (let's say in order A, + # B and C) but in practice that doesn't matter. So, a flow is set up for the + # device with unique_id set to "A" for services A and B. + # + # Now, service C is found and the same thing happens again but only service B + # is found. In this case, unique_id will be set to "B" which is problematic + # since both flows really represent the same device. They will however end up + # as two separate flows. + # + # To solve this, all identifiers are stored as + # "all_identifiers" in the flow context. When a new service is discovered, the + # code below will check these identifiers for all active flows and abort if a + # match is found. Before aborting, the original flow is updated with any + # potentially new identifiers. In the example above, when service C is + # discovered, the identifier of service C will be inserted into + # "all_identifiers" of the original flow (making the device complete). + # + # Wait DISCOVERY_AGGREGATION_TIME for multiple services to be + # discovered via zeroconf. Once the first service is discovered + # this allows other services to be discovered inside the time + # window before triggering a scan of the device. This prevents + # multiple scans of the device at the same time since each + # apple_tv device has multiple services that are discovered by + # zeroconf. + # + self._async_check_and_update_in_progress(host, unique_id) + await asyncio.sleep(DISCOVERY_AGGREGATION_TIME) + # Check again after sleeping in case another flow + # has made progress while we yielded to the event loop + self._async_check_and_update_in_progress(host, unique_id) + # Host must only be set AFTER checking and updating in progress + # flows or we will have a race condition where no flows move forward. + self.context[CONF_ADDRESS] = host + @callback + def _async_check_and_update_in_progress(self, host: str, unique_id: str) -> None: + """Check for in-progress flows and update them with identifiers if needed.""" + for flow in self._async_in_progress(include_uninitialized=True): + context = flow["context"] + if ( + context.get("source") != config_entries.SOURCE_ZEROCONF + or context.get(CONF_ADDRESS) != host + ): + continue + if ( + "all_identifiers" in context + and unique_id not in context["all_identifiers"] + ): + # Add potentially new identifiers from this device to the existing flow + context["all_identifiers"].append(unique_id) + raise data_entry_flow.AbortFlow("already_in_progress") + + async def async_found_zeroconf_device(self, user_input=None): + """Handle device found after Zeroconf discovery.""" + self.context["all_identifiers"] = self.atv.all_identifiers + # Also abort if an integration with this identifier already exists + await self.async_set_unique_id(self.device_identifier) + # but be sure to update the address if its changed so the scanner + # will probe the new address + self._abort_if_unique_id_configured( + updates={CONF_ADDRESS: str(self.atv.address)} + ) self.context["identifier"] = self.unique_id - self.context["title_placeholders"] = {"name": name} - self.target_device = identifier - return await self.async_find_device_wrapper(self.async_step_confirm) + return await self.async_step_confirm() async def async_find_device_wrapper(self, next_func, allow_exist=False): """Find a specific device and call another function when done. @@ -190,56 +278,110 @@ async def async_find_device_wrapper(self, next_func, allow_exist=False): async def async_find_device(self, allow_exist=False): """Scan for the selected device to discover services.""" - self.scan_result, self.atv = await device_scan( - self.target_device, self.hass.loop, cache=self.scan_result + self.atv, self.atv_identifiers = await device_scan( + self.scan_filter, self.hass.loop ) if not self.atv: raise DeviceNotFound() - self.protocol = self.atv.main_service().protocol - - if not allow_exist: - for identifier in self.atv.all_identifiers: - if identifier in self._async_current_ids(): - raise DeviceAlreadyConfigured() + # Protocols supported by the device are prospects for pairing + self.protocols_to_pair = deque( + service.protocol for service in self.atv.services if service.enabled + ) - # If credentials were found, save them - for service in self.atv.services: - if service.credentials: - self.credentials[service.protocol.value] = service.credentials + dev_info = self.atv.device_info + self.context["title_placeholders"] = { + "name": self.atv.name, + "type": ( + dev_info.raw_model + if dev_info.model == DeviceModel.Unknown and dev_info.raw_model + else model_str(dev_info.model) + ), + } + all_identifiers = set(self.atv.all_identifiers) + discovered_ip_address = str(self.atv.address) + for entry in self._async_current_entries(): + if not all_identifiers.intersection( + entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) + ): + continue + if entry.data.get(CONF_ADDRESS) != discovered_ip_address: + self.hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_ADDRESS: discovered_ip_address}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + if not allow_exist: + raise DeviceAlreadyConfigured() async def async_step_confirm(self, user_input=None): """Handle user-confirmation of discovered node.""" if user_input is not None: - return await self.async_begin_pairing() + expected_identifier_count = len(self.context["all_identifiers"]) + # If number of services found during device scan mismatch number of + # identifiers collected during Zeroconf discovery, then trigger a new scan + # with hopes of finding all services. + if len(self.atv.all_identifiers) != expected_identifier_count: + try: + await self.async_find_device() + except DeviceNotFound: + return self.async_abort(reason="device_not_found") + + # If all services still were not found, bail out with an error + if len(self.atv.all_identifiers) != expected_identifier_count: + return self.async_abort(reason="inconsistent_device") + + return await self.async_pair_next_protocol() + return self.async_show_form( - step_id="confirm", description_placeholders={"name": self.atv.name} + step_id="confirm", + description_placeholders={ + "name": self.atv.name, + "type": model_str(self.atv.device_info.model), + }, ) - async def async_begin_pairing(self): + async def async_pair_next_protocol(self): """Start pairing process for the next available protocol.""" - self.protocol = self._next_protocol_to_pair() - - # Dispose previous pairing sessions - if self.pairing is not None: - await self.pairing.close() - self.pairing = None + await self._async_cleanup() # Any more protocols to pair? Else bail out here - if not self.protocol: - await self.async_set_unique_id(self.atv.main_service().identifier) - return self._async_get_entry( - self.atv.main_service().protocol, - self.atv.name, - self.credentials, - self.atv.address, - ) + if not self.protocols_to_pair: + return await self._async_get_entry() + + self.protocol = self.protocols_to_pair.popleft() + service = self.atv.get_service(self.protocol) + + # Service requires a password + if service.requires_password: + return await self.async_step_password() + + # Figure out, depending on protocol, what kind of pairing is needed + if service.pairing == PairingRequirement.Unsupported: + _LOGGER.debug("%s does not support pairing", self.protocol) + return await self.async_pair_next_protocol() + if service.pairing == PairingRequirement.Disabled: + return await self.async_step_protocol_disabled() + if service.pairing == PairingRequirement.NotNeeded: + _LOGGER.debug("%s does not require pairing", self.protocol) + self.credentials[self.protocol.value] = None + return await self.async_pair_next_protocol() + + _LOGGER.debug("%s requires pairing", self.protocol) + + # Protocol specific arguments + pair_args = {} + if self.protocol == Protocol.DMAP: + pair_args["name"] = "Home Assistant" + pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass) # Initiate the pairing process abort_reason = None session = async_get_clientsession(self.hass) self.pairing = await pair( - self.atv, self.protocol, self.hass.loop, session=session + self.atv, self.protocol, self.hass.loop, session=session, **pair_args ) try: await self.pairing.begin() @@ -255,8 +397,7 @@ async def async_begin_pairing(self): abort_reason = "unknown" if abort_reason: - if self.pairing: - await self.pairing.close() + await self._async_cleanup() return self.async_abort(reason=abort_reason) # Choose step depending on if PIN is required from user or not @@ -265,6 +406,15 @@ async def async_begin_pairing(self): return await self.async_step_pair_no_pin() + async def async_step_protocol_disabled(self, user_input=None): + """Inform user that a protocol is disabled and cannot be paired.""" + if user_input is not None: + return await self.async_pair_next_protocol() + return self.async_show_form( + step_id="protocol_disabled", + description_placeholders={"protocol": protocol_str(self.protocol)}, + ) + async def async_step_pair_with_pin(self, user_input=None): """Handle pairing step where a PIN is required from the user.""" errors = {} @@ -273,12 +423,10 @@ async def async_step_pair_with_pin(self, user_input=None): self.pairing.pin(user_input[CONF_PIN]) await self.pairing.finish() self.credentials[self.protocol.value] = self.pairing.service.credentials - return await self.async_begin_pairing() + return await self.async_pair_next_protocol() except exceptions.PairingError: _LOGGER.exception("Authentication problem") errors["base"] = "invalid_auth" - except AbortFlow: - raise except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -296,7 +444,7 @@ async def async_step_pair_no_pin(self, user_input=None): await self.pairing.finish() if self.pairing.has_paired: self.credentials[self.protocol.value] = self.pairing.service.credentials - return await self.async_begin_pairing() + return await self.async_pair_next_protocol() await self.pairing.close() return self.async_abort(reason="device_did_not_pair") @@ -314,55 +462,57 @@ async def async_step_pair_no_pin(self, user_input=None): async def async_step_service_problem(self, user_input=None): """Inform user that a service will not be added.""" if user_input is not None: - self.credentials[self.protocol.value] = None - return await self.async_begin_pairing() + return await self.async_pair_next_protocol() return self.async_show_form( step_id="service_problem", description_placeholders={"protocol": protocol_str(self.protocol)}, ) - def _async_get_entry(self, protocol, name, credentials, address): - if not is_valid_credentials(credentials): - return self.async_abort(reason="invalid_config") - - data = { - CONF_PROTOCOL: protocol.value, - CONF_NAME: name, - CONF_CREDENTIALS: credentials, - CONF_ADDRESS: str(address), - } + async def async_step_password(self, user_input=None): + """Inform user that password is not supported.""" + if user_input is not None: + return await self.async_pair_next_protocol() - self._abort_if_unique_id_configured(reload_on_update=False, updates=data) + return self.async_show_form( + step_id="password", + description_placeholders={"protocol": protocol_str(self.protocol)}, + ) - return self.async_create_entry(title=name, data=data) + async def _async_cleanup(self): + """Clean up allocated resources.""" + if self.pairing is not None: + await self.pairing.close() + self.pairing = None - def _next_protocol_to_pair(self): - def _needs_pairing(protocol): - if self.atv.get_service(protocol) is None: - return False - return protocol.value not in self.credentials + async def _async_get_entry(self): + """Return config entry or update existing config entry.""" + # Abort if no protocols were paired + if not self.credentials: + return self.async_abort(reason="setup_failed") - for protocol in PROTOCOL_PRIORITY: - if _needs_pairing(protocol): - return protocol - return None + data = { + CONF_NAME: self.atv.name, + CONF_CREDENTIALS: self.credentials, + CONF_ADDRESS: str(self.atv.address), + CONF_IDENTIFIERS: self.atv_identifiers, + } - def _devices_str(self): - return ", ".join( - [ - f"`{atv.name} ({atv.address})`" - for atv in self.scan_result - if atv.identifier not in self._async_current_ids() - ] + existing_entry = await self.async_set_unique_id( + self.device_identifier, raise_on_progress=False ) - def _prefill_identifier(self): - # Return identifier (address) of one device that has not been paired with - for atv in self.scan_result: - if atv.identifier not in self._async_current_ids(): - return str(atv.address) - return "" + # If an existing config entry is updated, then this was a re-auth + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=data, unique_id=self.unique_id + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=self.atv.name, data=data) class AppleTVOptionsFlow(config_entries.OptionsFlow): diff --git a/homeassistant/components/apple_tv/const.py b/homeassistant/components/apple_tv/const.py index ac04cc1b93754..5fb169ec25976 100644 --- a/homeassistant/components/apple_tv/const.py +++ b/homeassistant/components/apple_tv/const.py @@ -2,10 +2,7 @@ DOMAIN = "apple_tv" -CONF_IDENTIFIER = "identifier" CONF_CREDENTIALS = "credentials" -CONF_CREDENTIALS_MRP = "mrp" -CONF_CREDENTIALS_DMAP = "dmap" -CONF_CREDENTIALS_AIRPLAY = "airplay" +CONF_IDENTIFIERS = "identifiers" CONF_START_OFF = "start_off" diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 963cbb9be33fa..9bc63773522a5 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -3,9 +3,19 @@ "name": "Apple TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apple_tv", - "requirements": ["pyatv==0.7.7"], - "zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."], - "after_dependencies": ["discovery"], + "requirements": ["pyatv==0.9.8"], + "zeroconf": [ + "_mediaremotetv._tcp.local.", + "_touch-able._tcp.local.", + "_appletv-v2._tcp.local.", + "_hscp._tcp.local.", + {"type":"_airplay._tcp.local.", "properties": {"model":"appletv*"}}, + {"type":"_airplay._tcp.local.", "properties": {"model":"audioaccessory*"}}, + {"type":"_airplay._tcp.local.", "properties": {"am":"airport*"}}, + {"type":"_raop._tcp.local.", "properties": {"am":"appletv*"}}, + {"type":"_raop._tcp.local.", "properties": {"am":"audioaccessory*"}}, + {"type":"_raop._tcp.local.", "properties": {"am":"airport*"}} + ], "codeowners": ["@postlund"], "iot_class": "local_push" } diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index a855fc6b53e03..77c97a1b54bc5 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -1,23 +1,28 @@ """Support for Apple TV media player.""" import logging +from pyatv import exceptions from pyatv.const import ( DeviceState, FeatureName, FeatureState, MediaType, + PowerState, RepeatState, ShuffleState, ) +from pyatv.helpers import is_streamable -from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity from homeassistant.components.media_player.const import ( + MEDIA_TYPE_APP, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, REPEAT_MODE_ALL, REPEAT_MODE_OFF, REPEAT_MODE_ONE, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -25,10 +30,12 @@ SUPPORT_PREVIOUS_TRACK, SUPPORT_REPEAT_SET, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( @@ -43,15 +50,22 @@ import homeassistant.util.dt as dt_util from . import AppleTVEntity +from .browse_media import build_app_list from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +# We always consider these to be supported +SUPPORT_BASE = SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +# This is the "optimistic" view of supported features and will be returned until the +# actual set of supported feature have been determined (will always be all or a subset +# of these). SUPPORT_APPLE_TV = ( - SUPPORT_TURN_ON - | SUPPORT_TURN_OFF + SUPPORT_BASE + | SUPPORT_BROWSE_MEDIA | SUPPORT_PLAY_MEDIA | SUPPORT_PAUSE | SUPPORT_PLAY @@ -59,12 +73,33 @@ | SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | SUPPORT_REPEAT_SET | SUPPORT_SHUFFLE_SET ) +# Map features in pyatv to Home Assistant +SUPPORT_FEATURE_MAPPING = { + FeatureName.PlayUrl: SUPPORT_PLAY_MEDIA, + FeatureName.StreamFile: SUPPORT_PLAY_MEDIA, + FeatureName.Pause: SUPPORT_PAUSE, + FeatureName.Play: SUPPORT_PLAY, + FeatureName.SetPosition: SUPPORT_SEEK, + FeatureName.Stop: SUPPORT_STOP, + FeatureName.Next: SUPPORT_NEXT_TRACK, + FeatureName.Previous: SUPPORT_PREVIOUS_TRACK, + FeatureName.VolumeUp: SUPPORT_VOLUME_STEP, + FeatureName.VolumeDown: SUPPORT_VOLUME_STEP, + FeatureName.SetRepeat: SUPPORT_REPEAT_SET, + FeatureName.SetShuffle: SUPPORT_SHUFFLE_SET, + FeatureName.SetVolume: SUPPORT_VOLUME_SET, + FeatureName.AppList: SUPPORT_BROWSE_MEDIA | SUPPORT_SELECT_SOURCE, + FeatureName.LaunchApp: SUPPORT_BROWSE_MEDIA | SUPPORT_SELECT_SOURCE, +} + + async def async_setup_entry(hass, config_entry, async_add_entities): """Load Apple TV media player based on a config entry.""" name = config_entry.data[CONF_NAME] @@ -75,22 +110,62 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Representation of an Apple TV media player.""" + _attr_supported_features = SUPPORT_APPLE_TV + def __init__(self, name, identifier, manager, **kwargs): """Initialize the Apple TV media player.""" super().__init__(name, identifier, manager, **kwargs) self._playing = None + self._app_list = {} @callback def async_device_connected(self, atv): """Handle when connection is made to device.""" - self.atv.push_updater.listener = self - self.atv.push_updater.start() + # NB: Do not use _is_feature_available here as it only works when playing + if self.atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): + self.atv.push_updater.listener = self + self.atv.push_updater.start() + + self._attr_supported_features = SUPPORT_BASE + + # Determine the actual set of supported features. All features not reported as + # "Unsupported" are considered here as the state of such a feature can never + # change after a connection has been established, i.e. an unsupported feature + # can never change to be supported. + all_features = self.atv.features.all_features() + for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items(): + feature_info = all_features.get(feature_name) + if feature_info and feature_info.state != FeatureState.Unsupported: + self._attr_supported_features |= support_flag + + # No need to schedule state update here as that will happen when the first + # metadata update arrives (sometime very soon after this callback returns) + + # Listen to power updates + self.atv.power.listener = self + + if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): + self.hass.create_task(self._update_app_list()) + + async def _update_app_list(self): + _LOGGER.debug("Updating app list") + try: + apps = await self.atv.apps.app_list() + except exceptions.NotSupportedError: + _LOGGER.error("Listing apps is not supported") + except exceptions.ProtocolError: + _LOGGER.exception("Failed to update app list") + else: + self._app_list = {app.name: app.identifier for app in apps} + self.async_write_ha_state() @callback def async_device_disconnected(self): """Handle when connection was lost to device.""" self.atv.push_updater.stop() self.atv.push_updater.listener = None + self.atv.power.listener = None + self._attr_supported_features = SUPPORT_APPLE_TV @property def state(self): @@ -99,6 +174,11 @@ def state(self): return None if self.atv is None: return STATE_OFF + if ( + self._is_feature_available(FeatureName.PowerState) + and self.atv.power.power_state == PowerState.Off + ): + return STATE_STANDBY if self._playing: state = self._playing.device_state if state in (DeviceState.Idle, DeviceState.Loading): @@ -123,6 +203,11 @@ def playstatus_error(self, _, exception): self._playing = None self.async_write_ha_state() + @callback + def powerstate_update(self, old_state: PowerState, new_state: PowerState): + """Update power state when it changes.""" + self.async_write_ha_state() + @property def app_id(self): """ID of the current running app.""" @@ -137,6 +222,11 @@ def app_name(self): return self.atv.metadata.app.name return None + @property + def source_list(self): + """List of available input sources.""" + return list(self._app_list.keys()) + @property def media_content_type(self): """Content type of current playing media.""" @@ -148,6 +238,20 @@ def media_content_type(self): }.get(self._playing.media_type) return None + @property + def media_content_id(self): + """Content ID of current playing media.""" + if self._playing: + return self._playing.content_identifier + return None + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + if self._is_feature_available(FeatureName.Volume): + return self.atv.audio.volume / 100.0 # from percent + return None + @property def media_duration(self): """Duration of current playing media in seconds.""" @@ -171,13 +275,30 @@ def media_position_updated_at(self): async def async_play_media(self, media_type, media_id, **kwargs): """Send the play_media command to the media player.""" - await self.atv.stream.play_url(media_id) + # If input (file) has a file format supported by pyatv, then stream it with + # RAOP. Otherwise try to play it with regular AirPlay. + if media_type == MEDIA_TYPE_APP: + await self.atv.apps.launch_app(media_id) + elif self._is_feature_available(FeatureName.StreamFile) and ( + await is_streamable(media_id) or media_type == MEDIA_TYPE_MUSIC + ): + _LOGGER.debug("Streaming %s via RAOP", media_id) + await self.atv.stream.stream_file(media_id) + elif self._is_feature_available(FeatureName.PlayUrl): + _LOGGER.debug("Playing %s via AirPlay", media_id) + await self.atv.stream.play_url(media_id) + else: + _LOGGER.error("Media streaming is not possible with current configuration") @property def media_image_hash(self): """Hash value for media image.""" state = self.state - if self._playing and state not in [None, STATE_OFF, STATE_IDLE]: + if ( + self._playing + and self._is_feature_available(FeatureName.Artwork) + and state not in [None, STATE_OFF, STATE_IDLE] + ): return self.atv.metadata.artwork_id return None @@ -212,6 +333,27 @@ def media_album_name(self): return self._playing.album return None + @property + def media_series_title(self): + """Title of series of current playing media, TV show only.""" + if self._is_feature_available(FeatureName.SeriesName): + return self._playing.series_name + return None + + @property + def media_season(self): + """Season of current playing media, TV show only.""" + if self._is_feature_available(FeatureName.SeasonNumber): + return str(self._playing.season_number) + return None + + @property + def media_episode(self): + """Episode of current playing media, TV show only.""" + if self._is_feature_available(FeatureName.EpisodeNumber): + return str(self._playing.episode_number) + return None + @property def repeat(self): """Return current repeat mode.""" @@ -229,31 +371,37 @@ def shuffle(self): return self._playing.shuffle != ShuffleState.Off return None - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_APPLE_TV - def _is_feature_available(self, feature): """Return if a feature is available.""" if self.atv and self._playing: return self.atv.features.in_state(FeatureState.Available, feature) return False + async def async_browse_media( + self, + media_content_type=None, + media_content_id=None, + ) -> BrowseMedia: + """Implement the websocket media browsing helper.""" + return build_app_list(self._app_list) + async def async_turn_on(self): """Turn the media player on.""" - await self.manager.connect() + if self._is_feature_available(FeatureName.TurnOn): + await self.atv.power.turn_on() async def async_turn_off(self): """Turn the media player off.""" - self._playing = None - await self.manager.disconnect() + if (self._is_feature_available(FeatureName.TurnOff)) and ( + not self._is_feature_available(FeatureName.PowerState) + or self.atv.power.power_state == PowerState.On + ): + await self.atv.power.turn_off() async def async_media_play_pause(self): """Pause media on media player.""" if self._playing: await self.atv.remote_control.play_pause() - return None async def async_media_play(self): """Play media.""" @@ -288,12 +436,18 @@ async def async_media_seek(self, position): async def async_volume_up(self): """Turn volume up for media player.""" if self.atv: - await self.atv.remote_control.volume_up() + await self.atv.audio.volume_up() async def async_volume_down(self): """Turn volume down for media player.""" if self.atv: - await self.atv.remote_control.volume_down() + await self.atv.audio.volume_down() + + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + if self.atv: + # pyatv expects volume in percent + await self.atv.audio.set_volume(volume * 100.0) async def async_set_repeat(self, repeat): """Set repeat mode.""" @@ -310,3 +464,8 @@ async def async_set_shuffle(self, shuffle): await self.atv.remote_control.set_shuffle( ShuffleState.Songs if shuffle else ShuffleState.Off ) + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + if app_id := self._app_list.get(source): + await self.atv.apps.launch_app(app_id) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 3d88bddcbc954..853ea29fcf53d 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -34,11 +34,6 @@ def is_on(self): """Return true if device is on.""" return self.atv is not None - @property - def should_poll(self): - """No polling needed for Apple TV.""" - return False - async def async_turn_on(self, **kwargs): """Turn the device on.""" await self.manager.connect() @@ -53,7 +48,7 @@ async def async_send_command(self, command, **kwargs): delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) if not self.is_on: - _LOGGER.error("Unable to send commands, not connected to %s", self._name) + _LOGGER.error("Unable to send commands, not connected to %s", self.name) return for _ in range(num_repeats): diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 00dd92cac89a8..f46f531865c0b 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -1,18 +1,17 @@ { - "title": "Apple TV", "config": { - "flow_title": "{name}", + "flow_title": "{name} ({type})", "step": { "user": { "title": "Setup a new Apple TV", - "description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add. If any devices were automatically found on your network, they are shown below.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.\n\n{devices}", + "description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.", "data": { "device_input": "Device" } }, "reconfigure": { "title": "Device reconfiguration", - "description": "This Apple TV is experiencing some connection difficulties and must be reconfigured." + "description": "Reconfigure this device to restore its functionality." }, "pair_with_pin": { "title": "Pairing", @@ -23,32 +22,42 @@ }, "pair_no_pin": { "title": "Pairing", - "description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your Apple TV to continue." + "description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your device to continue." + }, + "protocol_disabled": { + "title": "Pairing not possible", + "description": "Pairing is required for `{protocol}` but it is disabled on the device. Please review potential access restrictions (e.g. allow all devices on the local network to connect) on the device.\n\nYou may continue without pairing this protocol, but some functionality will be limited." + }, + "confirm": { + "title": "Confirm adding Apple TV", + "description": "You are about to add `{name}` of type `{type}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!" }, "service_problem": { "title": "Failed to add service", "description": "A problem occurred while pairing protocol `{protocol}`. It will be ignored." }, - "confirm": { - "title": "Confirm adding Apple TV", - "description": "You are about to add the Apple TV named `{name}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!" + "password": { + "title": "Password required", + "description": "A password is required by `{protocol}`. This is not yet supported, please disable password to continue." } }, "error": { "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_usable_service": "A device was found but could not identify any way to establish a connection to it. If you keep seeing this message, try specifying its IP address or restarting your Apple TV.", "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "device_did_not_pair": "No attempt to finish pairing process was made from the device.", "backoff": "Device does not accept pairing reqests at this time (you might have entered an invalid PIN code too many times), try again later.", - "invalid_config": "The configuration for this device is incomplete. Please try adding it again.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "setup_failed": "Failed to set up device.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "device_not_found": "Device was not found during discovery, please try adding it again.", + "inconsistent_device": "Expected protocols were not found during discovery. This normally indicates a problem with multicast DNS (Zeroconf). Please try adding the device again." } }, "options": { diff --git a/homeassistant/components/apple_tv/translations/ar.json b/homeassistant/components/apple_tv/translations/ar.json new file mode 100644 index 0000000000000..07ffa860c9c1d --- /dev/null +++ b/homeassistant/components/apple_tv/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "reconfigure": { + "description": "\u064a\u0648\u0627\u062c\u0647 Apple TV \u0647\u0630\u0627 \u0628\u0639\u0636 \u0627\u0644\u0635\u0639\u0648\u0628\u0627\u062a \u0641\u064a \u0627\u0644\u0627\u062a\u0635\u0627\u0644 \u0648\u064a\u062c\u0628 \u0625\u0639\u0627\u062f\u0629 \u062a\u0643\u0648\u064a\u0646\u0647.", + "title": "\u0625\u0639\u0627\u062f\u0629 \u062a\u0643\u0648\u064a\u0646 \u0627\u0644\u062c\u0647\u0627\u0632" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/bg.json b/homeassistant/components/apple_tv/translations/bg.json new file mode 100644 index 0000000000000..4629a79d152d9 --- /dev/null +++ b/homeassistant/components/apple_tv/translations/bg.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "already_configured_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name}", + "step": { + "pair_with_pin": { + "data": { + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434" + } + }, + "password": { + "title": "\u0418\u0437\u0438\u0441\u043a\u0432\u0430 \u0441\u0435 \u043f\u0430\u0440\u043e\u043b\u0430" + }, + "reconfigure": { + "title": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" + }, + "service_problem": { + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435\u0442\u043e \u043d\u0430 \u0443\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "user": { + "data": { + "device_input": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } + } + } + }, + "title": "Apple TV" +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/ca.json b/homeassistant/components/apple_tv/translations/ca.json index e9cd136720fe7..88c4905906764 100644 --- a/homeassistant/components/apple_tv/translations/ca.json +++ b/homeassistant/components/apple_tv/translations/ca.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", "already_configured_device": "El dispositiu ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "backoff": "En aquests moments el dispositiu no accepta sol\u00b7licituds de vinculaci\u00f3 (\u00e9s possible que hagis introdu\u00eft un codi PIN inv\u00e0lid massa vegades), torna-ho a provar m\u00e9s tard.", "device_did_not_pair": "No s'ha fet cap intent d'acabar el proc\u00e9s de vinculaci\u00f3 des del dispositiu.", + "device_not_found": "No s'ha trobat el dispositiu durant el descobriment, prova de tornar-lo a afegir.", + "inconsistent_device": "Els protocols esperats no s'han trobat durant el descobriment. Normalment aix\u00f2 indica un problema amb el DNS multicast (Zeroconf). Prova d'afegir el dispositiu de nou.", "invalid_config": "La configuraci\u00f3 d'aquest dispositiu no est\u00e0 completa. Intenta'l tornar a afegir.", "no_devices_found": "No s'han trobat dispositius a la xarxa", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", + "setup_failed": "No s'ha pogut configurar el dispositiu.", "unknown": "Error inesperat" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "S'ha trobat un dispositiu per\u00f2 no ha pogut identificar cap manera d'establir-hi una connexi\u00f3. Si continues veient aquest missatge, prova d'especificar-ne l'adre\u00e7a IP o reinicia l'Apple TV.", "unknown": "Error inesperat" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Est\u00e0s a punt d'afegir l'Apple TV amb nom \"{name}\" a Home Assistant.\n\n **Per completar el proc\u00e9s, \u00e9s possible que hagis d'introduir alguns codis PIN.** \n\n Tingues en compte que *no* pots apagar la teva Apple TV a trav\u00e9s d'aquesta integraci\u00f3. Nom\u00e9s es desactivar\u00e0 el reproductor de Home Assistant.", + "description": "Est\u00e0s a punt d'afegir `{name}` de tipus `{type}` a Home Assistant.\n\n **Per completar el proc\u00e9s, \u00e9s possible que hagis d'introduir alguns codis PIN.** \n\nTingues en compte que *no* pots apagar la teva Apple TV a trav\u00e9s d'aquesta integraci\u00f3. Nom\u00e9s es desactivar\u00e0 el reproductor de Home Assistant.", "title": "Confirma l'addici\u00f3 de l'Apple TV" }, "pair_no_pin": { - "description": "Vinculaci\u00f3 necess\u00e0ria amb el servei `{protocol}`. Per continuar, introdueix el PIN {pin} a la teva Apple TV.", + "description": "Vinculaci\u00f3 necess\u00e0ria pel servei `{protocol}`. Introdueix el PIN {pin} al teu dispositiu per continuar.", "title": "Vinculaci\u00f3" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "Amb el protocol \"{protocol}\" \u00e9s necess\u00e0ria la vinculaci\u00f3. Introdueix el codi PIN que es mostra en pantalla. Els zeros a l'inici, si n'hi ha, s'han d'ometre; per exemple: introdueix 123 si el codi mostrat \u00e9s 0123.", "title": "Vinculaci\u00f3" }, + "password": { + "description": "`{protocol}` necessita una contrasenya. Encara no est\u00e0 suportada, desactiva la contrasenya per continuar.", + "title": "Contrasenya necess\u00e0ria" + }, + "protocol_disabled": { + "description": "La vinculaci\u00f3 \u00e9s necess\u00e0ria per a `{protocol}` per\u00f2 est\u00e0 desactivada al dispositiu. Revisa les possibles restriccions d'acc\u00e9s del dispositiu (per exemple, permet que tots els dispositius a la xarxa local es puguin connectar). \n\nPots continuar sense vincular aquest protocol, per\u00f2 algunes funcionalitats quedaran limitades.", + "title": "No es pot vincular" + }, "reconfigure": { - "description": "Aquesta Apple TV est\u00e0 tenint problemes de connexi\u00f3 i s'ha de tornar a configurar.", + "description": "Torna a configurar aquest dispositiu per restablir el seu funcionament.", "title": "Reconfiguraci\u00f3 de dispositiu" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Dispositiu" }, - "description": "Comen\u00e7a introduint el nom del dispositiu (per exemple, cuina o dormitori) o l'adre\u00e7a IP de l'Apple TV que vulguis afegir. Si autom\u00e0ticament es troben dispositius a la teva xarxa, es mostra a continuaci\u00f3. \n\n Si no veus el teu dispositiu o tens problemes, prova d'especificar l'adre\u00e7a IP del dispositiu. \n\n {devices}", + "description": "Comen\u00e7a introduint el nom del dispositiu (per exemple, cuina o dormitori) o l'adre\u00e7a IP de l'Apple TV que vulguis afegir.\n\n Si no veus el teu dispositiu o tens problemes, prova d'especificar l'adre\u00e7a IP del dispositiu.", "title": "Configuraci\u00f3 d'una nova Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/de.json b/homeassistant/components/apple_tv/translations/de.json index 464bad99d5a21..d8eff460bd4da 100644 --- a/homeassistant/components/apple_tv/translations/de.json +++ b/homeassistant/components/apple_tv/translations/de.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_configured_device": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "backoff": "Das Ger\u00e4t akzeptiert derzeit keine Kopplungsanfragen (M\u00f6glicherweise wurde zu oft ein ung\u00fcltiger PIN-Code eingegeben), versuche es sp\u00e4ter erneut.", "device_did_not_pair": "Es wurde kein Versuch unternommen, den Kopplungsvorgang vom Ger\u00e4t aus abzuschlie\u00dfen.", + "device_not_found": "Das Ger\u00e4t wurde bei der Erkennung nicht gefunden. Bitte versuche es erneut hinzuzuf\u00fcgen.", + "inconsistent_device": "Die erwarteten Protokolle wurden bei der Erkennung nicht gefunden. Dies deutet normalerweise auf ein Problem mit Multicast-DNS (Zeroconf) hin. Bitte versuche das Ger\u00e4t erneut hinzuzuf\u00fcgen.", "invalid_config": "Die Konfiguration f\u00fcr dieses Ger\u00e4t ist unvollst\u00e4ndig. Bitte versuche, es erneut hinzuzuf\u00fcgen.", "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "setup_failed": "Fehler beim Einrichten des Ger\u00e4ts.", "unknown": "Unerwarteter Fehler" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "Es wurde ein Ger\u00e4t gefunden, aber es konnte keine M\u00f6glichkeit gefunden werden, eine Verbindung zu diesem Ger\u00e4t herzustellen. Wenn diese Meldung weiterhin erscheint, versuche, die IP-Adresse anzugeben oder den Apple TV neu zu starten.", "unknown": "Unerwarteter Fehler" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Es wird der Apple TV mit dem Namen \" {name} \" zu Home Assistant hinzugef\u00fcgt. \n\n ** Um den Vorgang abzuschlie\u00dfen, m\u00fcssen m\u00f6glicherweise mehrere PIN-Codes eingegeben werden. ** \n\n Bitte beachte, dass der Apple TV mit dieser Integration * nicht * ausgeschalten werden kann. Nur der Media Player in Home Assistant wird ausgeschaltet!", + "description": "Du bist dabei, `{name}` vom Typ `{type}` zu Home Assistant hinzuzuf\u00fcgen. \n\n **Um den Vorgang abzuschlie\u00dfen, musst du m\u00f6glicherweise mehrere PIN-Codes eingeben.** \n\n Bitte beachte, dass du dein Apple TV mit dieser Integration *nicht* ausschalten kannst. Nur der Mediaplayer in Home Assistant wird ausgeschaltet!", "title": "Best\u00e4tige das Hinzuf\u00fcgen vom Apple TV" }, "pair_no_pin": { - "description": "F\u00fcr den Dienst `{protocol}` ist eine Kopplung erforderlich. Bitte gebe die PIN {pin} am Apple TV ein, um fortzufahren.", + "description": "F\u00fcr den Dienst `{protocol}` ist eine Kopplung erforderlich. Bitte gib die PIN {pin} auf deinem Ger\u00e4t ein, um fortzufahren.", "title": "Kopplung" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "F\u00fcr das Protokoll `{protocol}` ist eine Kopplung erforderlich. Bitte gebe den auf dem Bildschirm angezeigten PIN-Code ein. F\u00fchrende Nullen m\u00fcssen weggelassen werden, d.h. gebe 123 ein, wenn der angezeigte Code 0123 lautet.", "title": "Kopplung" }, + "password": { + "description": "Ein Passwort ist f\u00fcr `{protocol}` erforderlich. Dies wird noch nicht unterst\u00fctzt, bitte deaktiviere das Passwort, um fortzufahren.", + "title": "Passwort erforderlich" + }, + "protocol_disabled": { + "description": "Die Kopplung ist f\u00fcr `{protocol}` erforderlich, aber auf dem Ger\u00e4t deaktiviert. Bitte \u00fcberpr\u00fcfe m\u00f6gliche Zugriffsbeschr\u00e4nkungen (z. B. Verbindung aller Ger\u00e4te im lokalen Netzwerk zulassen) auf dem Ger\u00e4t. \n\nDu kannst fortfahren, ohne dieses Protokoll zu koppeln, aber einige Funktionen sind eingeschr\u00e4nkt.", + "title": "Kopplung nicht m\u00f6glich" + }, "reconfigure": { - "description": "Dieser Apple TV hat Verbindungsprobleme und muss neu konfiguriert werden.", + "description": "Konfiguriere dieses Ger\u00e4t neu, um seine Funktionalit\u00e4t wiederherzustellen.", "title": "Ger\u00e4teneukonfiguration" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Ger\u00e4t" }, - "description": "Gebe zun\u00e4chst den Ger\u00e4tenamen (z. B. K\u00fcche oder Schlafzimmer) oder die IP-Adresse des Apple TV ein, der hinzugef\u00fcgt werden soll. Wenn Ger\u00e4te automatisch im Netzwerk gefunden wurden, werden sie unten angezeigt. \n\nWenn das Ger\u00e4t nicht sichtbar ist oder Probleme auftreten, gebe die IP-Adresse des Ger\u00e4ts an. \n\n{devices}", + "description": "Gib zun\u00e4chst den Ger\u00e4tenamen (z. B. K\u00fcche oder Schlafzimmer) oder die IP-Adresse des Apple TV ein, den du hinzuf\u00fcgen m\u00f6chtest.\n\nWenn du dein Ger\u00e4t nicht sehen kannst oder Probleme auftreten, versuche die IP-Adresse des Ger\u00e4ts einzugeben.", "title": "Neuen Apple TV einrichten" } } diff --git a/homeassistant/components/apple_tv/translations/en.json b/homeassistant/components/apple_tv/translations/en.json index 304a43363a03c..4d4d1bb167988 100644 --- a/homeassistant/components/apple_tv/translations/en.json +++ b/homeassistant/components/apple_tv/translations/en.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Device is already configured", "already_configured_device": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", "backoff": "Device does not accept pairing reqests at this time (you might have entered an invalid PIN code too many times), try again later.", "device_did_not_pair": "No attempt to finish pairing process was made from the device.", + "device_not_found": "Device was not found during discovery, please try adding it again.", + "inconsistent_device": "Expected protocols were not found during discovery. This normally indicates a problem with multicast DNS (Zeroconf). Please try adding the device again.", "invalid_config": "The configuration for this device is incomplete. Please try adding it again.", "no_devices_found": "No devices found on the network", + "reauth_successful": "Re-authentication was successful", + "setup_failed": "Failed to set up device.", "unknown": "Unexpected error" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "A device was found but could not identify any way to establish a connection to it. If you keep seeing this message, try specifying its IP address or restarting your Apple TV.", "unknown": "Unexpected error" }, - "flow_title": "{name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "You are about to add the Apple TV named `{name}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!", + "description": "You are about to add `{name}` of type `{type}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!", "title": "Confirm adding Apple TV" }, "pair_no_pin": { - "description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your Apple TV to continue.", + "description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your device to continue.", "title": "Pairing" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen. Leading zeros shall be omitted, i.e. enter 123 if the displayed code is 0123.", "title": "Pairing" }, + "password": { + "description": "A password is required by `{protocol}`. This is not yet supported, please disable password to continue.", + "title": "Password required" + }, + "protocol_disabled": { + "description": "Pairing is required for `{protocol}` but it is disabled on the device. Please review potential access restrictions (e.g. allow all devices on the local network to connect) on the device.\n\nYou may continue without pairing this protocol, but some functionality will be limited.", + "title": "Pairing not possible" + }, "reconfigure": { - "description": "This Apple TV is experiencing some connection difficulties and must be reconfigured.", + "description": "Reconfigure this device to restore its functionality.", "title": "Device reconfiguration" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Device" }, - "description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add. If any devices were automatically found on your network, they are shown below.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.\n\n{devices}", + "description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.", "title": "Setup a new Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/es-419.json b/homeassistant/components/apple_tv/translations/es-419.json new file mode 100644 index 0000000000000..75e6fb43ff23f --- /dev/null +++ b/homeassistant/components/apple_tv/translations/es-419.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "backoff": "El dispositivo no acepta solicitudes de emparejamiento en este momento (es posible que haya ingresado un c\u00f3digo PIN no v\u00e1lido demasiadas veces), vuelva a intentarlo m\u00e1s tarde.", + "device_did_not_pair": "No se intent\u00f3 finalizar el proceso de emparejamiento desde el dispositivo.", + "invalid_config": "La configuraci\u00f3n de este dispositivo est\u00e1 incompleta. Intente agregarlo nuevamente." + }, + "error": { + "no_usable_service": "Se encontr\u00f3 un dispositivo, pero no se pudo identificar ninguna forma de establecer una conexi\u00f3n con \u00e9l. Si sigue viendo este mensaje, intente especificar su direcci\u00f3n IP o reinicie su Apple TV." + }, + "step": { + "confirm": { + "description": "Est\u00e1 a punto de agregar el Apple TV llamado `{name} ` a Home Assistant. \n\n** Para completar el proceso, es posible que deba ingresar varios c\u00f3digos PIN. ** \n\nTenga en cuenta que *no* podr\u00e1 apagar su Apple TV con esta integraci\u00f3n. \u00a1Solo se apagar\u00e1 el reproductor multimedia en Home Assistant!", + "title": "Confirma la adici\u00f3n de Apple TV" + }, + "pair_no_pin": { + "description": "El emparejamiento es necesario para el servicio `{protocol}`. Ingresa el PIN {pin} en tu Apple TV para continuar.", + "title": "Emparejamiento" + }, + "pair_with_pin": { + "description": "El emparejamiento es necesario para el `{protocol}`. Ingrese el c\u00f3digo PIN que se muestra en la pantalla. Se omitir\u00e1n los ceros iniciales, es decir, introduzca 123 si el c\u00f3digo que se muestra es 0123.", + "title": "Emparejamiento" + }, + "reconfigure": { + "description": "Este Apple TV est\u00e1 experimentando algunas dificultades de conexi\u00f3n y debe reconfigurarse.", + "title": "Reconfiguraci\u00f3n del dispositivo" + }, + "service_problem": { + "description": "Ocurri\u00f3 un problema al emparejar el protocolo \" {protocol} \". Ser\u00e1 ignorado.", + "title": "No se pudo agregar el servicio" + }, + "user": { + "data": { + "device_input": "Dispositivo" + }, + "description": "Comience ingresando el nombre del dispositivo (por ejemplo, cocina o dormitorio) o la direcci\u00f3n IP del Apple TV que desea agregar. Si se encontraron dispositivos autom\u00e1ticamente en su red, se muestran a continuaci\u00f3n. \n\nSi no puede ver su dispositivo o experimenta alg\u00fan problema, intente especificar la direcci\u00f3n IP del dispositivo. \n\n{devices}", + "title": "Configurar un nuevo Apple TV" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "start_off": "No encienda el dispositivo al iniciar Home Assistant" + }, + "description": "Configurar los ajustes generales del dispositivo" + } + } + }, + "title": "Apple TV" +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/et.json b/homeassistant/components/apple_tv/translations/et.json index 597c4756907f3..fa660662d8b02 100644 --- a/homeassistant/components/apple_tv/translations/et.json +++ b/homeassistant/components/apple_tv/translations/et.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "already_configured_device": "Seade on juba h\u00e4\u00e4lestatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", "backoff": "Seade ei aktsepteeri praegu sidumisn\u00f5udeid (v\u00f5ib-olla oled liiga palju kordi vale PIN-koodi sisestanud), proovi hiljem uuesti.", "device_did_not_pair": "Seade ei \u00fcritatud sidumisprotsessi l\u00f5pule viia.", + "device_not_found": "Seadet avastamise ajal ei leitud, proovi seda uuesti lisada.", + "inconsistent_device": "Eeldatavaid protokolle avastamise ajal ei leitud. See n\u00e4itab tavaliselt probleemi multcast DNS-iga (Zeroconf). Proovi seade uuesti lisada.", "invalid_config": "Selle seadme s\u00e4tted on puudulikud. Proovi see uuesti lisada.", "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "setup_failed": "Seadme h\u00e4\u00e4lestamine nurjus.", "unknown": "Ootamatu t\u00f5rge" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "Leiti seade kuid ei suudetud tuvastada moodust \u00fchenduse loomiseks. Kui n\u00e4ed seda teadet pidevalt, proovi m\u00e4\u00e4rata seadme IP-aadress v\u00f5i taask\u00e4ivita Apple TV.", "unknown": "Ootamatu t\u00f5rge" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Oled Home Assistantile lisamas Apple TV-d nimega {name}.\n\n**Protsessi l\u00f5puleviimiseks pead v\u00f5ib-olla sisestama mitu PIN-koodi.**\n\nPane t\u00e4hele, et selle sidumisega * ei saa * v\u00e4lja l\u00fclitada oma Apple TV-d. Ainult Home Assistant-i meediam\u00e4ngija l\u00fclitub v\u00e4lja!", + "description": "Oled Home Assistantile lisamas `{type}` seadet nimega {name}.\n\n**Protsessi l\u00f5puleviimiseks pead v\u00f5ib-olla sisestama mitu PIN-koodi.**\n\nPane t\u00e4hele, et selle sidumisega * ei saa * v\u00e4lja l\u00fclitada oma Apple TV-d. Ainult Home Assistant-i meediam\u00e4ngija l\u00fclitub v\u00e4lja!", "title": "Kinnita Apple TV lisamine" }, "pair_no_pin": { - "description": "Teenuse {protocol} sidumine on vajalik. J\u00e4tkamiseks sisesta oma Apple TV-s PIN-kood {pin} .", + "description": "Teenuse {protocol} sidumine on vajalik. J\u00e4tkamiseks sisesta oma seadmes PIN-kood {pin} .", "title": "Sidumine" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "Vajalik on protokolli {protocol} sidumine. Sisesta ekraanil kuvatav PIN-kood. Alguse nullid j\u00e4etakse v\u00e4lja, st. sisesta 123, kui kuvatav kood on 0123.", "title": "Sidumine" }, + "password": { + "description": "`{protocol}` vajab salas\u00f5na. See ei ole veel toetatud, j\u00e4tkamiseks l\u00fclita salas\u00f5na v\u00e4lja.", + "title": "Salas\u00f5na on n\u00f5utav" + }, + "protocol_disabled": { + "description": "`{protocol}` jaoks on vajalik sidumine kuid see on seadmes keelatud. Vaata \u00fcle seadme v\u00f5imalikud juurdep\u00e4\u00e4supiirangud (nt luba k\u00f5igil kohtv\u00f5rgu seadmetel \u00fchenduda). \n\n V\u00f5id j\u00e4tkata ilma seda protokolli sidumata, kuid m\u00f5ned funktsioonid on piiratud.", + "title": "Sidumine pole v\u00f5imalik" + }, "reconfigure": { - "description": "Sellel Apple TV-l on \u00fchendusprobleemid ja see tuleb uuesti seadistada.", + "description": "Seadme funktsionaalsuse taastamiseks konfigureeri see seade \u00fcmber.", "title": "Seadme \u00fcmberseadistamine" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Seade" }, - "description": "Alustuseks sisesta lisatava Apple TV seadme nimi (nt K\u00f6\u00f6k v\u00f5i Magamistuba) v\u00f5i IP-aadress. Kui m\u00f5ni seade leiti teie v\u00f5rgust automaatselt kuvatakse see allpool. \n\n Kui ei n\u00e4e oma seadet v\u00f5i on probleeme, proovi m\u00e4\u00e4rata seadme IP-aadress. \n\n {devices}", + "description": "Alustuseks sisesta lisatava Apple TV seadme nimi (nt K\u00f6\u00f6k v\u00f5i Magamistuba) v\u00f5i IP-aadress. Kui m\u00f5ni seade leiti teie v\u00f5rgust automaatselt kuvatakse see allpool. \n\n Kui ei n\u00e4e oma seadet v\u00f5i on probleeme, proovi m\u00e4\u00e4rata seadme IP-aadress.", "title": "Seadista uus Apple TV sidumine" } } diff --git a/homeassistant/components/apple_tv/translations/fr.json b/homeassistant/components/apple_tv/translations/fr.json index e1a719b31c90b..23fed83cfc483 100644 --- a/homeassistant/components/apple_tv/translations/fr.json +++ b/homeassistant/components/apple_tv/translations/fr.json @@ -1,29 +1,34 @@ { "config": { "abort": { - "already_configured_device": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_configured_device": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "backoff": "L'appareil n'accepte pas les demandes d'appariement pour le moment (vous avez peut-\u00eatre saisi un code PIN non valide trop de fois), r\u00e9essayez plus tard.", "device_did_not_pair": "Aucune tentative pour terminer l'appairage n'a \u00e9t\u00e9 effectu\u00e9e \u00e0 partir de l'appareil.", + "device_not_found": "L'appareil n'a pas \u00e9t\u00e9 trouv\u00e9 lors de la d\u00e9couverte, veuillez r\u00e9essayer de l'ajouter.", + "inconsistent_device": "Les protocoles attendus n'ont pas \u00e9t\u00e9 trouv\u00e9s lors de la d\u00e9couverte. Cela indique normalement un probl\u00e8me avec le DNS multicast (Zeroconf). Veuillez r\u00e9essayer d'ajouter l'appareil.", "invalid_config": "La configuration de cet appareil est incompl\u00e8te. Veuillez r\u00e9essayer de l'ajouter.", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "setup_failed": "\u00c9chec de la configuration de l'appareil.", "unknown": "Erreur inattendue" }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "invalid_auth": "Autentification invalide", - "no_devices_found": "Aucun appareil d\u00e9tect\u00e9 sur le r\u00e9seau", + "invalid_auth": "Authentification invalide", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "no_usable_service": "Un dispositif a \u00e9t\u00e9 trouv\u00e9, mais aucun moyen d\u2019\u00e9tablir un lien avec lui. Si vous continuez \u00e0 voir ce message, essayez de sp\u00e9cifier son adresse IP ou de red\u00e9marrer votre Apple TV.", - "unknown": "Erreur innatendue" + "unknown": "Erreur inattendue" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Vous \u00eates sur le point d'ajouter l'Apple TV nomm\u00e9e \u00ab {name} \u00bb \u00e0 Home Assistant. \n\n **Pour terminer le processus, vous devrez peut-\u00eatre saisir plusieurs codes PIN.** \n\n Veuillez noter que vous ne pourrez *pas* \u00e9teindre votre Apple TV avec cette int\u00e9gration. Seul le lecteur multim\u00e9dia de Home Assistant s'\u00e9teint!", + "description": "Vous \u00eates sur le point d'ajouter ` {name} ` de type ` {type} ` \u00e0 Home Assistant. \n\n **Pour terminer le processus, vous devrez peut-\u00eatre saisir plusieurs codes PIN.** \n\n Veuillez noter que vous ne pourrez *pas* \u00e9teindre votre Apple TV avec cette int\u00e9gration. Seul le lecteur multim\u00e9dia de Home Assistant s'\u00e9teindra !", "title": "Confirmer l'ajout d'Apple TV" }, "pair_no_pin": { - "description": "L'appairage est requis pour le service ` {protocol} `. Veuillez saisir le code PIN {pin} sur votre Apple TV pour continuer.", + "description": "L'appariement est requis pour le service ` {protocol} `. Veuillez saisir le code PIN {pin} sur votre appareil pour continuer.", "title": "Appairage" }, "pair_with_pin": { @@ -33,6 +38,14 @@ "description": "L'appairage est requis pour le protocole `{protocol}`. Veuillez saisir le code PIN affich\u00e9 \u00e0 l'\u00e9cran. Les z\u00e9ros doivent \u00eatre omis, c'est-\u00e0-dire entrer 123 si le code affich\u00e9 est 0123.", "title": "Appairage" }, + "password": { + "description": "Un mot de passe est requis par ` {protocol} `. Ceci n'est pas encore pris en charge, veuillez d\u00e9sactiver le mot de passe pour continuer.", + "title": "Mot de passe requis" + }, + "protocol_disabled": { + "description": "L'appairage est requis pour ` {protocol} ` mais il est d\u00e9sactiv\u00e9 sur l'appareil. Veuillez examiner les restrictions d'acc\u00e8s potentielles (par exemple, autoriser tous les appareils du r\u00e9seau local \u00e0 se connecter) sur l'appareil. \n\n Vous pouvez continuer sans appairer ce protocole, mais certaines fonctionnalit\u00e9s seront limit\u00e9es.", + "title": "Appairage impossible" + }, "reconfigure": { "description": "Cette Apple TV rencontre des difficult\u00e9s de connexion et doit \u00eatre reconfigur\u00e9e.", "title": "Reconfiguration de l'appareil" @@ -45,7 +58,7 @@ "data": { "device_input": "Appareil" }, - "description": "Commencez par entrer le nom de l'appareil (par exemple, Cuisine ou Chambre) ou l'adresse IP de l'Apple TV que vous souhaitez ajouter. Si des appareils ont \u00e9t\u00e9 d\u00e9tect\u00e9s automatiquement sur votre r\u00e9seau, ils sont affich\u00e9s ci-dessous. \n\n Si vous ne voyez pas votre appareil ou rencontrez des probl\u00e8mes, essayez de sp\u00e9cifier l'adresse IP de l'appareil. \n\n {devices}", + "description": "Commencez par saisir le nom de l'appareil (par exemple, cuisine ou chambre) ou l'adresse IP de l'Apple TV que vous souhaitez ajouter. \n\n Si vous ne pouvez pas voir votre appareil ou rencontrez des probl\u00e8mes, essayez de sp\u00e9cifier l'adresse IP de l'appareil.", "title": "Configurer une nouvelle Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/he.json b/homeassistant/components/apple_tv/translations/he.json new file mode 100644 index 0000000000000..209cd7069f0ec --- /dev/null +++ b/homeassistant/components/apple_tv/translations/he.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_configured_device": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}", + "step": { + "pair_with_pin": { + "data": { + "pin": "\u05e7\u05d5\u05d3 PIN" + } + }, + "user": { + "data": { + "device_input": "\u05d4\u05ea\u05e7\u05df" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/hu.json b/homeassistant/components/apple_tv/translations/hu.json index 63bf29a73f1aa..f76063c5eeb7c 100644 --- a/homeassistant/components/apple_tv/translations/hu.json +++ b/homeassistant/components/apple_tv/translations/hu.json @@ -1,44 +1,77 @@ { "config": { "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", + "backoff": "Az eszk\u00f6z jelenleg nem fogadja el a p\u00e1ros\u00edt\u00e1si k\u00e9relmeket (lehet, hogy t\u00fal sokszor adott meg \u00e9rv\u00e9nytelen PIN-k\u00f3dot), pr\u00f3b\u00e1lkozzon \u00fajra k\u00e9s\u0151bb.", + "device_did_not_pair": "A p\u00e1ros\u00edt\u00e1s folyamat\u00e1t az eszk\u00f6zr\u0151l nem pr\u00f3b\u00e1lt\u00e1k befejezni.", + "device_not_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a felder\u00edt\u00e9s sor\u00e1n, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra hozz\u00e1adni.", + "inconsistent_device": "Az elv\u00e1rt protokollok nem tal\u00e1lhat\u00f3k a felder\u00edt\u00e9s sor\u00e1n. Ez \u00e1ltal\u00e1ban a multicast DNS (Zeroconf) probl\u00e9m\u00e1j\u00e1t jelzi. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra hozz\u00e1adni az eszk\u00f6zt.", + "invalid_config": "Az eszk\u00f6z konfigur\u00e1l\u00e1sa nem teljes. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra hozz\u00e1adni.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", + "setup_failed": "Az eszk\u00f6z be\u00e1ll\u00edt\u00e1sa sikertelen.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "no_usable_service": "Tal\u00e1ltunk egy eszk\u00f6zt, de nem tudtuk azonos\u00edtani, hogyan lehetne kapcsolatot l\u00e9tes\u00edteni vele. Ha tov\u00e1bbra is ezt az \u00fczenetet l\u00e1tja, pr\u00f3b\u00e1lja meg megadni az IP-c\u00edm\u00e9t, vagy ind\u00edtsa \u00fajra az Apple TV-t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name}", "step": { "confirm": { + "description": "Arra k\u00e9sz\u00fcl, hogy felvegye {name} nev\u0171 Apple TV-t a Home Assistant p\u00e9ld\u00e1ny\u00e1ba. \n\n ** A folyamat befejez\u00e9s\u00e9hez t\u00f6bb PIN-k\u00f3dot kell megadnia. ** \n\nFelh\u00edvjuk figyelm\u00e9t, hogy ezzel az integr\u00e1ci\u00f3val *nem* fogja tudni kikapcsolni az Apple TV-t. Csak a Home Assistant saj\u00e1t m\u00e9dialej\u00e1tsz\u00f3ja kapcsol ki!", "title": "Apple TV sikeresen hozz\u00e1adva" }, "pair_no_pin": { + "description": "P\u00e1ros\u00edt\u00e1sra van sz\u00fcks\u00e9g a {protocol} szolg\u00e1ltat\u00e1shoz. A folytat\u00e1shoz k\u00e9rj\u00fck, \u00edrja be az Apple TV {pin}-t.", "title": "P\u00e1ros\u00edt\u00e1s" }, "pair_with_pin": { "data": { "pin": "PIN-k\u00f3d" }, + "description": "P\u00e1ros\u00edt\u00e1sra van sz\u00fcks\u00e9g a {protocol} protokollhoz. K\u00e9rj\u00fck, adja meg a k\u00e9perny\u0151n megjelen\u0151 PIN-k\u00f3dot. A vezet\u0151 null\u00e1kat el kell hagyni, pl. \u00edrja be a 123 \u00e9rt\u00e9ket, ha a megjelen\u00edtett k\u00f3d 0123.", "title": "P\u00e1ros\u00edt\u00e1s" }, + "password": { + "description": "`{protocol}` jelsz\u00f3t ig\u00e9nyel. Ez m\u00e9g nem t\u00e1mogatott, k\u00e9rj\u00fck, a folytat\u00e1shoz tiltsa le a jelsz\u00f3t.", + "title": "Jelsz\u00f3 sz\u00fcks\u00e9ges" + }, + "protocol_disabled": { + "description": "P\u00e1ros\u00edt\u00e1s sz\u00fcks\u00e9ges a `{protokoll}` miatt, de az eszk\u00f6z\u00f6n le van tiltva. K\u00e9rj\u00fck, vizsg\u00e1lja meg az eszk\u00f6z\u00f6n az esetleges hozz\u00e1f\u00e9r\u00e9si korl\u00e1toz\u00e1sokat (pl. enged\u00e9lyezze a helyi h\u00e1l\u00f3zaton l\u00e9v\u0151 \u00f6sszes eszk\u00f6z csatlakoztat\u00e1s\u00e1t).\n\nFolytathatja a protokoll p\u00e1ros\u00edt\u00e1sa n\u00e9lk\u00fcl is, de bizonyos funkci\u00f3k korl\u00e1tozottak lesznek.", + "title": "A p\u00e1ros\u00edt\u00e1s nem lehets\u00e9ges" + }, "reconfigure": { + "description": "Ez az Apple TV csatlakoz\u00e1si neh\u00e9zs\u00e9gekkel k\u00fczd, ez\u00e9rt \u00fajra kell konfigur\u00e1lni.", "title": "Eszk\u00f6z \u00fajrakonfigur\u00e1l\u00e1sa" }, "service_problem": { + "description": "Hiba t\u00f6rt\u00e9nt a `{protocol}` protokoll p\u00e1ros\u00edt\u00e1sakor. Figyelmen k\u00edv\u00fcl lesz hagyva.", "title": "Nem siker\u00fclt hozz\u00e1adni a szolg\u00e1ltat\u00e1st" }, "user": { "data": { "device_input": "Eszk\u00f6z" }, + "description": "El\u0151sz\u00f6r \u00edrja be a hozz\u00e1adni k\u00edv\u00e1nt Apple TV eszk\u00f6znev\u00e9t (pl. Konyha vagy H\u00e1l\u00f3szoba) vagy IP-c\u00edm\u00e9t. Ha valamilyen eszk\u00f6zt automatikusan tal\u00e1ltak a h\u00e1l\u00f3zat\u00e1n, az al\u00e1bb l\u00e1that\u00f3. \n\nHa nem l\u00e1tja eszk\u00f6z\u00e9t, vagy b\u00e1rmilyen probl\u00e9m\u00e1t tapasztal, pr\u00f3b\u00e1lja meg megadni az eszk\u00f6z IP-c\u00edm\u00e9t. \n\n {devices}", "title": "\u00daj Apple TV be\u00e1ll\u00edt\u00e1sa" } } }, + "options": { + "step": { + "init": { + "data": { + "start_off": "A Home Assistant ind\u00edt\u00e1sakor ne kapcsolja be az eszk\u00f6zt" + }, + "description": "Konfigur\u00e1lja az eszk\u00f6z \u00e1ltal\u00e1nos be\u00e1ll\u00edt\u00e1sait" + } + } + }, "title": "Apple TV" } \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/id.json b/homeassistant/components/apple_tv/translations/id.json index 5646b4982422c..8a978eca737c5 100644 --- a/homeassistant/components/apple_tv/translations/id.json +++ b/homeassistant/components/apple_tv/translations/id.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", "already_configured_device": "Perangkat sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", "backoff": "Perangkat tidak bisa menerima permintaan pemasangan saat ini (Anda mungkin telah berulang kali memasukkan kode PIN yang salah). Coba lagi nanti.", "device_did_not_pair": "Tidak ada upaya untuk menyelesaikan proses pemasangan dari sisi perangkat.", + "device_not_found": "Perangkat tidak ditemukan selama penemuan, coba tambahkan lagi.", + "inconsistent_device": "Protokol yang diharapkan tidak ditemukan selama penemuan. Ini biasanya terjadi karena masalah dengan DNS multicast (Zeroconf). Coba tambahkan perangkat lagi.", "invalid_config": "Konfigurasi untuk perangkat ini tidak lengkap. Coba tambahkan lagi.", "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "reauth_successful": "Autentikasi ulang berhasil", + "setup_failed": "Gagal menyiapkan perangkat.", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { @@ -16,25 +21,33 @@ "no_usable_service": "Perangkat ditemukan tetapi kami tidak dapat mengidentifikasi berbagai cara untuk membuat koneksi ke perangkat tersebut. Jika Anda terus melihat pesan ini, coba tentukan alamat IP-nya atau mulai ulang Apple TV Anda.", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Anda akan menambahkan Apple TV bernama `{name}` ke Home Assistant.\n\n** Untuk menyelesaikan proses, Anda mungkin harus memasukkan kode PIN beberapa kali.**\n\nPerhatikan bahwa Anda *tidak* akan dapat mematikan Apple TV dengan integrasi ini. Hanya pemutar media di Home Assistant yang akan dimatikan!", + "description": "Anda akan menambahkan Apple TV bernama `{name}` dengan jenis `{type}` ke Home Assistant.\n\n** Untuk menyelesaikan proses, Anda mungkin harus memasukkan kode PIN beberapa kali.**\n\nPerhatikan bahwa Anda *tidak* akan dapat mematikan Apple TV dengan integrasi ini. Hanya pemutar media di Home Assistant yang akan dimatikan!", "title": "Konfirmasikan menambahkan Apple TV" }, "pair_no_pin": { - "description": "Pemasangan diperlukan untuk layanan `{protocol}`. Masukkan PIN {pin} di Apple TV untuk melanjutkan.", - "title": "Memasangkan" + "description": "Pemasangan diperlukan untuk layanan `{protocol}`. Masukkan PIN {pin} di perangkat Anda untuk melanjutkan.", + "title": "Pemasangan" }, "pair_with_pin": { "data": { "pin": "Kode PIN" }, "description": "Pemasangan diperlukan untuk protokol `{protocol}`. Masukkan kode PIN yang ditampilkan pada layar. Angka nol di awal harus dihilangkan. Misalnya, masukkan 123 jika kode yang ditampilkan adalah 0123.", - "title": "Memasangkan" + "title": "Pemasangan" + }, + "password": { + "description": "Kata sandi diperlukan oleh `{protocol}`. Ini belum didukung, nonaktifkan kata sandi untuk melanjutkan.", + "title": "Kata sandi diperlukan" + }, + "protocol_disabled": { + "description": "Pemasangan diperlukan untuk `{protocol}` tetapi dinonaktifkan pada perangkat. Tinjau kemungkinan pembatasan akses (misalnya izinkan semua perangkat di jaringan lokal untuk terhubung) pada perangkat.\n\nAnda dapat melanjutkan tanpa memasangkan protokol ini, tetapi beberapa fungsi akan terbatas.", + "title": "Pemasangan tidak dimungkinkan" }, "reconfigure": { - "description": "Apple TV ini mengalami masalah koneksi dan harus dikonfigurasi ulang.", + "description": "Konfigurasi ulang perangkat ini untuk memulihkan fungsinya.", "title": "Konfigurasi ulang perangkat" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Perangkat" }, - "description": "Mulai dengan memasukkan nama perangkat (misalnya Dapur atau Kamar Tidur) atau alamat IP Apple TV yang ingin ditambahkan. Jika ada perangkat yang ditemukan secara otomatis di jaringan Anda, perangkat tersebut akan ditampilkan di bawah ini.\n\nJika Anda tidak dapat melihat perangkat atau mengalami masalah, coba tentukan alamat IP perangkat.\n\n{devices}", + "description": "Mulai dengan memasukkan nama perangkat (misalnya Dapur atau Kamar Tidur) atau alamat IP Apple TV yang ingin ditambahkan. \n\nJika Anda tidak dapat melihat perangkat atau mengalami masalah, coba tentukan alamat IP perangkat.", "title": "Siapkan Apple TV baru" } } diff --git a/homeassistant/components/apple_tv/translations/it.json b/homeassistant/components/apple_tv/translations/it.json index 7ed3306721c70..47bd861226595 100644 --- a/homeassistant/components/apple_tv/translations/it.json +++ b/homeassistant/components/apple_tv/translations/it.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_configured_device": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "backoff": "Il dispositivo non accetta richieste di abbinamento in questo momento (potresti aver inserito un codice PIN non valido troppe volte), riprova pi\u00f9 tardi.", "device_did_not_pair": "Nessun tentativo di completare il processo di abbinamento \u00e8 stato effettuato dal dispositivo.", + "device_not_found": "Il dispositivo non \u00e8 stato trovato durante il rilevamento, prova ad aggiungerlo di nuovo.", + "inconsistent_device": "I protocolli previsti non sono stati trovati durante il rilevamento. Questo normalmente indica un problema con DNS multicast (Zeroconf). Prova ad aggiungere di nuovo il dispositivo.", "invalid_config": "La configurazione per questo dispositivo \u00e8 incompleta. Prova ad aggiungerlo di nuovo.", "no_devices_found": "Nessun dispositivo trovato sulla rete", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "setup_failed": "Impossibile configurare il dispositivo.", "unknown": "Errore imprevisto" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "\u00c8 stato trovato un dispositivo ma non \u00e8 stato possibile identificare alcun modo per stabilire una connessione ad esso. Se continui a vedere questo messaggio, prova a specificarne l'indirizzo IP o a riavviare l'Apple TV.", "unknown": "Errore imprevisto" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Stai per aggiungere l'Apple TV denominata \"{name}\" a Home Assistant. \n\n **Per completare la procedura, potrebbe essere necessario inserire pi\u00f9 codici PIN.** \n\nTieni presente che *non* sarai in grado di spegnere la tua Apple TV con questa integrazione. Solo il lettore multimediale in Home Assistant si spegner\u00e0!", + "description": "Stai per aggiungere `{name}` di tipo `{type}` a Home Assistant. \n\n **Per completare il processo, potrebbe essere necessario inserire pi\u00f9 codici PIN.** \n\nTieni presente che *non* sarai in grado di spegnere la tua Apple TV con questa integrazione. Solo il lettore multimediale in Home Assistant si spegner\u00e0!", "title": "Conferma l'aggiunta di Apple TV" }, "pair_no_pin": { - "description": "L'abbinamento \u00e8 richiesto per il servizio \"{protocol}\". Inserisci il PIN {pin} sulla tua Apple TV per continuare.", + "description": "L'associazione \u00e8 necessaria per il servizio `{protocol}`. Inserisci il PIN {pin} sul tuo dispositivo per continuare.", "title": "Abbinamento" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "L'abbinamento \u00e8 richiesto per il protocollo \"{protocol}\". Immettere il codice PIN visualizzato sullo schermo. Gli zeri iniziali devono essere omessi, ovvero immettere 123 se il codice visualizzato \u00e8 0123.", "title": "Abbinamento" }, + "password": { + "description": "\u00c8 richiesta una password da `{protocol}`. Questo non \u00e8 ancora supportato, disabilita la password per continuare.", + "title": "Password richiesta" + }, + "protocol_disabled": { + "description": "L'associazione \u00e8 necessaria per `{protocol}`, ma \u00e8 disabilitata sul dispositivo. Rivedi le potenziali restrizioni di accesso (ad esempio consentire a tutti i dispositivi sulla rete locale di connettersi) sul dispositivo. \n\n Puoi continuare senza associare questo protocollo, ma alcune funzionalit\u00e0 saranno limitate.", + "title": "Associazione non possibile" + }, "reconfigure": { - "description": "Questa Apple TV sta riscontrando alcune difficolt\u00e0 di connessione e deve essere riconfigurata.", + "description": "Riconfigura questo dispositivo per ripristinarne la funzionalit\u00e0.", "title": "Riconfigurazione del dispositivo" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Dispositivo" }, - "description": "Inizia inserendo il nome del dispositivo (es. Cucina o Camera da letto) o l'indirizzo IP dell'Apple TV che desideri aggiungere. Se sono stati rilevati automaticamente dei dispositivi sulla rete, verranno visualizzati di seguito. \n\n Se non riesci a vedere il tuo dispositivo o riscontri problemi, prova a specificare l'indirizzo IP del dispositivo. \n\n {devices}", + "description": "Inizia inserendo il nome del dispositivo (es. Cucina o Camera da letto) o l'indirizzo IP dell'Apple TV che desideri aggiungere. \n\n Se non riesci a vedere il tuo dispositivo o riscontri problemi, prova a specificare l'indirizzo IP del dispositivo.", "title": "Configura una nuova Apple TV" } } @@ -56,7 +69,7 @@ "data": { "start_off": "Non accendere il dispositivo all'avvio di Home Assistant" }, - "description": "Configurare le impostazioni generali del dispositivo" + "description": "Configura le impostazioni generali del dispositivo" } } }, diff --git a/homeassistant/components/apple_tv/translations/ja.json b/homeassistant/components/apple_tv/translations/ja.json new file mode 100644 index 0000000000000..c70dda18d011f --- /dev/null +++ b/homeassistant/components/apple_tv/translations/ja.json @@ -0,0 +1,77 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_configured_device": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "backoff": "\u73fe\u5728\u3001\u30c7\u30d0\u30a4\u30b9\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u8981\u6c42\u3092\u53d7\u3051\u4ed8\u3051\u3066\u3044\u307e\u305b\u3093(\u7121\u52b9\u306aPIN\u30b3\u30fc\u30c9\u3092\u4f55\u5ea6\u3082\u5165\u529b\u3057\u305f\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059)\u3001\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "device_did_not_pair": "\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u30da\u30a2\u30ea\u30f3\u30b0\u30d7\u30ed\u30bb\u30b9\u3092\u7d42\u4e86\u3059\u308b\u8a66\u307f\u306f\u884c\u308f\u308c\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "device_not_found": "\u691c\u51fa\u4e2d\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u3082\u3046\u4e00\u5ea6\u8ffd\u52a0\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "inconsistent_device": "\u691c\u51fa\u4e2d\u306b\u671f\u5f85\u3057\u305f\u30d7\u30ed\u30c8\u30b3\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u3053\u308c\u306f\u901a\u5e38\u3001\u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8DNS(Zeroconf)\u306b\u554f\u984c\u304c\u3042\u308b\u3053\u3068\u3092\u793a\u3057\u3066\u3044\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u3092\u3082\u3046\u4e00\u5ea6\u8ffd\u52a0\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_config": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a\u306f\u4e0d\u5b8c\u5168\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u8ffd\u52a0\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "setup_failed": "\u30c7\u30d0\u30a4\u30b9\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "no_usable_service": "\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f\u304c\u3001\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u63a5\u7d9a\u3092\u78ba\u7acb\u3059\u308b\u65b9\u6cd5\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u3053\u306e\u30e1\u30c3\u30bb\u30fc\u30b8\u304c\u5f15\u304d\u7d9a\u304d\u8868\u793a\u3055\u308c\u308b\u5834\u5408\u306f\u3001IP\u30a2\u30c9\u30ec\u30b9\u3092\u6307\u5b9a\u3059\u308b\u304b\u3001Apple TV\u3092\u518d\u8d77\u52d5\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "`{name}` \u3068\u3044\u3046\u540d\u524d\u306eApple TV\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u307e\u3059\u3002 \n\n **\u51e6\u7406\u3092\u5b8c\u4e86\u3059\u308b\u306b\u306f\u3001\u8907\u6570\u306ePIN\u30b3\u30fc\u30c9\u306e\u5165\u529b\u304c\u5fc5\u8981\u306b\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002** \n\n\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001Apple TV\u306e\u96fb\u6e90\u3092\u30aa\u30d5\u306b\u3059\u308b\u3053\u3068\u306f *\u3067\u304d\u306a\u3044* \u3053\u3068\u306b\u6ce8\u610f\u3057\u3066\u304f\u3060\u3055\u3044\u3002 Home Assistant\u306e\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u306e\u307f\u304c\u30aa\u30d5\u306b\u306a\u308a\u307e\u3059\uff01", + "title": "Apple TV\u306e\u8ffd\u52a0\u3092\u78ba\u8a8d\u3059\u308b" + }, + "pair_no_pin": { + "description": "`{protocol}` \u30b5\u30fc\u30d3\u30b9\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u5fc5\u8981\u3067\u3059\u3002\u7d9a\u884c\u3059\u308b\u306b\u306f\u3001Apple TV\u3067\u3001PIN {pin}\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "pair_with_pin": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "`{protocol}` \u30d7\u30ed\u30c8\u30b3\u30eb\u306b\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u5fc5\u8981\u3067\u3059\u3002\u753b\u9762\u306b\u8868\u793a\u3055\u308c\u3066\u3044\u308bPIN\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u306a\u304a\u5148\u982d\u306e\u30bc\u30ed\u306f\u7701\u7565\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u3064\u307e\u308a\u3001\u8868\u793a\u3055\u308c\u308b\u30b3\u30fc\u30c9\u304c0123\u306e\u5834\u5408\u306f123\u3068\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "password": { + "description": "`{protocol}` \u306b\u306f\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u5fc5\u8981\u3067\u3059\u3002\u3053\u308c\u306f\u307e\u3060\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u7d9a\u884c\u3059\u308b\u306b\u306f\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u7121\u52b9\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u5fc5\u8981" + }, + "protocol_disabled": { + "description": "`{protocol}` \u306b\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u5fc5\u8981\u3068\u3057\u307e\u3059\u304c\u3001\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u7121\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u3059\u3002\u6a5f\u5668\u5074\u306e\u30a2\u30af\u30bb\u30b9\u5236\u9650(\u4f8b: \u30ed\u30fc\u30ab\u30eb\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306e\u3059\u3079\u3066\u306e\u6a5f\u5668\u306e\u63a5\u7d9a\u3092\u8a31\u53ef\u3059\u308b)\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u3053\u306e\u30d7\u30ed\u30c8\u30b3\u30eb\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u305b\u305a\u306b\u7d9a\u884c\u3067\u304d\u307e\u3059\u304c\u3001\u4e00\u90e8\u306e\u6a5f\u80fd\u304c\u5236\u9650\u3055\u308c\u307e\u3059\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0\u3067\u304d\u307e\u305b\u3093" + }, + "reconfigure": { + "description": "\u3053\u306eApple TV\u306b\u306f\u63a5\u7d9a\u969c\u5bb3\u304c\u767a\u751f\u3057\u3066\u3044\u308b\u305f\u3081\u3001\u518d\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u518d\u69cb\u6210" + }, + "service_problem": { + "description": "\u30d7\u30ed\u30c8\u30b3\u30eb `{protocol}`\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u4e2d\u306b\u554f\u984c\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u3053\u306e\u554f\u984c\u306f\u7121\u8996\u3055\u308c\u307e\u3059\u3002", + "title": "\u30b5\u30fc\u30d3\u30b9\u306e\u8ffd\u52a0\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "user": { + "data": { + "device_input": "\u30c7\u30d0\u30a4\u30b9" + }, + "description": "\u307e\u305a\u3001\u8ffd\u52a0\u3057\u305f\u3044Apple TV\u306e\u30c7\u30d0\u30a4\u30b9\u540d(Kitchen \u3084 Bedroom\u306a\u3069)\u304bIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u3067\u30c7\u30d0\u30a4\u30b9\u304c\u81ea\u52d5\u7684\u306b\u898b\u3064\u304b\u3063\u305f\u5834\u5408\u306f\u3001\u4ee5\u4e0b\u306b\u8868\u793a\u3055\u308c\u307e\u3059\u3002\n\n\u30c7\u30d0\u30a4\u30b9\u304c\u8868\u793a\u3055\u308c\u306a\u3044\u5834\u5408\u3084\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u6307\u5b9a\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002\n\n{devices}", + "title": "\u65b0\u3057\u3044Apple TV\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "start_off": "Home Assistant\u306e\u8d77\u52d5\u6642\u306b\u30c7\u30d0\u30a4\u30b9\u306e\u96fb\u6e90\u3092\u5165\u308c\u306a\u3044" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u4e00\u822c\u7684\u306a\u8a2d\u5b9a" + } + } + }, + "title": "Apple TV" +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/lt.json b/homeassistant/components/apple_tv/translations/lt.json new file mode 100644 index 0000000000000..133261e6fa416 --- /dev/null +++ b/homeassistant/components/apple_tv/translations/lt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "password": { + "title": "Reikalingas slapta\u017eodis" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/nl.json b/homeassistant/components/apple_tv/translations/nl.json index e313e97218879..7fdc20c729164 100644 --- a/homeassistant/components/apple_tv/translations/nl.json +++ b/homeassistant/components/apple_tv/translations/nl.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Apparaat is al geconfigureerd", "already_configured_device": "Apparaat is al geconfigureerd", "already_in_progress": "De configuratiestroom is al aan de gang", "backoff": "Het apparaat accepteert op dit moment geen koppelingsverzoeken (u heeft mogelijk te vaak een ongeldige pincode ingevoerd), probeer het later opnieuw.", "device_did_not_pair": "Er is geen poging gedaan om het koppelingsproces te voltooien vanaf het apparaat.", + "device_not_found": "Apparaat werd niet gevonden tijdens het zoeken, probeer het opnieuw toe te voegen.", + "inconsistent_device": "De verwachte protocollen zijn niet gevonden tijdens het zoeken. Dit wijst gewoonlijk op een probleem met multicast DNS (Zeroconf). Probeer het apparaat opnieuw toe te voegen.", "invalid_config": "De configuratie voor dit apparaat is onvolledig. Probeer het opnieuw toe te voegen.", "no_devices_found": "Geen apparaten gevonden op het netwerk", + "reauth_successful": "Herauthenticatie was succesvol", + "setup_failed": "Kan het apparaat niet instellen.", "unknown": "Onverwachte fout" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "Er is een apparaat gevonden, maar er kon geen manier worden gevonden om er verbinding mee te maken. Als u dit bericht blijft zien, probeert u het IP-adres in te voeren of uw Apple TV opnieuw op te starten.", "unknown": "Onverwachte fout" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ( {type} )", "step": { "confirm": { - "description": "U staat op het punt om de Apple TV met de naam `{name}` toe te voegen aan Home Assistant.\n\n**Om het proces te voltooien, moet u mogelijk meerdere PIN-codes invoeren.**\n\nLet op: u kunt uw Apple TV *niet* uitschakelen met deze integratie. Alleen de mediaspeler in Home Assistant wordt uitgeschakeld!", + "description": "U staat op het punt om `{name}` van het type `{type}` toe te voegen aan Home Assistant.\n\n**Om het proces te voltooien, kan het zijn dat u meerdere PIN-codes moet invoeren.**\n\nLet op dat u *niet* uw Apple TV kunt uitschakelen met deze integratie. Alleen de mediaspeler in Home Assistant gaat uit!", "title": "Bevestig het toevoegen van Apple TV" }, "pair_no_pin": { - "description": "Koppeling is vereist voor de `{protocol}` service. Voer de PIN {pin} in op uw Apple TV om verder te gaan.", + "description": "Koppeling is vereist voor de `{protocol}` service. Voer de PIN {pin} in op uw apparaat om verder te gaan.", "title": "Koppelen" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "Koppelen is vereist voor het `{protocol}` protocol. Voer de PIN-code in die op het scherm wordt getoond. Beginnende nullen moeten worden weggelaten, d.w.z. voer 123 in als de getoonde code 0123 is.", "title": "Koppelen" }, + "password": { + "description": "Een wachtwoord is vereist door `{protocol}`. Dit wordt nog niet ondersteund, schakel het wachtwoord uit om verder te gaan.", + "title": "Wachtwoord vereist" + }, + "protocol_disabled": { + "description": "Koppelen is vereist voor `{protocol}` maar het is uitgeschakeld op het apparaat. Controleer mogelijke toegangsbeperkingen (bijv. alle apparaten op het lokale netwerk toestaan verbinding te maken) op het apparaat.\n\nU kunt doorgaan zonder dit protocol te koppelen, maar sommige functies zullen beperkt zijn.", + "title": "Koppelen niet mogelijk" + }, "reconfigure": { - "description": "Deze Apple TV ondervindt verbindingsproblemen en moet opnieuw worden geconfigureerd.", + "description": "Configureer dit apparaat opnieuw om de functionaliteit te herstellen.", "title": "Apparaat herconfiguratie" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Apparaat" }, - "description": "Begin met het invoeren van de apparaatnaam (bijv. Keuken of Slaapkamer) of het IP-adres van de Apple TV die u wilt toevoegen. Als er automatisch apparaten in uw netwerk zijn gevonden, worden deze hieronder weergegeven.\n\nAls u het apparaat niet kunt zien of problemen ondervindt, probeer dan het IP-adres van het apparaat in te voeren.\n\n{devices}", + "description": "Begin met het invoeren van de apparaatnaam (bijv. Keuken of Slaapkamer) of het IP-adres van de Apple TV die u wilt toevoegen. \n\nAls u het apparaat niet kunt zien of problemen ondervindt, probeer dan het IP-adres van het apparaat in te voeren.\n\n", "title": "Stel een nieuwe Apple TV in" } } diff --git a/homeassistant/components/apple_tv/translations/no.json b/homeassistant/components/apple_tv/translations/no.json index 88a7c98615269..4821fb2128d06 100644 --- a/homeassistant/components/apple_tv/translations/no.json +++ b/homeassistant/components/apple_tv/translations/no.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "already_configured_device": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "backoff": "Enheten godtar ikke parringsanmodninger for \u00f8yeblikket (du har kanskje angitt en ugyldig PIN-kode for mange ganger), pr\u00f8v igjen senere.", "device_did_not_pair": "Ingen fors\u00f8k p\u00e5 \u00e5 fullf\u00f8re paringsprosessen ble gjort fra enheten", + "device_not_found": "Enheten ble ikke funnet under oppdagelsen. Pr\u00f8v \u00e5 legge den til p\u00e5 nytt.", + "inconsistent_device": "Forventede protokoller ble ikke funnet under oppdagelsen. Dette indikerer vanligvis et problem med multicast DNS (Zeroconf). Pr\u00f8v \u00e5 legge til enheten p\u00e5 nytt.", "invalid_config": "Konfigurasjonen for denne enheten er ufullstendig. Pr\u00f8v \u00e5 legge den til p\u00e5 nytt.", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "setup_failed": "Kunne ikke konfigurere enheten.", "unknown": "Uventet feil" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "En enhet ble funnet, men kunne ikke identifisere noen m\u00e5te \u00e5 etablere en tilkobling til den. Hvis du fortsetter \u00e5 se denne meldingen, kan du pr\u00f8ve \u00e5 angi IP-adressen eller starte Apple TV p\u00e5 nytt.", "unknown": "Uventet feil" }, - "flow_title": "", + "flow_title": "{name} ( {type} )", "step": { "confirm": { - "description": "Du er i ferd med \u00e5 legge til Apple TV med navnet {name} i Home Assistant.\n\n**For \u00e5 fullf\u00f8re prosessen m\u00e5 du kanskje angi flere PIN-koder.**\n\nV\u00e6r oppmerksom p\u00e5 at du *ikke* kan sl\u00e5 av Apple TV med denne integreringen. Bare mediespilleren i Home Assistant sl\u00e5r seg av!", + "description": "Du er i ferd med \u00e5 legge til ` {name} ` av typen ` {type} ` til Home Assistant. \n\n **For \u00e5 fullf\u00f8re prosessen m\u00e5 du kanskje angi flere PIN-koder.** \n\n V\u00e6r oppmerksom p\u00e5 at du *ikke* vil kunne sl\u00e5 av Apple TV med denne integrasjonen. Bare mediespilleren i Home Assistant vil sl\u00e5 seg av!", "title": "Bekreft at du legger til Apple TV" }, "pair_no_pin": { - "description": "Paring kreves for tjenesten {protocol}. Skriv inn PIN-koden {pin} p\u00e5 Apple TV for \u00e5 fortsette.", + "description": "Paring er n\u00f8dvendig for tjenesten ` {protocol} `. Skriv inn PIN-koden {pin} p\u00e5 enheten din for \u00e5 fortsette.", "title": "Sammenkobling" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "Paring kreves for protokollen {protocol}. Skriv inn PIN-koden som vises p\u00e5 skjermen. Ledende nuller utelates, det vil si angi 123 hvis den viste koden er 0123.", "title": "Sammenkobling" }, + "password": { + "description": "Et passord kreves av ` {protocol} `. Dette st\u00f8ttes ikke enn\u00e5. Deaktiver passordet for \u00e5 fortsette.", + "title": "Passord kreves" + }, + "protocol_disabled": { + "description": "Paring er n\u00f8dvendig for ` {protocol} `, men den er deaktivert p\u00e5 enheten. Se gjennom potensielle tilgangsbegrensninger (f.eks. la alle enheter p\u00e5 det lokale nettverket koble seg til) p\u00e5 enheten. \n\n Du kan fortsette uten \u00e5 pare denne protokollen, men noe funksjonalitet vil v\u00e6re begrenset.", + "title": "Sammenkobling ikke mulig" + }, "reconfigure": { - "description": "Denne Apple TVen har noen tilkoblingsvansker og m\u00e5 konfigureres p\u00e5 nytt", + "description": "Konfigurer denne enheten p\u00e5 nytt for \u00e5 gjenopprette funksjonaliteten.", "title": "Omkonfigurering av enheter" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Enhet" }, - "description": "Start med \u00e5 skrive inn enhetsnavnet (f.eks. kj\u00f8kken eller soverom) eller IP-adressen til Apple TV-en du vil legge til. Hvis noen enheter ble funnet automatisk p\u00e5 nettverket ditt, vises de nedenfor.\n\nHvis du ikke kan se enheten eller oppleve problemer, kan du pr\u00f8ve \u00e5 angi enhetens IP-adresse.\n\n{devices}", + "description": "Start med \u00e5 skrive inn enhetsnavnet (f.eks. Kj\u00f8kken eller soverom) eller IP-adressen til Apple TV-en du vil legge til. \n\n Hvis du ikke kan se enheten eller opplever problemer, pr\u00f8v \u00e5 spesifisere enhetens IP-adresse.", "title": "Konfigurere en ny Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/pl.json b/homeassistant/components/apple_tv/translations/pl.json index e8950d1c714c2..1ba9b46dc3be4 100644 --- a/homeassistant/components/apple_tv/translations/pl.json +++ b/homeassistant/components/apple_tv/translations/pl.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "already_configured_device": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "already_in_progress": "Konfiguracja jest ju\u017c w toku", "backoff": "Urz\u0105dzenie w tej chwili nie akceptuje \u017c\u0105da\u0144 parowania (by\u0107 mo\u017ce zbyt wiele razy wpisa\u0142e\u015b nieprawid\u0142owy kod PIN), spr\u00f3buj ponownie p\u00f3\u017aniej.", "device_did_not_pair": "Nie podj\u0119to pr\u00f3by zako\u0144czenia procesu parowania z urz\u0105dzenia.", + "device_not_found": "Urz\u0105dzenie nie zosta\u0142o znalezione podczas wykrywania, spr\u00f3buj doda\u0107 je ponownie.", + "inconsistent_device": "Oczekiwane protoko\u0142y nie zosta\u0142y znalezione podczas wykrywania. Zwykle wskazuje to na problem z multicastem DNS (Zeroconf). Spr\u00f3buj ponownie doda\u0107 urz\u0105dzenie.", "invalid_config": "Konfiguracja tego urz\u0105dzenia jest niekompletna. Spr\u00f3buj doda\u0107 go ponownie.", "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "setup_failed": "Nie uda\u0142o si\u0119 skonfigurowa\u0107 urz\u0105dzenia.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "Znaleziono urz\u0105dzenie, ale nie uda\u0142o si\u0119 zidentyfikowa\u0107 \u017cadnego sposobu na nawi\u0105zanie z nim po\u0142\u0105czenia. Je\u015bli nadal widzisz t\u0119 wiadomo\u015b\u0107, spr\u00f3buj poda\u0107 jego adres IP lub uruchom ponownie Apple TV.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "Zamierzasz doda\u0107 Apple TV o nazwie \"{name}\" do Home Assistanta. \n\n **Aby uko\u0144czy\u0107 ca\u0142y proces, mo\u017ce by\u0107 konieczne wprowadzenie wielu kod\u00f3w PIN.** \n\nPami\u0119taj, \u017ce \"NIE\" b\u0119dziesz w stanie wy\u0142\u0105czy\u0107 Apple TV dzi\u0119ki tej integracji. Wy\u0142\u0105cza si\u0119 tylko sam odtwarzacz multimedialny w Home Assistant!", + "description": "Zamierzasz doda\u0107 \"{name}\" ({type}) do Home Assistanta. \n\n **Aby uko\u0144czy\u0107 ca\u0142y proces, mo\u017ce by\u0107 konieczne wprowadzenie wielu kod\u00f3w PIN.** \n\nPami\u0119taj, \u017ce \"NIE\" b\u0119dziesz w stanie wy\u0142\u0105czy\u0107 Apple TV dzi\u0119ki tej integracji. Wy\u0142\u0105cza si\u0119 tylko sam odtwarzacz multimedialny w Home Assistant!", "title": "Potwierdzenie dodania Apple TV" }, "pair_no_pin": { - "description": "Parowanie jest wymagane dla us\u0142ugi \"{protocol}\". Aby kontynuowa\u0107, wprowad\u017a kod {pin} na swoim Apple TV.", + "description": "Parowanie jest wymagane dla us\u0142ugi \"{protocol}\". Aby kontynuowa\u0107, wprowad\u017a kod {pin} na swoim urz\u0105dzeniu.", "title": "Parowanie" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "Parowanie jest wymagane dla protoko\u0142u \"{protocol}\". Wprowad\u017a kod PIN wy\u015bwietlony na ekranie. Zera poprzedzaj\u0105ce nale\u017cy pomin\u0105\u0107, tj. wpisa\u0107 123, zamiast 0123.", "title": "Parowanie" }, + "password": { + "description": "Has\u0142o jest wymagane przez `{protocol}`. To nie jest jeszcze obs\u0142ugiwane. Wy\u0142\u0105cz has\u0142o, aby kontynuowa\u0107.", + "title": "Wymagane has\u0142o" + }, + "protocol_disabled": { + "description": "Parowanie jest wymagane dla `{protocol}`, ale jest wy\u0142\u0105czone na urz\u0105dzeniu. Sprawd\u017a potencjalne ograniczenia dost\u0119pu na urz\u0105dzeniu (np. zezw\u00f3l wszystkim urz\u0105dzeniom w sieci lokalnej na po\u0142\u0105czenie). \n\n Mo\u017cesz kontynuowa\u0107 bez parowania tego protoko\u0142u, ale niekt\u00f3re funkcje b\u0119d\u0105 ograniczone.", + "title": "Brak mo\u017cliwo\u015bci parowania" + }, "reconfigure": { - "description": "Ten Apple TV ma pewne problemy z po\u0142\u0105czeniem i musi zosta\u0107 ponownie skonfigurowany.", + "description": "Ponownie skonfiguruj to urz\u0105dzenie, aby przywr\u00f3ci\u0107 jego funkcjonalno\u015b\u0107.", "title": "Ponowna konfiguracja urz\u0105dzenia" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "Urz\u0105dzenie" }, - "description": "Zacznij od wprowadzenia nazwy urz\u0105dzenia (np. Kuchnia lub Sypialnia) lub adresu IP Apple TV, kt\u00f3re chcesz doda\u0107. Je\u015bli jakie\u015b urz\u0105dzenia zosta\u0142y automatycznie znalezione w Twojej sieci, s\u0105 one pokazane poni\u017cej. \n\nJe\u015bli nie widzisz swojego urz\u0105dzenia lub wyst\u0119puj\u0105 jakiekolwiek problemy, spr\u00f3buj okre\u015bli\u0107 adres IP urz\u0105dzenia. \n\n{devices}", + "description": "Zacznij od wprowadzenia nazwy urz\u0105dzenia (np. Kuchnia lub Sypialnia) lub adresu IP Apple TV, kt\u00f3re chcesz doda\u0107.\n\nJe\u015bli nie widzisz swojego urz\u0105dzenia lub wyst\u0119puj\u0105 jakiekolwiek problemy, spr\u00f3buj okre\u015bli\u0107 adres IP urz\u0105dzenia.", "title": "Konfiguracja nowego Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/ru.json b/homeassistant/components/apple_tv/translations/ru.json index 4ad9b9f52c762..4863a54160364 100644 --- a/homeassistant/components/apple_tv/translations/ru.json +++ b/homeassistant/components/apple_tv/translations/ru.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "already_configured_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "backoff": "\u0412 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0435\u0442 \u0437\u0430\u043f\u0440\u043e\u0441\u044b \u043d\u0430 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 (\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e, \u0412\u044b \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0440\u0430\u0437 \u0432\u0432\u043e\u0434\u0438\u043b\u0438 \u043d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434), \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", "device_did_not_pair": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u044b\u0442\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f.", + "device_not_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0435\u0433\u043e \u0435\u0449\u0451 \u0440\u0430\u0437.", + "inconsistent_device": "\u041d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u043e\u0436\u0438\u0434\u0430\u0435\u043c\u044b\u0435 \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u044b. \u041e\u0431\u044b\u0447\u043d\u043e \u044d\u0442\u043e \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443 \u0441 \u043c\u043d\u043e\u0433\u043e\u0430\u0434\u0440\u0435\u0441\u043d\u044b\u043c DNS (Zeroconf). \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0435\u0449\u0451 \u0440\u0430\u0437.", "invalid_config": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0435\u0433\u043e \u0435\u0449\u0451 \u0440\u0430\u0437.", "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", + "setup_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u043f\u043e\u0441\u043e\u0431 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u043c\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u0415\u0441\u043b\u0438 \u0412\u044b \u0443\u0436\u0435 \u0432\u0438\u0434\u0435\u043b\u0438 \u044d\u0442\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 \u0435\u0433\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "\u0412\u044b \u0441\u043e\u0431\u0438\u0440\u0430\u0435\u0442\u0435\u0441\u044c \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Apple TV `{name}` \u0432 Home Assistant. \n\n**\u0414\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0412\u0430\u043c \u043c\u043e\u0436\u0435\u0442 \u043f\u043e\u0442\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0432\u0432\u0435\u0441\u0442\u0438 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e PIN-\u043a\u043e\u0434\u043e\u0432.** \n\n\u041e\u0431\u0440\u0430\u0442\u0438\u0442\u0435 \u0432\u043d\u0438\u043c\u0430\u043d\u0438\u0435, \u0447\u0442\u043e \u0412\u044b *\u043d\u0435* \u0441\u043c\u043e\u0436\u0435\u0442\u0435 \u0432\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c Apple TV \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438. \u0412 Home Assistant \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440!", + "description": "\u0412\u044b \u0441\u043e\u0431\u0438\u0440\u0430\u0435\u0442\u0435\u0441\u044c \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Apple TV `{name}` `{type}` \u0432 Home Assistant. \n\n**\u0414\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0412\u0430\u043c \u043c\u043e\u0436\u0435\u0442 \u043f\u043e\u0442\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0432\u0432\u0435\u0441\u0442\u0438 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e PIN-\u043a\u043e\u0434\u043e\u0432.** \n\n\u041e\u0431\u0440\u0430\u0442\u0438\u0442\u0435 \u0432\u043d\u0438\u043c\u0430\u043d\u0438\u0435, \u0447\u0442\u043e \u0412\u044b *\u043d\u0435* \u0441\u043c\u043e\u0436\u0435\u0442\u0435 \u0432\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c Apple TV \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438. \u0412 Home Assistant \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440!", "title": "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 Apple TV" }, "pair_no_pin": { - "description": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0434\u043b\u044f \u0441\u043b\u0443\u0436\u0431\u044b`{protocol}`. \u0414\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434 {pin} \u043d\u0430 \u0412\u0430\u0448\u0435\u043c Apple TV.", + "description": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0434\u043b\u044f \u0441\u043b\u0443\u0436\u0431\u044b`{protocol}`. \u0414\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434 {pin} \u043d\u0430 \u0412\u0430\u0448\u0435\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435.", "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 `{protocol}`. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434, \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0439 \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435. \u041f\u0435\u0440\u0432\u044b\u0435 \u043d\u0443\u043b\u0438 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u043e\u043f\u0443\u0449\u0435\u043d\u044b, \u0442.\u0435. \u0432\u0432\u0435\u0434\u0438\u0442\u0435 123, \u0435\u0441\u043b\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0439 \u043a\u043e\u0434 0123.", "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435" }, + "password": { + "description": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b `{protocol}` \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u043f\u0430\u0440\u043e\u043b\u044c. \u042d\u0442\u043e \u043f\u043e\u043a\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f. \u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c.", + "title": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0430\u0440\u043e\u043b\u044c" + }, + "protocol_disabled": { + "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0434\u043b\u044f `{protocol}`, \u043d\u043e \u043e\u043d\u043e \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435. \u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0438\u0442\u0435 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u044b\u0435 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0432\u0441\u0435\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430\u043c \u0432 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0435\u0442\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0442\u044c\u0441\u044f) \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \n\n\u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0431\u0435\u0437 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043d\u043e \u043d\u0435\u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438 \u0431\u0443\u0434\u0443\u0442 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u044b.", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e" + }, "reconfigure": { - "description": "\u0423 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Apple TV \u0432\u043e\u0437\u043d\u0438\u043a\u0430\u044e\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u043f\u0440\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438, \u0435\u0433\u043e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c.", + "description": "\u041f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u0447\u0442\u043e\u0431\u044b \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0435\u0433\u043e \u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c.", "title": "\u041f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" }, - "description": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u0432\u0432\u043e\u0434\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u041a\u0443\u0445\u043d\u044f \u0438\u043b\u0438 \u0421\u043f\u0430\u043b\u044c\u043d\u044f) \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441\u0430 Apple TV, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c. \u0415\u0441\u043b\u0438 \u043a\u0430\u043a\u0438\u0435-\u043b\u0438\u0431\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u044b\u043b\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0432 \u0412\u0430\u0448\u0435\u0439 \u0441\u0435\u0442\u0438, \u043e\u043d\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u044b \u043d\u0438\u0436\u0435. \n\n\u0415\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0432\u0438\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0438\u043b\u0438 \u0432\u043e\u0437\u043d\u0438\u043a\u0430\u044e\u0442 \u043a\u0430\u043a\u0438\u0435-\u043b\u0438\u0431\u043e \u0434\u0440\u0443\u0433\u0438\u0435 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u043f\u0440\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. \n\n {devices}", + "description": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u0432\u0432\u043e\u0434\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u041a\u0443\u0445\u043d\u044f \u0438\u043b\u0438 \u0421\u043f\u0430\u043b\u044c\u043d\u044f) \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441\u0430 Apple TV, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c. \n\n\u0415\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0432\u0438\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0438\u043b\u0438 \u0432\u043e\u0437\u043d\u0438\u043a\u0430\u044e\u0442 \u043a\u0430\u043a\u0438\u0435-\u043b\u0438\u0431\u043e \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u043e\u0432\u043e\u0433\u043e Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/tr.json b/homeassistant/components/apple_tv/translations/tr.json index f33e3998af627..4b9c2a4ca0729 100644 --- a/homeassistant/components/apple_tv/translations/tr.json +++ b/homeassistant/components/apple_tv/translations/tr.json @@ -1,43 +1,64 @@ { "config": { "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_configured_device": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "backoff": "Cihaz \u015fu anda e\u015fle\u015ftirme isteklerini kabul etmiyor (\u00e7ok say\u0131da ge\u00e7ersiz PIN kodu girmi\u015f olabilirsiniz), daha sonra tekrar deneyin.", + "device_did_not_pair": "Cihazdan e\u015fle\u015ftirme i\u015flemini bitirmek i\u00e7in herhangi bir giri\u015fimde bulunulmad\u0131.", + "device_not_found": "Cihaz ke\u015fif s\u0131ras\u0131nda bulunamad\u0131, l\u00fctfen tekrar eklemeyi deneyin.", + "inconsistent_device": "Ke\u015fif s\u0131ras\u0131nda beklenen protokoller bulunamad\u0131. Bu normalde \u00e7ok noktaya yay\u0131n DNS (Zeroconf) ile ilgili bir sorunu g\u00f6sterir. L\u00fctfen cihaz\u0131 tekrar eklemeyi deneyin.", "invalid_config": "Bu ayg\u0131t\u0131n yap\u0131land\u0131rmas\u0131 tamamlanmad\u0131. L\u00fctfen tekrar eklemeyi deneyin.", "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "setup_failed": "Cihaz kurulumu ba\u015far\u0131s\u0131z.", "unknown": "Beklenmeyen hata" }, "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "no_usable_service": "Bir ayg\u0131t bulundu, ancak ba\u011flant\u0131 kurman\u0131n herhangi bir yolunu tan\u0131mlayamad\u0131. Bu iletiyi g\u00f6rmeye devam ederseniz, IP adresini belirtmeye veya Apple TV'nizi yeniden ba\u015flatmaya \u00e7al\u0131\u015f\u0131n.", "unknown": "Beklenmeyen hata" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name} ( {type} )", "step": { "confirm": { + "description": "Home Assistant'a {type} ` t\u00fcr\u00fcnde ` {name} ` eklemek \u00fczeresiniz. \n\n **\u0130\u015flemi tamamlamak i\u00e7in birden fazla PIN kodu girmeniz gerekebilir.** \n\n Bu entegrasyonla Apple TV'nizi *kapatamayaca\u011f\u0131n\u0131z\u0131* l\u00fctfen unutmay\u0131n. Yaln\u0131zca Home Assistant'taki medya oynat\u0131c\u0131 kapanacak!", "title": "Apple TV eklemeyi onaylay\u0131n" }, "pair_no_pin": { + "description": "{protocol} ` hizmeti i\u00e7in e\u015fle\u015ftirme gereklidir. Devam etmek i\u00e7in l\u00fctfen cihaz\u0131n\u0131za PIN {pin} giriniz.", "title": "E\u015fle\u015ftirme" }, "pair_with_pin": { "data": { "pin": "PIN Kodu" }, + "description": "{protocol} ` protokol\u00fc i\u00e7in e\u015fle\u015ftirme gereklidir. L\u00fctfen ekranda g\u00f6r\u00fcnt\u00fclenen PIN kodunu girin. Ba\u015ftaki s\u0131f\u0131rlar atlanmal\u0131d\u0131r, yani g\u00f6r\u00fcnt\u00fclenen kod 0123 ise 123 girin.", "title": "E\u015fle\u015ftirme" }, + "password": { + "description": "{protocol} ` taraf\u0131ndan bir \u015fifre gerekiyor. Bu hen\u00fcz desteklenmiyor, l\u00fctfen devam etmek i\u00e7in \u015fifreyi devre d\u0131\u015f\u0131 b\u0131rak\u0131n.", + "title": "\u015eifre gerekli" + }, + "protocol_disabled": { + "description": "{protocol} ` i\u00e7in e\u015fle\u015ftirme gerekli ancak cihazda devre d\u0131\u015f\u0131 b\u0131rak\u0131ld\u0131. L\u00fctfen cihazdaki olas\u0131 eri\u015fim k\u0131s\u0131tlamalar\u0131n\u0131 g\u00f6zden ge\u00e7irin (\u00f6rne\u011fin, yerel a\u011fdaki t\u00fcm cihazlar\u0131n ba\u011flanmas\u0131na izin verin). \n\n Bu protokol\u00fc e\u015fle\u015ftirmeden devam edebilirsiniz, ancak baz\u0131 i\u015flevler s\u0131n\u0131rl\u0131 olacakt\u0131r.", + "title": "E\u015fle\u015ftirme m\u00fcmk\u00fcn de\u011fil" + }, "reconfigure": { - "description": "Bu Apple TV baz\u0131 ba\u011flant\u0131 sorunlar\u0131 ya\u015f\u0131yor ve yeniden yap\u0131land\u0131r\u0131lmas\u0131 gerekiyor.", + "description": "\u0130\u015flevselli\u011fini geri y\u00fcklemek i\u00e7in bu cihaz\u0131 yeniden yap\u0131land\u0131r\u0131n.", "title": "Cihaz\u0131n yeniden yap\u0131land\u0131r\u0131lmas\u0131" }, "service_problem": { + "description": "{protocol} ` e\u015fle\u015ftirilirken bir sorun olu\u015ftu. G\u00f6z ard\u0131 edilecek.", "title": "Hizmet eklenemedi" }, "user": { "data": { "device_input": "Cihaz" }, + "description": "Eklemek istedi\u011finiz Apple TV'nin cihaz ad\u0131n\u0131 (\u00f6rn. Mutfak veya Yatak Odas\u0131) veya IP adresini girerek ba\u015flay\u0131n. \n\n Cihaz\u0131n\u0131z\u0131 g\u00f6remiyorsan\u0131z veya herhangi bir sorun ya\u015f\u0131yorsan\u0131z, cihaz\u0131n IP adresini belirtmeyi deneyin.", "title": "Yeni bir Apple TV kurun" } } diff --git a/homeassistant/components/apple_tv/translations/zh-Hans.json b/homeassistant/components/apple_tv/translations/zh-Hans.json index 54095a0a63367..65ab4d272382c 100644 --- a/homeassistant/components/apple_tv/translations/zh-Hans.json +++ b/homeassistant/components/apple_tv/translations/zh-Hans.json @@ -1,19 +1,61 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", + "already_configured_device": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "backoff": "\u8bbe\u5907\u76ee\u524d\u6682\u4e0d\u63a5\u53d7\u914d\u5bf9\u8bf7\u6c42\uff08\u53ef\u80fd\u591a\u6b21\u8f93\u5165\u65e0\u6548 PIN \u7801\uff09\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002", + "device_not_found": "\u672a\u627e\u5230\u8bbe\u5907\uff0c\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u6dfb\u52a0\u3002", + "inconsistent_device": "\u641c\u7d22\u671f\u95f4\u672a\u53d1\u73b0\u914d\u7f6e\u8bbe\u5907\u6240\u5fc5\u9700\u7684\u534f\u8bae\u3002\u8fd9\u901a\u5e38\u662f\u56e0\u4e3a mDNS \u534f\u8bae\uff08zeroconf\uff09\u5b58\u5728\u95ee\u9898\u3002\u8bf7\u7a0d\u540e\u518d\u91cd\u65b0\u5c1d\u8bd5\u6dfb\u52a0\u8bbe\u5907\u3002", + "invalid_config": "\u6b64\u8bbe\u5907\u7684\u914d\u7f6e\u4fe1\u606f\u4e0d\u5b8c\u6574\u3002\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u6dfb\u52a0\u3002", + "no_devices_found": "\u672a\u5728\u6b64\u7f51\u7edc\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f", + "setup_failed": "\u8bbe\u7f6e\u8bbe\u5907\u5931\u8d25\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "invalid_auth": "\u51ed\u636e\u65e0\u6548", + "no_devices_found": "\u672a\u5728\u6b64\u7f51\u7edc\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "no_usable_service": "\u5df2\u76f8\u5173\u627e\u5230\u8bbe\u5907\uff0c\u4f46\u65e0\u6cd5\u8bc6\u522b\u5e76\u4e0e\u5176\u5efa\u7acb\u8fde\u63a5\u3002\u82e5\u60a8\u4e00\u76f4\u6536\u5230\u6b64\u8b66\u544a\u6d88\u606f\uff0c\u8bf7\u5c1d\u8bd5\u4e3a\u5176\u6307\u5b9a\u56fa\u5b9a IP \u5730\u5740\u6216\u91cd\u65b0\u542f\u52a8\u60a8\u7684 Apple TV\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "\u60a8\u5373\u5c06\u6dfb\u52a0 Apple TV (\u540d\u79f0\u4e3a\u201c{name}\u201d)\u5230 Home Assistant\u3002 \n\n **\u8981\u5b8c\u6210\u6b64\u8fc7\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u8f93\u5165\u591a\u4e2a PIN \u7801\u3002** \n\n\u8bf7\u6ce8\u610f\uff0c\u6b64\u96c6\u6210*\u4e0d\u80fd*\u5173\u95ed Apple TV \u7684\u7535\u6e90\uff0c\u53ea\u4f1a\u5173\u95ed Home Assistant \u4e2d\u7684\u5a92\u4f53\u64ad\u653e\u5668\uff01" + "description": "\u60a8\u5373\u5c06\u6dfb\u52a0\u201c{type}\u201d(\u540d\u79f0\u4e3a\u201c{name}\u201d) \u5230 Home Assistant\u3002 \n\n **\u8981\u5b8c\u6210\u6b64\u8fc7\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u8f93\u5165\u591a\u4e2a PIN \u7801\u3002** \n\n\u8bf7\u6ce8\u610f\uff0c\u6b64\u96c6\u6210*\u4e0d\u80fd*\u5173\u95ed Apple TV \u7684\u7535\u6e90\uff0c\u53ea\u4f1a\u5173\u95ed Home Assistant \u4e2d\u7684\u5a92\u4f53\u64ad\u653e\u5668\uff01", + "title": "\u786e\u8ba4\u6dfb\u52a0 Apple TV" }, "pair_no_pin": { + "description": "\u201c{protocol}\u201d\u670d\u52a1\u9700\u8981\u914d\u5bf9\u3002\u8bf7\u5728\u60a8\u7684 Apple TV \u4e0a\u8f93\u5165 PIN {pin} \u4ee5\u7ee7\u7eed\u3002", "title": "\u914d\u5bf9\u4e2d" }, "pair_with_pin": { "data": { "pin": "PIN\u7801" - } + }, + "title": "\u914d\u5bf9\u4e2d" + }, + "password": { + "description": "\u201c{protocol}\u201d\u9700\u8981\u8f93\u5165\u5bc6\u7801\uff0c\u76ee\u524d\u6682\u4e0d\u652f\u6301\u3002\u8bf7\u7981\u7528\u5bc6\u7801\u540e\u518d\u7ee7\u7eed\u3002", + "title": "\u9700\u8981\u5bc6\u7801" + }, + "protocol_disabled": { + "description": "\u201c{protocol}\u201d\u534f\u8bae\u9700\u8981\u914d\u5bf9\uff0c\u4f46\u662f\u5df2\u5728\u8bbe\u5907\u4e0a\u7981\u6b62\u914d\u5bf9\u3002\u8bf7\u68c0\u67e5\u8bbe\u5907\u4e0a\u7684\u8bbf\u95ee\u6743\u9650\u8bbe\u7f6e\uff0c\u4f8b\u5982\u5c06\u201c\u5141\u8bb8\u8bbf\u95ee\u626c\u58f0\u5668\u548c\u7535\u89c6\u201d\u8bbe\u4e3a\u201c\u540c\u4e00\u7f51\u7edc\u4e2d\u7684\u4efb\u4f55\u4eba\u201d\u3002\n\n\u5728\u4e0d\u914d\u5bf9\u201c{protocol}\u201d\u534f\u8bae\u7684\u60c5\u51b5\u4e0b\u4e5f\u53ef\u4ee5\u7ee7\u7eed\uff0c\u4f46\u662f\u4e0d\u80fd\u4f7f\u7528\u6240\u6709\u529f\u80fd\u3002", + "title": "\u65e0\u6cd5\u914d\u5bf9" + }, + "reconfigure": { + "description": "\u91cd\u65b0\u914d\u7f6e\u8bbe\u5907\u4ee5\u6062\u590d\u5176\u529f\u80fd\u3002", + "title": "\u8bbe\u5907\u91cd\u65b0\u914d\u7f6e" + }, + "service_problem": { + "title": "\u6dfb\u52a0\u670d\u52a1\u5931\u8d25" }, "user": { - "description": "\u8981\u5f00\u59cb\uff0c\u8bf7\u8f93\u5165\u8981\u6dfb\u52a0\u7684 Apple TV \u7684\u8bbe\u5907\u540d\u79f0\u6216 IP \u5730\u5740\u3002\u5728\u7f51\u7edc\u4e0a\u81ea\u52a8\u53d1\u73b0\u7684\u8bbe\u5907\u4f1a\u663e\u793a\u5728\u4e0b\u65b9\u3002 \n\n\u5982\u679c\u6ca1\u6709\u53d1\u73b0\u8bbe\u5907\u6216\u9047\u5230\u4efb\u4f55\u95ee\u9898\uff0c\u8bf7\u5c1d\u8bd5\u6307\u5b9a\u8bbe\u5907 IP \u5730\u5740\u3002 \n\n {devices}", + "data": { + "device_input": "\u8bbe\u5907\u5730\u5740" + }, + "description": "\u8981\u5f00\u59cb\uff0c\u8bf7\u8f93\u5165\u8981\u6dfb\u52a0\u7684 Apple TV \u7684\u8bbe\u5907\u540d\u79f0\u6216 IP \u5730\u5740\u3002\n\n\u5982\u679c\u6ca1\u6709\u53d1\u73b0\u8bbe\u5907\u6216\u9047\u5230\u4efb\u4f55\u95ee\u9898\uff0c\u8bf7\u5c1d\u8bd5\u6307\u5b9a\u8bbe\u5907 IP \u5730\u5740\u3002", "title": "\u8bbe\u7f6e\u65b0\u7684 Apple TV" } } diff --git a/homeassistant/components/apple_tv/translations/zh-Hant.json b/homeassistant/components/apple_tv/translations/zh-Hant.json index ea6cbf7d3d4fe..41732c0813ef7 100644 --- a/homeassistant/components/apple_tv/translations/zh-Hant.json +++ b/homeassistant/components/apple_tv/translations/zh-Hant.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_configured_device": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "backoff": "\u88dd\u7f6e\u4e0d\u63a5\u53d7\u6b64\u6b21\u914d\u5c0d\u8acb\u6c42\uff08\u53ef\u80fd\u8f38\u5165\u592a\u591a\u6b21\u7121\u6548\u7684 PIN \u78bc\uff09\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66\u3002", "device_did_not_pair": "\u88dd\u7f6e\u6c92\u6709\u5617\u8a66\u914d\u5c0d\u5b8c\u6210\u904e\u7a0b\u3002", + "device_not_found": "\u641c\u5c0b\u4e0d\u5230\u88dd\u7f6e\u3001\u8acb\u8a66\u8457\u518d\u65b0\u589e\u4e00\u6b21\u3002", + "inconsistent_device": "\u641c\u5c0b\u4e0d\u5230\u9810\u671f\u7684\u901a\u8a0a\u5354\u5b9a\u3002\u901a\u5e38\u539f\u56e0\u70ba Multicast DNS (Zeroconf) \u554f\u984c\u3001\u8acb\u8a66\u8457\u518d\u65b0\u589e\u4e00\u6b21\u3002", "invalid_config": "\u6b64\u88dd\u7f6e\u8a2d\u5b9a\u4e0d\u5b8c\u6574\uff0c\u8acb\u7a0d\u5019\u518d\u8a66\u4e00\u6b21\u3002", "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "setup_failed": "\u88dd\u7f6e\u8a2d\u5b9a\u5931\u6557\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { @@ -16,14 +21,14 @@ "no_usable_service": "\u627e\u5230\u7684\u88dd\u7f6e\u7121\u6cd5\u8b58\u5225\u4ee5\u9032\u884c\u9023\u7dda\u3002\u5047\u5982\u6b64\u8a0a\u606f\u91cd\u8907\u767c\u751f\u3002\u8acb\u8a66\u8457\u6307\u5b9a\u7279\u5b9a IP \u4f4d\u5740\u6216\u91cd\u555f Apple TV\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "Apple TV\uff1a{name}", + "flow_title": "{name} ({type})", "step": { "confirm": { - "description": "\u6b63\u8981\u65b0\u589e\u540d\u70ba `{name}` \u7684 Apple TV \u81f3 Home Assistant\u3002\n\n**\u6b32\u5b8c\u6210\u6b65\u9a5f\uff0c\u5fc5\u9808\u8f38\u5165\u591a\u7d44 PIN \u78bc\u3002**\n\n\u8acb\u6ce8\u610f\uff1a\u6b64\u6574\u5408\u4e26 *\u7121\u6cd5* \u9032\u884c Apple TV \u95dc\u6a5f\u7684\u52d5\u4f5c\uff0c\u50c5\u80fd\u65bc Home Assistant \u4e2d\u95dc\u9589\u5a92\u9ad4\u64ad\u653e\u5668\u529f\u80fd\uff01", + "description": "\u6b63\u8981\u65b0\u589e\u540d\u70ba `{name}` \u7684 `{type}` \u81f3 Home Assistant\u3002\n\n**\u6b32\u5b8c\u6210\u6b65\u9a5f\uff0c\u5fc5\u9808\u8f38\u5165\u591a\u7d44 PIN \u78bc\u3002**\n\n\u8acb\u6ce8\u610f\uff1a\u6b64\u6574\u5408\u4e26 *\u7121\u6cd5* \u9032\u884c Apple TV \u95dc\u6a5f\u7684\u52d5\u4f5c\uff0c\u50c5\u80fd\u65bc Home Assistant \u4e2d\u95dc\u9589\u5a92\u9ad4\u64ad\u653e\u5668\u529f\u80fd\uff01", "title": "\u78ba\u8a8d\u65b0\u589e Apple TV" }, "pair_no_pin": { - "description": "`{protocol}` \u670d\u52d9\u9700\u8981\u9032\u884c\u914d\u5c0d\uff0c\u8acb\u8f38\u5165 Apple TV \u4e0a\u6240\u986f\u793a\u4e4b PIN {pin} \u4ee5\u7e7c\u7e8c\u3002", + "description": "`{protocol}` \u670d\u52d9\u9700\u8981\u9032\u884c\u914d\u5c0d\uff0c\u8acb\u8f38\u5165\u88dd\u7f6e\u4e0a\u6240\u986f\u793a\u4e4b PIN {pin} \u4ee5\u7e7c\u7e8c\u3002", "title": "\u914d\u5c0d\u4e2d" }, "pair_with_pin": { @@ -33,8 +38,16 @@ "description": "\u914d\u5c0d\u9700\u8981 `{protocol}` \u901a\u8a0a\u5354\u5b9a\u3002\u8acb\u8f38\u5165\u986f\u793a\u65bc\u756b\u9762\u4e0a\u7684 PIN \u78bc\uff0c\u524d\u65b9\u7684 0 \u53ef\u5ffd\u8996\u986f\u793a\u78bc\u70ba 0123\uff0c\u5247\u8f38\u5165 123\u3002", "title": "\u914d\u5c0d\u4e2d" }, + "password": { + "description": "`{protocol}` \u9700\u8981\u5bc6\u78bc\u65b9\u80fd\u9032\u884c\u3002\u4f46\u76ee\u524d\u4e26\u4e0d\u652f\u63f4\u6b64\u529f\u80fd\uff0c\u8acb\u95dc\u9589\u5f8c\u7e7c\u7e8c\u9032\u884c\u3002\u3002", + "title": "\u5fc5\u8981\u5bc6\u78bc" + }, + "protocol_disabled": { + "description": "\u914d\u5c0d\u9700\u8981 `{protocol}` \u958b\u555f\u65b9\u80fd\u9032\u884c\u3001\u4f46\u76ee\u524d\u70ba\u95dc\u9589\u72c0\u614b\u3002\u8acb\u67e5\u770b\u88dd\u5099\u4e0a\u7684\u5b58\u53d6\u9650\u5236\u8a2d\u5b9a\uff08\u4f8b\u5982\u5141\u8a31\u672c\u5730\u7aef\u7db2\u8def\u9023\u7dda\uff09\u3002\n\n\u53ef\u4ee5\u4e0d\u958b\u555f\u6b64\u5354\u5b9a\u7e7c\u7e8c\u914d\u5c0d\uff0c\u4f46\u90e8\u5206\u529f\u80fd\u5c07\u6703\u53d7\u5230\u9650\u5236\u3002", + "title": "\u7121\u6cd5\u914d\u5c0d" + }, "reconfigure": { - "description": "\u6b64 Apple TV \u906d\u9047\u5230\u4e00\u4e9b\u9023\u7dda\u554f\u984c\uff0c\u5fc5\u9808\u91cd\u65b0\u8a2d\u5b9a\u3002", + "description": "\u5fc5\u9808\u91cd\u65b0\u8a2d\u5b9a\u6b64\u88dd\u7f6e\u4ee5\u6062\u5fa9\u5176\u529f\u80fd\u3002", "title": "\u88dd\u7f6e\u91cd\u65b0\u8a2d\u5b9a" }, "service_problem": { @@ -45,7 +58,7 @@ "data": { "device_input": "\u88dd\u7f6e" }, - "description": "\u9996\u5148\u8f38\u5165\u6240\u8981\u65b0\u589e\u7684 Apple TV \u88dd\u7f6e\u540d\u7a31\uff08\u4f8b\u5982\u5eda\u623f\u6216\u81e5\u5ba4\uff09\u6216 IP \u4f4d\u5740\u3002\u5047\u5982\u65bc\u5340\u7db2\u4e0a\u627e\u5230\u4efb\u4f55\u88dd\u7f6e\uff0c\u5c07\u6703\u986f\u793a\u65bc\u4e0b\u65b9\u3002\n\n\u5047\u5982\u7121\u6cd5\u770b\u5230\u88dd\u7f6e\u6216\u906d\u9047\u4efb\u4f55\u554f\u984c\uff0c\u8acb\u8a66\u8457\u6307\u5b9a\u88dd\u7f6e\u7684 IP \u4f4d\u5740\u3002\n\n{devices}", + "description": "\u9996\u5148\u8f38\u5165\u6240\u8981\u65b0\u589e\u7684 Apple TV \u88dd\u7f6e\u540d\u7a31\uff08\u4f8b\u5982\u5eda\u623f\u6216\u81e5\u5ba4\uff09\u6216 IP \u4f4d\u5740\u3002\n\n\u5047\u5982\u7121\u6cd5\u770b\u5230\u88dd\u7f6e\u6216\u906d\u9047\u4efb\u4f55\u554f\u984c\uff0c\u8acb\u8a66\u8457\u6307\u5b9a\u88dd\u7f6e\u7684 IP \u4f4d\u5740\u3002", "title": "\u8a2d\u5b9a\u4e00\u7d44 Apple TV" } } diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index f9e6305678a89..4e0209cc337db 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.8.9"], + "requirements": ["apprise==0.9.6"], "codeowners": ["@caronc"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index 1ce34c8a7518c..f86a5a4648d7f 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -8,7 +8,9 @@ import geopy.distance import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, +) from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, @@ -44,7 +46,7 @@ MSG_FORMATS = ["compressed", "uncompressed", "mic-e"] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_CALLSIGNS): cv.ensure_list, vol.Required(CONF_USERNAME): cv.string, @@ -176,7 +178,7 @@ def rx_msg(self, msg: dict): _LOGGER.warning( "APRS message contained invalid posambiguity: %s", str(pos_amb) ) - for attr in [ATTR_ALTITUDE, ATTR_COMMENT, ATTR_COURSE, ATTR_SPEED]: + for attr in (ATTR_ALTITUDE, ATTR_COMMENT, ATTR_COURSE, ATTR_SPEED): if attr in msg: attrs[attr] = msg[attr] diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json index 5879c12235652..29216e622da47 100644 --- a/homeassistant/components/aprs/manifest.json +++ b/homeassistant/components/aprs/manifest.json @@ -3,6 +3,6 @@ "name": "APRS", "documentation": "https://www.home-assistant.io/integrations/aprs", "codeowners": ["@PhilRW"], - "requirements": ["aprslib==0.6.46", "geopy==1.21.0"], + "requirements": ["aprslib==0.6.46", "geopy==2.1.0"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 315b039f778af..7d66e558e6fb9 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -1,8 +1,16 @@ """Support for AquaLogic sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, PERCENTAGE, @@ -15,30 +23,88 @@ from . import DOMAIN, UPDATE_TOPIC -TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] -PERCENT_UNITS = [PERCENTAGE, PERCENTAGE] -SALT_UNITS = ["g/L", "PPM"] -WATT_UNITS = [POWER_WATT, POWER_WATT] -NO_UNITS = [None, None] - -# sensor_type [ description, unit, icon ] -# sensor_type corresponds to property names in aqualogic.core.AquaLogic -SENSOR_TYPES = { - "air_temp": ["Air Temperature", TEMP_UNITS, "mdi:thermometer"], - "pool_temp": ["Pool Temperature", TEMP_UNITS, "mdi:oil-temperature"], - "spa_temp": ["Spa Temperature", TEMP_UNITS, "mdi:oil-temperature"], - "pool_chlorinator": ["Pool Chlorinator", PERCENT_UNITS, "mdi:gauge"], - "spa_chlorinator": ["Spa Chlorinator", PERCENT_UNITS, "mdi:gauge"], - "salt_level": ["Salt Level", SALT_UNITS, "mdi:gauge"], - "pump_speed": ["Pump Speed", PERCENT_UNITS, "mdi:speedometer"], - "pump_power": ["Pump Power", WATT_UNITS, "mdi:gauge"], - "status": ["Status", NO_UNITS, "mdi:alert"], -} + +@dataclass +class AquaLogicSensorEntityDescription(SensorEntityDescription): + """Describes AquaLogic sensor entity.""" + + unit_metric: str | None = None + unit_imperial: str | None = None + + +# keys correspond to property names in aqualogic.core.AquaLogic +SENSOR_TYPES: tuple[AquaLogicSensorEntityDescription, ...] = ( + AquaLogicSensorEntityDescription( + key="air_temp", + name="Air Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="pool_temp", + name="Pool Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + icon="mdi:oil-temperature", + device_class=SensorDeviceClass.TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="spa_temp", + name="Spa Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + icon="mdi:oil-temperature", + device_class=SensorDeviceClass.TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="pool_chlorinator", + name="Pool Chlorinator", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="spa_chlorinator", + name="Spa Chlorinator", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="salt_level", + name="Salt Level", + unit_metric="g/L", + unit_imperial="PPM", + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="pump_speed", + name="Pump Speed", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:speedometer", + ), + AquaLogicSensorEntityDescription( + key="pump_power", + name="Pump Power", + unit_metric=POWER_WATT, + unit_imperial=POWER_WATT, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="status", + name="Status", + icon="mdi:alert", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -46,53 +112,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" - sensors = [] - processor = hass.data[DOMAIN] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(AquaLogicSensor(processor, sensor_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - async_add_entities(sensors) + entities = [ + AquaLogicSensor(processor, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + + async_add_entities(entities) class AquaLogicSensor(SensorEntity): """Sensor implementation for the AquaLogic component.""" - def __init__(self, processor, sensor_type): + entity_description: AquaLogicSensorEntityDescription + _attr_should_poll = False + + def __init__(self, processor, description: AquaLogicSensorEntityDescription): """Initialize sensor.""" + self.entity_description = description self._processor = processor - self._type = sensor_type - self._state = None - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def name(self): - """Return the name of the sensor.""" - return f"AquaLogic {SENSOR_TYPES[self._type][0]}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement the value is expressed in.""" - panel = self._processor.panel - if panel is None: - return None - if panel.is_metric: - return SENSOR_TYPES[self._type][1][0] - return SENSOR_TYPES[self._type][1][1] - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self._type][2] + self._attr_name = f"AquaLogic {description.name}" async def async_added_to_hass(self): """Register callbacks.""" @@ -105,7 +147,17 @@ async def async_added_to_hass(self): @callback def async_update_callback(self): """Update callback.""" - panel = self._processor.panel - if panel is not None: - self._state = getattr(panel, self._type) + if (panel := self._processor.panel) is not None: + if panel.is_metric: + self._attr_native_unit_of_measurement = ( + self.entity_description.unit_metric + ) + else: + self._attr_native_unit_of_measurement = ( + self.entity_description.unit_imperial + ) + + self._attr_native_value = getattr(panel, self.entity_description.key) self.async_write_ha_state() + else: + self._attr_native_unit_of_measurement = None diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 08bba4cbd2d2a..157688c75768e 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -44,10 +44,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AquaLogicSwitch(SwitchEntity): """Switch implementation for the AquaLogic component.""" + _attr_should_poll = False + def __init__(self, processor, switch_type): """Initialize switch.""" self._processor = processor - self._type = switch_type self._state_name = { "lights": States.LIGHTS, "filter": States.FILTER, @@ -60,37 +61,25 @@ def __init__(self, processor, switch_type): "aux_6": States.AUX_6, "aux_7": States.AUX_7, }[switch_type] - - @property - def name(self): - """Return the name of the switch.""" - return f"AquaLogic {SWITCH_TYPES[self._type]}" - - @property - def should_poll(self): - """Return the polling state.""" - return False + self._attr_name = f"AquaLogic {SWITCH_TYPES[switch_type]}" @property def is_on(self): """Return true if device is on.""" - panel = self._processor.panel - if panel is None: + if (panel := self._processor.panel) is None: return False state = panel.get_state(self._state_name) return state def turn_on(self, **kwargs): """Turn the device on.""" - panel = self._processor.panel - if panel is None: + if (panel := self._processor.panel) is None: return panel.set_state(self._state_name, True) def turn_off(self, **kwargs): """Turn the device off.""" - panel = self._processor.panel - if panel is None: + if (panel := self._processor.panel) is None: return panel.set_state(self._state_name, False) diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 35c7e2ae64698..50dd70bddccfd 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -124,33 +124,30 @@ def wrapper(obj, *args, **kwargs): class SharpAquosTVDevice(MediaPlayerEntity): """Representation of a Aquos TV.""" + _attr_source_list = list(SOURCES.values()) + _attr_supported_features = SUPPORT_SHARPTV + def __init__(self, name, remote, power_on_enabled=False): """Initialize the aquos device.""" - self._supported_features = SUPPORT_SHARPTV self._power_on_enabled = power_on_enabled - if self._power_on_enabled: - self._supported_features |= SUPPORT_TURN_ON + if power_on_enabled: + self._attr_supported_features |= SUPPORT_TURN_ON # Save a reference to the imported class - self._name = name + self._attr_name = name # Assume that the TV is not muted - self._muted = False - self._state = None self._remote = remote - self._volume = 0 - self._source = None - self._source_list = list(SOURCES.values()) def set_state(self, state): """Set TV state.""" - self._state = state + self._attr_state = state @_retry def update(self): """Retrieve the latest data.""" if self._remote.power() == 1: - self._state = STATE_ON + self._attr_state = STATE_ON else: - self._state = STATE_OFF + self._attr_state = STATE_OFF # Set TV to be able to remotely power on if self._power_on_enabled: self._remote.power_on_command_settings(2) @@ -158,48 +155,13 @@ def update(self): self._remote.power_on_command_settings(0) # Get mute state if self._remote.mute() == 2: - self._muted = False + self._attr_is_volume_muted = False else: - self._muted = True + self._attr_is_volume_muted = True # Get source - self._source = SOURCES.get(self._remote.input()) + self._attr_source = SOURCES.get(self._remote.input()) # Get volume - self._volume = self._remote.volume() / 60 - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def source(self): - """Return the current source.""" - return self._source - - @property - def source_list(self): - """Return the source list.""" - return self._source_list - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return self._supported_features + self._attr_volume_level = self._remote.volume() / 60 @_retry def turn_off(self): @@ -209,12 +171,12 @@ def turn_off(self): @_retry def volume_up(self): """Volume up the media player.""" - self._remote.volume(int(self._volume * 60) + 2) + self._remote.volume(int(self.volume_level * 60) + 2) @_retry def volume_down(self): """Volume down media player.""" - self._remote.volume(int(self._volume * 60) - 2) + self._remote.volume(int(self.volume_level * 60) - 2) @_retry def set_volume_level(self, volume): diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index e1dfac09d76ff..3cb4c83211f4b 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -7,8 +7,8 @@ from arcam.fmj.client import Client import async_timeout -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -25,9 +25,9 @@ _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = ["media_player"] +PLATFORMS = [Platform.MEDIA_PLAYER] async def _await_cancel(task): @@ -36,14 +36,14 @@ async def _await_cancel(task): await task -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" hass.data[DOMAIN_DATA_ENTRIES] = {} hass.data[DOMAIN_DATA_TASKS] = {} async def _stop(_): asyncio.gather( - *[_await_cancel(task) for task in hass.data[DOMAIN_DATA_TASKS].values()] + *(_await_cancel(task) for task in hass.data[DOMAIN_DATA_TASKS].values()) ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop) @@ -51,7 +51,7 @@ async def _stop(_): return True -async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" entries = hass.data[DOMAIN_DATA_ENTRIES] tasks = hass.data[DOMAIN_DATA_TASKS] @@ -85,7 +85,7 @@ def _listen(_): while True: try: - with async_timeout.timeout(interval): + async with async_timeout.timeout(interval): await client.start() _LOGGER.debug("Client connected %s", client.host) diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index cbf707c14e656..2570fd1aea564 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -6,8 +6,9 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_UDN +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN, DOMAIN_DATA_ENTRIES @@ -84,11 +85,11 @@ async def async_step_confirm(self, user_input=None): step_id="confirm", description_placeholders=placeholders ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered device.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location).hostname port = DEFAULT_PORT - uuid = get_uniqueid_from_udn(discovery_info[ATTR_UPNP_UDN]) + uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) await self._async_set_unique_id_and_update(host, port, uuid) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index 4ae34abb2c2a6..ed9308a89c610 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -1,10 +1,15 @@ """Provides device automations for Arcam FMJ Receiver control.""" from __future__ import annotations +from typing import Any + import voluptuous as vol -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -20,7 +25,7 @@ from .const import DOMAIN, EVENT_TURN_ON TRIGGER_TYPES = {"turn_on"} -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), @@ -28,7 +33,9 @@ ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Arcam FMJ Receiver control devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -53,10 +60,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None + trigger_data = automation_info["trigger_data"] job = HassJob(action) if config[CONF_TYPE] == "turn_on": @@ -69,9 +76,9 @@ def _handle_event(event: Event): job, { "trigger": { + **trigger_data, **config, "description": f"{DOMAIN} - {entity_id}", - "id": trigger_id, } }, event.context, diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index d38ceceba7305..08545f4c5b057 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -3,7 +3,7 @@ "name": "Arcam FMJ Receivers", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", - "requirements": ["arcam-fmj==0.5.3"], + "requirements": ["arcam-fmj==0.12.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 8a119d020fe47..553524dbcdf33 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -1,7 +1,7 @@ """Arcam media player.""" import logging -from arcam.fmj import DecodeMode2CH, DecodeModeMCH, IncomingAudioFormat, SourceCodes +from arcam.fmj import SourceCodes from arcam.fmj.state import State from homeassistant import config_entries @@ -23,6 +23,7 @@ from homeassistant.components.media_player.errors import BrowseError from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from .config_flow import get_entry_client from .const import ( @@ -52,7 +53,7 @@ async def async_setup_entry( State(client, zone), config_entry.unique_id or config_entry.entry_id, ) - for zone in [1, 2] + for zone in (1, 2) ], True, ) @@ -63,6 +64,8 @@ async def async_setup_entry( class ArcamFmj(MediaPlayerEntity): """Representation of a media device.""" + _attr_should_poll = False + def __init__( self, device_name, @@ -72,9 +75,9 @@ def __init__( """Initialize device.""" self._state = state self._device_name = device_name - self._name = f"{device_name} - Zone: {state.zn}" + self._attr_name = f"{device_name} - Zone: {state.zn}" self._uuid = uuid - self._support = ( + self._attr_supported_features = ( SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA | SUPPORT_BROWSE_MEDIA @@ -85,53 +88,9 @@ def __init__( | SUPPORT_TURN_ON ) if state.zn == 1: - self._support |= SUPPORT_SELECT_SOUND_MODE - - def _get_2ch(self): - """Return if source is 2 channel or not.""" - audio_format, _ = self._state.get_incoming_audio_format() - return bool( - audio_format - in ( - IncomingAudioFormat.PCM, - IncomingAudioFormat.ANALOGUE_DIRECT, - IncomingAudioFormat.UNDETECTED, - None, - ) - ) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._state.zn == 1 - - @property - def unique_id(self): - """Return unique identifier if known.""" - return f"{self._uuid}-{self._state.zn}" - - @property - def device_info(self): - """Return a device description for device registry.""" - return { - "name": self._device_name, - "identifiers": { - (DOMAIN, self._uuid), - (DOMAIN, self._state.client.host, self._state.client.port), - }, - "model": "Arcam FMJ AVR", - "manufacturer": "Arcam", - } - - @property - def should_poll(self) -> bool: - """No need to poll.""" - return False - - @property - def name(self): - """Return the name of the controlled device.""" - return self._name + self._attr_supported_features |= SUPPORT_SELECT_SOUND_MODE + self._attr_unique_id = f"{uuid}-{state.zn}" + self._attr_entity_registry_enabled_default = state.zn == 1 @property def state(self): @@ -141,13 +100,22 @@ def state(self): return STATE_OFF @property - def supported_features(self): - """Flag media player features that are supported.""" - return self._support + def device_info(self): + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self._uuid), + (DOMAIN, self._state.client.host, self._state.client.port), + }, + manufacturer="Arcam", + model="Arcam FMJ AVR", + name=self._device_name, + ) async def async_added_to_hass(self): """Once registered, add listener for events.""" await self._state.start() + await self._state.update() @callback def _data(host): @@ -206,11 +174,8 @@ async def async_select_source(self, source): async def async_select_sound_mode(self, sound_mode): """Select a specific source.""" try: - if self._get_2ch(): - await self._state.set_decode_mode_2ch(DecodeMode2CH[sound_mode]) - else: - await self._state.set_decode_mode_mch(DecodeModeMCH[sound_mode]) - except KeyError: + await self._state.set_decode_mode(sound_mode) + except (KeyError, ValueError): _LOGGER.error("Unsupported sound_mode %s", sound_mode) return @@ -290,8 +255,7 @@ async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> No @property def source(self): """Return the current input source.""" - value = self._state.get_source() - if value is None: + if (value := self._state.get_source()) is None: return None return value.name @@ -303,40 +267,28 @@ def source_list(self): @property def sound_mode(self): """Name of the current sound mode.""" - if self._state.zn != 1: + if (value := self._state.get_decode_mode()) is None: return None - - if self._get_2ch(): - value = self._state.get_decode_mode_2ch() - else: - value = self._state.get_decode_mode_mch() - if value: - return value.name - return None + return value.name @property def sound_mode_list(self): """List of available sound modes.""" - if self._state.zn != 1: + if (values := self._state.get_decode_modes()) is None: return None - - if self._get_2ch(): - return [x.name for x in DecodeMode2CH] - return [x.name for x in DecodeModeMCH] + return [x.name for x in values] @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - value = self._state.get_mute() - if value is None: + if (value := self._state.get_mute()) is None: return None return value @property def volume_level(self): """Volume level of device.""" - value = self._state.get_volume() - if value is None: + if (value := self._state.get_volume()) is None: return None return value / 99.0 @@ -357,8 +309,7 @@ def media_content_id(self): """Content type of current playing media.""" source = self._state.get_source() if source in (SourceCodes.DAB, SourceCodes.FM): - preset = self._state.get_tuner_preset() - if preset: + if preset := self._state.get_tuner_preset(): value = f"preset:{preset}" else: value = None @@ -382,8 +333,7 @@ def media_channel(self): @property def media_artist(self): """Artist of current playing media, music track only.""" - source = self._state.get_source() - if source == SourceCodes.DAB: + if self._state.get_source() == SourceCodes.DAB: value = self._state.get_dls_pdt() else: value = None @@ -392,13 +342,10 @@ def media_artist(self): @property def media_title(self): """Title of current playing media.""" - source = self._state.get_source() - if source is None: + if (source := self._state.get_source()) is None: return None - channel = self.media_channel - - if channel: + if channel := self.media_channel: value = f"{source.name} - {channel}" else: value = source.name diff --git a/homeassistant/components/arcam_fmj/strings.json b/homeassistant/components/arcam_fmj/strings.json index 154727baf9fa4..435a6971d5bb3 100644 --- a/homeassistant/components/arcam_fmj/strings.json +++ b/homeassistant/components/arcam_fmj/strings.json @@ -5,7 +5,6 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, - "error": {}, "flow_title": "{host}", "step": { "confirm": { diff --git a/homeassistant/components/arcam_fmj/translations/bg.json b/homeassistant/components/arcam_fmj/translations/bg.json new file mode 100644 index 0000000000000..f24b5481b2ce9 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "flow_title": "{host}", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043c\u0435\u0442\u043e \u043d\u0430 \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/ca.json b/homeassistant/components/arcam_fmj/translations/ca.json index 6d30f32e16a74..84ed4dd89902c 100644 --- a/homeassistant/components/arcam_fmj/translations/ca.json +++ b/homeassistant/components/arcam_fmj/translations/ca.json @@ -5,7 +5,7 @@ "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "Arcam FMJ a {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Vols afegir l'Arcam FMJ `{host}` a Home Assistant?" diff --git a/homeassistant/components/arcam_fmj/translations/de.json b/homeassistant/components/arcam_fmj/translations/de.json index 1f67a8d30a94e..684a2f189614a 100644 --- a/homeassistant/components/arcam_fmj/translations/de.json +++ b/homeassistant/components/arcam_fmj/translations/de.json @@ -5,7 +5,7 @@ "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "Arcam FMJ auf {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "M\u00f6chtest du Arcam FMJ auf `{host}` zum Home Assistant hinzuf\u00fcgen?" diff --git a/homeassistant/components/arcam_fmj/translations/en.json b/homeassistant/components/arcam_fmj/translations/en.json index 20a71df9d6705..891f268aa1f00 100644 --- a/homeassistant/components/arcam_fmj/translations/en.json +++ b/homeassistant/components/arcam_fmj/translations/en.json @@ -5,7 +5,6 @@ "already_in_progress": "Configuration flow is already in progress", "cannot_connect": "Failed to connect" }, - "error": {}, "flow_title": "{host}", "step": { "confirm": { diff --git a/homeassistant/components/arcam_fmj/translations/es-419.json b/homeassistant/components/arcam_fmj/translations/es-419.json new file mode 100644 index 0000000000000..a69b353354b74 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u00bfDesea agregar Arcam FMJ en `{host}` a Home Assistant?" + }, + "user": { + "description": "Ingrese el nombre de host o la direcci\u00f3n IP del dispositivo." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/et.json b/homeassistant/components/arcam_fmj/translations/et.json index 84735beefabc5..60f1895039ed5 100644 --- a/homeassistant/components/arcam_fmj/translations/et.json +++ b/homeassistant/components/arcam_fmj/translations/et.json @@ -5,7 +5,7 @@ "already_in_progress": "Seadistamine on juba k\u00e4imas", "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "Arcam FMJ saidil {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Kas soovid lisada Arcam FMJ \u00fcksuse {host} Home Assistanti?" diff --git a/homeassistant/components/arcam_fmj/translations/fr.json b/homeassistant/components/arcam_fmj/translations/fr.json index 511d9e98a505a..363621cc94abe 100644 --- a/homeassistant/components/arcam_fmj/translations/fr.json +++ b/homeassistant/components/arcam_fmj/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_configured": "L'appareil \u00e9tait d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "error": { "one": "Vide", "other": "Vide" }, - "flow_title": "Arcam FMJ sur {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Voulez-vous ajouter Arcam FMJ sur ` {host} ` \u00e0 HomeAssistant ?" diff --git a/homeassistant/components/arcam_fmj/translations/he.json b/homeassistant/components/arcam_fmj/translations/he.json new file mode 100644 index 0000000000000..447d79eed2810 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{host}", + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Arcam FMJ \u05d1-`{host}` \u05dc-Home Assistant?" + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + }, + "description": "\u05d0\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05e9\u05dd \u05d4\u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05d0\u05ea \u05db\u05ea\u05d5\u05d1\u05ea \u05d4-IP \u05e9\u05dc \u05d4\u05d4\u05ea\u05e7\u05df." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/hu.json b/homeassistant/components/arcam_fmj/translations/hu.json index 4af1181a265b2..964ebe2a33d67 100644 --- a/homeassistant/components/arcam_fmj/translations/hu.json +++ b/homeassistant/components/arcam_fmj/translations/hu.json @@ -2,16 +2,30 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, + "error": { + "one": "\u00dcres", + "other": "\u00dcres" + }, + "flow_title": "{host}", "step": { + "confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni az Arcam FMJ \"{host}\" eszk\u00f6zt a HomeAssistanthoz?" + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" - } + }, + "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z hosztnev\u00e9t vagy c\u00edm\u00e9t" } } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} bekapcsol\u00e1s\u00e1t k\u00e9rt\u00e9k" + } } } \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/id.json b/homeassistant/components/arcam_fmj/translations/id.json index 96b10140948bc..cee43cbb4e9fb 100644 --- a/homeassistant/components/arcam_fmj/translations/id.json +++ b/homeassistant/components/arcam_fmj/translations/id.json @@ -5,7 +5,7 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung", "cannot_connect": "Gagal terhubung" }, - "flow_title": "Arcam FMJ di {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Ingin menambahkan Arcam FMJ `{host}` ke Home Assistant?" diff --git a/homeassistant/components/arcam_fmj/translations/it.json b/homeassistant/components/arcam_fmj/translations/it.json index f5cef4cd8b07c..2b99566888ba3 100644 --- a/homeassistant/components/arcam_fmj/translations/it.json +++ b/homeassistant/components/arcam_fmj/translations/it.json @@ -5,7 +5,11 @@ "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "cannot_connect": "Impossibile connettersi" }, - "flow_title": "Arcam FMJ su {host}", + "error": { + "one": "Pi\u00f9", + "other": "Altri" + }, + "flow_title": "{host}", "step": { "confirm": { "description": "Vuoi aggiungere Arcam FMJ su `{host}` a Home Assistant?" diff --git a/homeassistant/components/arcam_fmj/translations/ja.json b/homeassistant/components/arcam_fmj/translations/ja.json new file mode 100644 index 0000000000000..2ba5cd17aa0df --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{host}", + "step": { + "confirm": { + "description": "Home Assistant\u306bArcam FMJ on `{host}` \u3092\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} \u3092\u30aa\u30f3\u306b\u3059\u308b\u3088\u3046\u306b\u30ea\u30af\u30a8\u30b9\u30c8\u3055\u308c\u307e\u3057\u305f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/nl.json b/homeassistant/components/arcam_fmj/translations/nl.json index 03465d5c53df4..45a7be867b9fc 100644 --- a/homeassistant/components/arcam_fmj/translations/nl.json +++ b/homeassistant/components/arcam_fmj/translations/nl.json @@ -9,7 +9,7 @@ "one": "Leeg", "other": "Leeg" }, - "flow_title": "Arcam FMJ op {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Wil je Arcam FMJ op `{host}` toevoegen aan Home Assistant?" diff --git a/homeassistant/components/arcam_fmj/translations/no.json b/homeassistant/components/arcam_fmj/translations/no.json index e98f943f56524..8e4d28d80b815 100644 --- a/homeassistant/components/arcam_fmj/translations/no.json +++ b/homeassistant/components/arcam_fmj/translations/no.json @@ -5,7 +5,7 @@ "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "Arcam FMJ p\u00e5 {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Vil du legge Arcam FMJ p\u00e5 `{host}` til Home Assistant?" diff --git a/homeassistant/components/arcam_fmj/translations/pl.json b/homeassistant/components/arcam_fmj/translations/pl.json index af28552d89241..1373aae5cf283 100644 --- a/homeassistant/components/arcam_fmj/translations/pl.json +++ b/homeassistant/components/arcam_fmj/translations/pl.json @@ -11,7 +11,7 @@ "one": "jeden", "other": "inne" }, - "flow_title": "Arcam FMJ na {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Czy chcesz doda\u0107 Arcam FMJ na \"{host}\" do Home Assistanta?" diff --git a/homeassistant/components/arcam_fmj/translations/ru.json b/homeassistant/components/arcam_fmj/translations/ru.json index 8b3c309274553..20f44f068de6c 100644 --- a/homeassistant/components/arcam_fmj/translations/ru.json +++ b/homeassistant/components/arcam_fmj/translations/ru.json @@ -5,7 +5,7 @@ "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, - "flow_title": "Arcam FMJ {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Arcam FMJ `{host}`?" diff --git a/homeassistant/components/arcam_fmj/translations/tr.json b/homeassistant/components/arcam_fmj/translations/tr.json index dd15f57212caa..7943dece765a9 100644 --- a/homeassistant/components/arcam_fmj/translations/tr.json +++ b/homeassistant/components/arcam_fmj/translations/tr.json @@ -5,13 +5,27 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "error": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "flow_title": "{host}", "step": { + "confirm": { + "description": "Arcam FMJ'yi ` {host} ` \u00fczerinde Home Assistant'a eklemek istiyor musunuz?" + }, "user": { "data": { "host": "Ana Bilgisayar", "port": "Port" - } + }, + "description": "L\u00fctfen cihaz\u0131n ana bilgisayar ad\u0131n\u0131 veya IP adresini girin." } } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} nin a\u00e7\u0131lmas\u0131 istendi" + } } } \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/zh-Hant.json b/homeassistant/components/arcam_fmj/translations/zh-Hant.json index 4c7455f844404..358805e0de6fe 100644 --- a/homeassistant/components/arcam_fmj/translations/zh-Hant.json +++ b/homeassistant/components/arcam_fmj/translations/zh-Hant.json @@ -5,7 +5,7 @@ "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "Arcam FMJ \uff08{host}\uff09", + "flow_title": "{host}", "step": { "confirm": { "description": "\u662f\u5426\u8981\u5c07 Arcam FMJ `{host}` \u65b0\u589e\u81f3 Home Assistant\uff1f" diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py index 588a652660aad..0853fb5537da3 100644 --- a/homeassistant/components/arduino/sensor.py +++ b/homeassistant/components/arduino/sensor.py @@ -35,24 +35,11 @@ class ArduinoSensor(SensorEntity): def __init__(self, name, pin, pin_type, board): """Initialize the sensor.""" self._pin = pin - self._name = name - self.pin_type = pin_type - self.direction = "in" - self._value = None + self._attr_name = name - board.set_mode(self._pin, self.direction, self.pin_type) + board.set_mode(self._pin, "in", pin_type) self._board = board - @property - def state(self): - """Return the state of the sensor.""" - return self._value - - @property - def name(self): - """Get the name of the sensor.""" - return self._name - def update(self): """Get the latest value from the pin.""" - self._value = self._board.get_analog_inputs()[self._pin][1] + self._attr_native_value = self._board.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arduino/switch.py b/homeassistant/components/arduino/switch.py index 6ee742fd50679..9368426b38e9d 100644 --- a/homeassistant/components/arduino/switch.py +++ b/homeassistant/components/arduino/switch.py @@ -43,11 +43,9 @@ class ArduinoSwitch(SwitchEntity): def __init__(self, pin, options, board): """Initialize the Pin.""" self._pin = pin - self._name = options[CONF_NAME] - self.pin_type = CONF_TYPE - self.direction = "out" + self._attr_name = options[CONF_NAME] - self._state = options[CONF_INITIAL] + self._attr_is_on = options[CONF_INITIAL] if options[CONF_NEGATE]: self.turn_on_handler = board.set_digital_out_low @@ -56,25 +54,15 @@ def __init__(self, pin, options, board): self.turn_on_handler = board.set_digital_out_high self.turn_off_handler = board.set_digital_out_low - board.set_mode(self._pin, self.direction, self.pin_type) - (self.turn_on_handler if self._state else self.turn_off_handler)(pin) - - @property - def name(self): - """Get the name of the pin.""" - return self._name - - @property - def is_on(self): - """Return true if pin is high/on.""" - return self._state + board.set_mode(pin, "out", CONF_TYPE) + (self.turn_on_handler if self.is_on else self.turn_off_handler)(pin) def turn_on(self, **kwargs): """Turn the pin to high/on.""" - self._state = True + self._attr_is_on = True self.turn_on_handler(self._pin) def turn_off(self, **kwargs): """Turn the pin to low/off.""" - self._state = False + self._attr_is_on = False self.turn_off_handler(self._pin) diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 3cd9038f1a89b..1280e013f8d04 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -1,5 +1,6 @@ """Support for an exposed aREST RESTful API of a device.""" from datetime import timedelta +from http import HTTPStatus import logging import requests @@ -10,13 +11,7 @@ PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_NAME, - CONF_PIN, - CONF_RESOURCE, - HTTP_OK, -) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -73,34 +68,18 @@ class ArestBinarySensor(BinarySensorEntity): def __init__(self, arest, resource, name, device_class, pin): """Initialize the aREST device.""" self.arest = arest - self._resource = resource - self._name = name - self._device_class = device_class - self._pin = pin - - if self._pin is not None: - request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) - if request.status_code != HTTP_OK: - _LOGGER.error("Can't set mode of %s", self._resource) - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return bool(self.arest.data.get("state")) + self._attr_name = name + self._attr_device_class = device_class - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class + if pin is not None: + request = requests.get(f"{resource}/mode/{pin}/i", timeout=10) + if request.status_code != HTTPStatus.OK: + _LOGGER.error("Can't set mode of %s", resource) def update(self): """Get the latest data from aREST API.""" self.arest.update() + self._attr_is_on = bool(self.arest.data.get("state")) class ArestData: diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 061c15eafb04b..7ca6d230a0813 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -1,5 +1,6 @@ """Support for an exposed aREST RESTful API of a device.""" from datetime import timedelta +from http import HTTPStatus import logging import requests @@ -12,7 +13,6 @@ CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - HTTP_OK, ) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv @@ -139,48 +139,27 @@ def __init__( ): """Initialize the sensor.""" self.arest = arest - self._resource = resource - self._name = f"{location.title()} {name.title()}" + self._attr_name = f"{location.title()} {name.title()}" self._variable = variable - self._pin = pin - self._state = None - self._unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._renderer = renderer - if self._pin is not None: - request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) - if request.status_code != HTTP_OK: - _LOGGER.error("Can't set mode of %s", self._resource) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - values = self.arest.data - - if "error" in values: - return values["error"] - - value = self._renderer(values.get("value", values.get(self._variable, None))) - return value + if pin is not None: + request = requests.get(f"{resource}/mode/{pin}/i", timeout=10) + if request.status_code != HTTPStatus.OK: + _LOGGER.error("Can't set mode of %s", resource) def update(self): """Get the latest data from aREST API.""" self.arest.update() - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self.arest.available + self._attr_available = self.arest.available + values = self.arest.data + if "error" in values: + self._attr_native_value = values["error"] + else: + self._attr_native_value = self._renderer( + values.get("value", values.get(self._variable, None)) + ) class ArestData: @@ -191,7 +170,7 @@ def __init__(self, resource, pin=None): self._resource = resource self._pin = pin self.data = {} - self.available = True + self._attr_available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -212,7 +191,7 @@ def update(self): f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self.available = True + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self.available = False + self._attr_available = False diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index ddd6b51f76d32..97a763cb652ec 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -1,12 +1,13 @@ """Support for an exposed aREST RESTful API of a device.""" +from http import HTTPStatus import logging import requests import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import CONF_NAME, CONF_RESOURCE, HTTP_OK +from homeassistant.const import CONF_NAME, CONF_RESOURCE import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -86,24 +87,9 @@ class ArestSwitchBase(SwitchEntity): def __init__(self, resource, location, name): """Initialize the switch.""" self._resource = resource - self._name = f"{location.title()} {name.title()}" - self._state = None - self._available = True - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + self._attr_name = f"{location.title()} {name.title()}" + self._attr_available = True + self._attr_is_on = False class ArestSwitchFunction(ArestSwitchBase): @@ -116,7 +102,7 @@ def __init__(self, resource, location, name, func): request = requests.get(f"{self._resource}/{self._func}", timeout=10) - if request.status_code != HTTP_OK: + if request.status_code != HTTPStatus.OK: _LOGGER.error("Can't find function") return @@ -133,8 +119,8 @@ def turn_on(self, **kwargs): f"{self._resource}/{self._func}", timeout=10, params={"params": "1"} ) - if request.status_code == HTTP_OK: - self._state = True + if request.status_code == HTTPStatus.OK: + self._attr_is_on = True else: _LOGGER.error("Can't turn on function %s at %s", self._func, self._resource) @@ -144,8 +130,8 @@ def turn_off(self, **kwargs): f"{self._resource}/{self._func}", timeout=10, params={"params": "0"} ) - if request.status_code == HTTP_OK: - self._state = False + if request.status_code == HTTPStatus.OK: + self._attr_is_on = False else: _LOGGER.error( "Can't turn off function %s at %s", self._func, self._resource @@ -155,11 +141,11 @@ def update(self): """Get the latest data from aREST API and update the state.""" try: request = requests.get(f"{self._resource}/{self._func}", timeout=10) - self._state = request.json()["return_value"] != 0 - self._available = True + self._attr_is_on = request.json()["return_value"] != 0 + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) - self._available = False + self._attr_available = False class ArestSwitchPin(ArestSwitchBase): @@ -171,10 +157,10 @@ def __init__(self, resource, location, name, pin, invert): self._pin = pin self.invert = invert - request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) - if request.status_code != HTTP_OK: + request = requests.get(f"{resource}/mode/{pin}/o", timeout=10) + if request.status_code != HTTPStatus.OK: _LOGGER.error("Can't set mode") - self._available = False + self._attr_available = False def turn_on(self, **kwargs): """Turn the device on.""" @@ -182,8 +168,8 @@ def turn_on(self, **kwargs): request = requests.get( f"{self._resource}/digital/{self._pin}/{turn_on_payload}", timeout=10 ) - if request.status_code == HTTP_OK: - self._state = True + if request.status_code == HTTPStatus.OK: + self._attr_is_on = True else: _LOGGER.error("Can't turn on pin %s at %s", self._pin, self._resource) @@ -193,8 +179,8 @@ def turn_off(self, **kwargs): request = requests.get( f"{self._resource}/digital/{self._pin}/{turn_off_payload}", timeout=10 ) - if request.status_code == HTTP_OK: - self._state = False + if request.status_code == HTTPStatus.OK: + self._attr_is_on = False else: _LOGGER.error("Can't turn off pin %s at %s", self._pin, self._resource) @@ -203,8 +189,8 @@ def update(self): try: request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) - self._state = request.json()["return_value"] != status_value - self._available = True + self._attr_is_on = request.json()["return_value"] != status_value + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) - self._available = False + self._attr_available = False diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py index dd899cbd04ff2..b9a1004ac704d 100644 --- a/homeassistant/components/arlo/alarm_control_panel.py +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, AlarmControlPanelEntity, ) from homeassistant.components.alarm_control_panel.const import ( @@ -14,6 +14,7 @@ ) from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_DEVICE_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, @@ -33,11 +34,9 @@ CONF_AWAY_MODE_NAME = "away_mode_name" CONF_NIGHT_MODE_NAME = "night_mode_name" -DISARMED = "disarmed" - ICON = "mdi:security" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, vol.Optional(CONF_AWAY_MODE_NAME, default=ARMED): cv.string, @@ -69,18 +68,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ArloBaseStation(AlarmControlPanelEntity): """Representation of an Arlo Alarm Control Panel.""" + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + _attr_icon = ICON + def __init__(self, data, home_mode_name, away_mode_name, night_mode_name): """Initialize the alarm control panel.""" self._base_station = data self._home_mode_name = home_mode_name self._away_mode_name = away_mode_name self._night_mode_name = night_mode_name - self._state = None - - @property - def icon(self): - """Return icon.""" - return ICON + self._attr_name = data.name + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_DEVICE_ID: data.device_id, + } async def async_added_to_hass(self): """Register callbacks.""" @@ -95,28 +98,15 @@ def _update_callback(self): """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - def update(self): """Update the state of the device.""" _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) mode = self._base_station.mode - if mode: - self._state = self._get_state_from_mode(mode) - else: - self._state = None + self._attr_state = self._get_state_from_mode(mode) if mode else None def alarm_disarm(self, code=None): """Send disarm command.""" - self._base_station.mode = DISARMED + self._base_station.mode = STATE_ALARM_DISARMED def alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" @@ -130,24 +120,11 @@ def alarm_arm_night(self, code=None): """Send arm night command. Uses custom mode.""" self._base_station.mode = self._night_mode_name - @property - def name(self): - """Return the name of the base station.""" - return self._base_station.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "device_id": self._base_station.device_id, - } - def _get_state_from_mode(self, mode): """Convert Arlo mode to Home Assistant state.""" if mode == ARMED: return STATE_ALARM_ARMED_AWAY - if mode == DISARMED: + if mode == STATE_ALARM_DISARMED: return STATE_ALARM_DISARMED if mode == self._home_mode_name: return STATE_ALARM_ARMED_HOME diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index c184866142988..d60a82db77023 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -1,11 +1,13 @@ """Support for Netgear Arlo IP cameras.""" +from __future__ import annotations + import logging from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream import homeassistant.helpers.config_validation as cv @@ -55,14 +57,16 @@ def __init__(self, hass, camera, device_info): """Initialize an Arlo camera.""" super().__init__() self._camera = camera - self._name = self._camera.name + self._attr_name = camera.name self._motion_status = False - self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg = get_ffmpeg_manager(hass) self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) self._last_refresh = None self.attrs = {} - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.last_image_from_cache @@ -102,11 +106,6 @@ async def handle_async_mjpeg_stream(self, request): finally: await stream.close() - @property - def name(self): - """Return the name of this camera.""" - return self._name - @property def extra_state_attributes(self): """Return the state attributes.""" @@ -146,13 +145,12 @@ def motion_detection_enabled(self): def set_base_station_mode(self, mode): """Set the mode in the base station.""" # Get the list of base stations identified by library - base_stations = self.hass.data[DATA_ARLO].base_stations # Some Arlo cameras does not have base station # So check if there is base station detected first # if yes, then choose the primary base station # Set the mode on the chosen base station - if base_stations: + if base_stations := self.hass.data[DATA_ARLO].base_stations: primary_base_station = base_stations[0] primary_base_station.mode = mode diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index c794bf1ef5e75..868b2c81e8766 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -1,15 +1,20 @@ """Sensor support for Netgear Arlo IP cameras.""" +from __future__ import annotations + +from dataclasses import replace import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) @@ -22,22 +27,59 @@ _LOGGER = logging.getLogger(__name__) -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - "last_capture": ["Last", None, "run-fast"], - "total_cameras": ["Arlo Cameras", None, "video"], - "captured_today": ["Captured Today", None, "file-video"], - "battery_level": ["Battery Level", PERCENTAGE, "battery-50"], - "signal_strength": ["Signal Strength", None, "signal"], - "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], - "humidity": ["Humidity", PERCENTAGE, "water-percent"], - "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="last_capture", + name="Last", + icon="mdi:run-fast", + ), + SensorEntityDescription( + key="total_cameras", + name="Arlo Cameras", + icon="mdi:video", + ), + SensorEntityDescription( + key="captured_today", + name="Captured Today", + icon="mdi:file-video", + ), + SensorEntityDescription( + key="battery_level", + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + SensorEntityDescription( + key="signal_strength", + name="Signal Strength", + icon="mdi:signal", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key="air_quality", + name="Air Quality", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:biohazard", + ), +) + +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -45,29 +87,31 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up an Arlo IP sensor.""" - arlo = hass.data.get(DATA_ARLO) - if not arlo: + if not (arlo := hass.data.get(DATA_ARLO)): return sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - if sensor_type == "total_cameras": - sensors.append(ArloSensor(SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + for sensor_original in SENSOR_TYPES: + if sensor_original.key not in config[CONF_MONITORED_CONDITIONS]: + continue + sensor_entry = replace(sensor_original) + if sensor_entry.key == "total_cameras": + sensors.append(ArloSensor(arlo, sensor_entry)) else: for camera in arlo.cameras: - if sensor_type in ("temperature", "humidity", "air_quality"): + if sensor_entry.key in ("temperature", "humidity", "air_quality"): continue - name = f"{SENSOR_TYPES[sensor_type][0]} {camera.name}" - sensors.append(ArloSensor(name, camera, sensor_type)) + sensor_entry.name = f"{sensor_entry.name} {camera.name}" + sensors.append(ArloSensor(camera, sensor_entry)) for base_station in arlo.base_stations: if ( - sensor_type in ("temperature", "humidity", "air_quality") + sensor_entry.key in ("temperature", "humidity", "air_quality") and base_station.model_id == "ABC1000" ): - name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}" - sensors.append(ArloSensor(name, base_station, sensor_type)) + sensor_entry.name = f"{sensor_entry.name} {base_station.name}" + sensors.append(ArloSensor(base_station, sensor_entry)) add_entities(sensors, True) @@ -75,19 +119,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ArloSensor(SensorEntity): """An implementation of a Netgear Arlo IP sensor.""" - def __init__(self, name, device, sensor_type): + _attr_attribution = ATTRIBUTION + + def __init__(self, device, sensor_entry): """Initialize an Arlo sensor.""" - _LOGGER.debug("ArloSensor created for %s", name) - self._name = name + self.entity_description = sensor_entry self._data = device - self._sensor_type = sensor_type self._state = None - self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" - - @property - def name(self): - """Return the name of this camera.""" - return self._name async def async_added_to_hass(self): """Register callbacks.""" @@ -103,43 +141,29 @@ def _update_callback(self): self.async_schedule_update_ha_state(True) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery_level" and self._state is not None: + if self.entity_description.key == "battery_level" and self._state is not None: return icon_for_battery_level( battery_level=int(self._state), charging=False ) - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[1] - - @property - def device_class(self): - """Return the device class of the sensor.""" - if self._sensor_type == "temperature": - return DEVICE_CLASS_TEMPERATURE - if self._sensor_type == "humidity": - return DEVICE_CLASS_HUMIDITY - return None + return self.entity_description.icon def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Updating Arlo sensor %s", self.name) - if self._sensor_type == "total_cameras": + if self.entity_description.key == "total_cameras": self._state = len(self._data.cameras) - elif self._sensor_type == "captured_today": + elif self.entity_description.key == "captured_today": self._state = len(self._data.captured_today) - elif self._sensor_type == "last_capture": + elif self.entity_description.key == "last_capture": try: video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") @@ -151,31 +175,31 @@ def update(self): _LOGGER.debug(error_msg) self._state = None - elif self._sensor_type == "battery_level": + elif self.entity_description.key == "battery_level": try: self._state = self._data.battery_level except TypeError: self._state = None - elif self._sensor_type == "signal_strength": + elif self.entity_description.key == "signal_strength": try: self._state = self._data.signal_strength except TypeError: self._state = None - elif self._sensor_type == "temperature": + elif self.entity_description.key == "temperature": try: self._state = self._data.ambient_temperature except TypeError: self._state = None - elif self._sensor_type == "humidity": + elif self.entity_description.key == "humidity": try: self._state = self._data.ambient_humidity except TypeError: self._state = None - elif self._sensor_type == "air_quality": + elif self.entity_description.key == "air_quality": try: self._state = self._data.ambient_air_quality except TypeError: @@ -186,10 +210,9 @@ def extra_state_attributes(self): """Return the device state attributes.""" attrs = {} - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION attrs["brand"] = DEFAULT_BRAND - if self._sensor_type != "total_cameras": + if self.entity_description.key != "total_cameras": attrs["model"] = self._data.model_id return attrs diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 1011d76f8aa12..d0f15499d208c 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD @@ -14,7 +14,7 @@ DEFAULT_HOST = "192.168.178.1" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, @@ -33,7 +33,7 @@ def get_scanner(hass, config): class ArrisDeviceScanner(DeviceScanner): """This class queries a Arris TG2492LG router for connected devices.""" - def __init__(self, connect_box: ConnectBox): + def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" self.connect_box = connect_box self.last_results: list[Device] = [] diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 355bcad3aaf24..721585fa39164 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -21,7 +21,7 @@ + r"(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+" ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -74,8 +74,7 @@ def _update_info(self): if not self.success_init: return False - data = self.get_aruba_data() - if not data: + if not (data := self.get_aruba_data()): return False self.last_results = data.values() @@ -125,8 +124,7 @@ def get_aruba_data(self): devices = {} for device in devices_result: - match = _DEVICES_REGEX.search(device.decode("utf-8")) - if match: + if match := _DEVICES_REGEX.search(device.decode("utf-8")): devices[match.group("ip")] = { "ip": match.group("ip"), "mac": match.group("mac").upper(), diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index ba9166d1af585..4f06ac169ad5b 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -3,8 +3,13 @@ import logging from homeassistant.components import mqtt -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEGREE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import ( + DEGREE, + PRECIPITATION_INCHES, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.core import callback from homeassistant.util import slugify @@ -30,14 +35,20 @@ def discover_sensors(topic, payload): unit = TEMP_FAHRENHEIT else: unit = TEMP_CELSIUS - return ArwnSensor(topic, name, "temp", unit) + return ArwnSensor( + topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE + ) if domain == "moisture": name = f"{parts[2]} Moisture" return ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent") if domain == "rain": if len(parts) >= 3 and parts[2] == "today": return ArwnSensor( - topic, "Rain Since Midnight", "since_midnight", "in", "mdi:water" + topic, + "Rain Since Midnight", + "since_midnight", + PRECIPITATION_INCHES, + "mdi:water", ) return ( ArwnSensor(topic + "/total", "Total Rainfall", "total", unit, "mdi:water"), @@ -83,8 +94,7 @@ def async_sensor_event_received(msg): if not sensors: return - store = hass.data.get(DATA_ARWN) - if store is None: + if (store := hass.data.get(DATA_ARWN)) is None: store = hass.data[DATA_ARWN] = {} if isinstance(sensors, ArwnSensor): @@ -117,58 +127,23 @@ def async_sensor_event_received(msg): class ArwnSensor(SensorEntity): """Representation of an ARWN sensor.""" - def __init__(self, topic, name, state_key, units, icon=None): + _attr_should_poll = False + + def __init__(self, topic, name, state_key, units, icon=None, device_class=None): """Initialize the sensor.""" - self.hass = None self.entity_id = _slug(name) - self._name = name + self._attr_name = name # This mqtt topic for the sensor which is its uid - self._uid = topic + self._attr_unique_id = topic self._state_key = state_key - self.event = {} - self._unit_of_measurement = units - self._icon = icon + self._attr_native_unit_of_measurement = units + self._attr_icon = icon + self._attr_device_class = device_class def set_event(self, event): """Update the sensor with the most recent event.""" - self.event = {} - self.event.update(event) + ev = {} + ev.update(event) + self._attr_extra_state_attributes = ev + self._attr_native_value = ev.get(self._state_key, None) self.async_write_ha_state() - - @property - def state(self): - """Return the state of the device.""" - return self.event.get(self._state_key, None) - - @property - def name(self): - """Get the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID. - - This is based on the topic that comes from mqtt - """ - return self._uid - - @property - def extra_state_attributes(self): - """Return all the state attributes.""" - return self.event - - @property - def unit_of_measurement(self): - """Return the unit of measurement the state is expressed in.""" - return self._unit_of_measurement - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def icon(self): - """Return the icon of device based on its type.""" - return self._icon diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py new file mode 100644 index 0000000000000..7c7e8cc009fda --- /dev/null +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -0,0 +1,77 @@ +"""The Aseko Pool Live integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Dict + +from aioaseko import APIUnavailable, MobileAccount, Unit, Variable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Aseko Pool Live from a config entry.""" + account = MobileAccount( + async_get_clientsession(hass), access_token=entry.data[CONF_ACCESS_TOKEN] + ) + + try: + units = await account.get_units() + except APIUnavailable as err: + raise ConfigEntryNotReady from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = [] + + for unit in units: + coordinator = AsekoDataUpdateCoordinator(hass, unit) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id].append((unit, coordinator)) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class AsekoDataUpdateCoordinator(DataUpdateCoordinator[Dict[str, Variable]]): + """Class to manage fetching Aseko unit data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, unit: Unit) -> None: + """Initialize global Aseko unit data updater.""" + self._unit = unit + + if self._unit.name: + name = self._unit.name + else: + name = f"{self._unit.type}-{self._unit.serial_number}" + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(minutes=2), + ) + + async def _async_update_data(self) -> dict[str, Variable]: + """Fetch unit data.""" + await self._unit.get_state() + return {variable.type: variable for variable in self._unit.variables} diff --git a/homeassistant/components/aseko_pool_live/config_flow.py b/homeassistant/components/aseko_pool_live/config_flow.py new file mode 100644 index 0000000000000..c8f96db3bc8f8 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/config_flow.py @@ -0,0 +1,81 @@ +"""Config flow for Aseko Pool Live integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount, WebAccount +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_EMAIL, + CONF_PASSWORD, + CONF_UNIQUE_ID, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aseko Pool Live.""" + + VERSION = 1 + + async def get_account_info(self, email: str, password: str) -> dict: + """Get account info from the mobile API and the web API.""" + session = async_get_clientsession(self.hass) + + web_account = WebAccount(session, email, password) + web_account_info = await web_account.login() + + mobile_account = MobileAccount(session, email, password) + await mobile_account.login() + + return { + CONF_ACCESS_TOKEN: mobile_account.access_token, + CONF_EMAIL: web_account_info.email, + CONF_UNIQUE_ID: web_account_info.user_id, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await self.get_account_info( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except APIUnavailable: + errors["base"] = "cannot_connect" + except InvalidAuthCredentials: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[CONF_UNIQUE_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info[CONF_EMAIL], + data={CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN]}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/aseko_pool_live/const.py b/homeassistant/components/aseko_pool_live/const.py new file mode 100644 index 0000000000000..41701e09754a3 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/const.py @@ -0,0 +1,3 @@ +"""Constants for the Aseko Pool Live integration.""" + +DOMAIN = "aseko_pool_live" diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py new file mode 100644 index 0000000000000..963bb5366714c --- /dev/null +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -0,0 +1,29 @@ +"""Aseko entity.""" +from aioaseko import Unit + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AsekoDataUpdateCoordinator +from .const import DOMAIN + + +class AsekoEntity(CoordinatorEntity): + """Representation of an aseko entity.""" + + coordinator: AsekoDataUpdateCoordinator + + def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None: + """Initialize the aseko entity.""" + super().__init__(coordinator) + self._unit = unit + + self._device_model = f"ASIN AQUA {self._unit.type}" + self._device_name = self._unit.name if self._unit.name else self._device_model + + self._attr_device_info = DeviceInfo( + name=self._device_name, + identifiers={(DOMAIN, str(self._unit.serial_number))}, + manufacturer="Aseko", + model=self._device_model, + ) diff --git a/homeassistant/components/aseko_pool_live/manifest.json b/homeassistant/components/aseko_pool_live/manifest.json new file mode 100644 index 0000000000000..f6323b493547f --- /dev/null +++ b/homeassistant/components/aseko_pool_live/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aseko_pool_live", + "name": "Aseko Pool Live", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aseko_pool_live", + "requirements": ["aioaseko==0.0.1"], + "codeowners": [ + "@milanmeu" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py new file mode 100644 index 0000000000000..74051ef454f07 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -0,0 +1,75 @@ +"""Support for Aseko Pool Live sensors.""" +from __future__ import annotations + +from aioaseko import Unit, Variable + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AsekoDataUpdateCoordinator +from .const import DOMAIN +from .entity import AsekoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Aseko Pool Live sensors.""" + data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][ + config_entry.entry_id + ] + entities = [] + for unit, coordinator in data: + for variable in unit.variables: + entities.append(VariableSensorEntity(unit, variable, coordinator)) + async_add_entities(entities) + + +class VariableSensorEntity(AsekoEntity, SensorEntity): + """Representation of a unit variable sensor entity.""" + + attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, unit: Unit, variable: Variable, coordinator: AsekoDataUpdateCoordinator + ) -> None: + """Initialize the variable sensor.""" + super().__init__(unit, coordinator) + self._variable = variable + + variable_name = { + "Air temp.": "Air Temperature", + "Cl free": "Free Chlorine", + "Water temp.": "Water Temperature", + }.get(self._variable.name, self._variable.name) + + self._attr_name = f"{self._device_name} {variable_name}" + self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}" + self._attr_native_unit_of_measurement = self._variable.unit + + self._attr_icon = { + "clf": "mdi:flask", + "ph": "mdi:ph", + "rx": "mdi:test-tube", + "waterLevel": "mdi:waves", + "waterTemp": "mdi:coolant-temperature", + }.get(self._variable.type) + + self._attr_device_class = { + "airTemp": SensorDeviceClass.TEMPERATURE, + "waterTemp": SensorDeviceClass.TEMPERATURE, + }.get(self._variable.type) + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + variable = self.coordinator.data[self._variable.type] + return variable.current_value diff --git a/homeassistant/components/aseko_pool_live/strings.json b/homeassistant/components/aseko_pool_live/strings.json new file mode 100644 index 0000000000000..4c3813220b6f3 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/bg.json b/homeassistant/components/aseko_pool_live/translations/bg.json new file mode 100644 index 0000000000000..982674c337e84 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/ca.json b/homeassistant/components/aseko_pool_live/translations/ca.json new file mode 100644 index 0000000000000..3451da0874ec6 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/cs.json b/homeassistant/components/aseko_pool_live/translations/cs.json new file mode 100644 index 0000000000000..580d10b354d4d --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/de.json b/homeassistant/components/aseko_pool_live/translations/de.json new file mode 100644 index 0000000000000..6714068ab30f1 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/en.json b/homeassistant/components/aseko_pool_live/translations/en.json new file mode 100644 index 0000000000000..399b4650695d1 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/et.json b/homeassistant/components/aseko_pool_live/translations/et.json new file mode 100644 index 0000000000000..b2550d5634b65 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/fr.json b/homeassistant/components/aseko_pool_live/translations/fr.json new file mode 100644 index 0000000000000..d28b22f8d98f6 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Mot de passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/he.json b/homeassistant/components/aseko_pool_live/translations/he.json new file mode 100644 index 0000000000000..2b083313602df --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/hu.json b/homeassistant/components/aseko_pool_live/translations/hu.json new file mode 100644 index 0000000000000..46a9e952fcf23 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/id.json b/homeassistant/components/aseko_pool_live/translations/id.json new file mode 100644 index 0000000000000..b3d46b4e41258 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/it.json b/homeassistant/components/aseko_pool_live/translations/it.json new file mode 100644 index 0000000000000..1cb1594adc7ee --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/ja.json b/homeassistant/components/aseko_pool_live/translations/ja.json new file mode 100644 index 0000000000000..aed2b7c0932e9 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/lt.json b/homeassistant/components/aseko_pool_live/translations/lt.json new file mode 100644 index 0000000000000..838d40511e41a --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/lt.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepavyko prisijungti" + }, + "step": { + "user": { + "data": { + "email": "Elektroninis pa\u0161tas", + "password": "Slapta\u017eodis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/nl.json b/homeassistant/components/aseko_pool_live/translations/nl.json new file mode 100644 index 0000000000000..a347122d07874 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/no.json b/homeassistant/components/aseko_pool_live/translations/no.json new file mode 100644 index 0000000000000..8c08ab0c5618a --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/pl.json b/homeassistant/components/aseko_pool_live/translations/pl.json new file mode 100644 index 0000000000000..04ced93480c1d --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/ru.json b/homeassistant/components/aseko_pool_live/translations/ru.json new file mode 100644 index 0000000000000..23896af80910d --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/tr.json b/homeassistant/components/aseko_pool_live/translations/tr.json new file mode 100644 index 0000000000000..3d71918869e1e --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/zh-Hant.json b/homeassistant/components/aseko_pool_live/translations/zh-Hant.json new file mode 100644 index 0000000000000..afeaa113fbbda --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index ad3cea1106b18..fb6c547cc6b8b 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -12,6 +12,7 @@ CONF_SENSORS, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -33,7 +34,7 @@ ) from .router import AsusWrtRouter -PLATFORMS = ["device_tracker", "sensor"] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] CONF_PUB_KEY = "pub_key" SECRET_GROUP = "Password or SSH Key" @@ -73,15 +74,14 @@ async def async_setup(hass, config): """Set up the AsusWrt integration.""" - conf = config.get(DOMAIN) - if conf is None: + if (conf := config.get(DOMAIN)) is None: return True # save the options from config yaml options = {} mode = conf.get(CONF_MODE, MODE_ROUTER) for name, value in conf.items(): - if name in ([CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]): + if name in [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]: if name == CONF_REQUIRE_IP and mode != MODE_AP: continue options[name] = value @@ -93,8 +93,7 @@ async def async_setup(hass, config): return True # remove not required config keys - pub_key = conf.pop(CONF_PUB_KEY, "") - if pub_key: + if pub_key := conf.pop(CONF_PUB_KEY, ""): conf[CONF_SSH_KEY] = pub_key conf.pop(CONF_REQUIRE_IP, True) @@ -111,7 +110,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AsusWrt platform.""" # import options from yaml if empty @@ -142,7 +141,7 @@ async def async_close_connection(event): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 8028a703ac055..5a20880b4b020 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -49,12 +49,7 @@ def _is_file(value) -> bool: """Validate that the value is an existing file.""" file_in = os.path.expanduser(str(value)) - - if not os.path.isfile(file_in): - return False - if not os.access(file_in, os.R_OK): - return False - return True + return os.path.isfile(file_in) and os.access(file_in, os.R_OK) def _get_ip(host): @@ -184,7 +179,7 @@ def async_get_options_flow(config_entry): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for AsusWrt.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry @@ -222,8 +217,7 @@ async def async_step_init(self, user_input=None): } ) - conf_mode = self.config_entry.data[CONF_MODE] - if conf_mode == MODE_AP: + if self.config_entry.data[CONF_MODE] == MODE_AP: data_schema = data_schema.extend( { vol.Optional( diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index a8977a77ea8e1..95e93e0ff2537 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -21,8 +21,8 @@ PROTOCOL_TELNET = "telnet" # Sensors -SENSOR_CONNECTED_DEVICE = "sensor_connected_device" -SENSOR_RX_BYTES = "sensor_rx_bytes" -SENSOR_TX_BYTES = "sensor_tx_bytes" -SENSOR_RX_RATES = "sensor_rx_rates" -SENSOR_TX_RATES = "sensor_tx_rates" +SENSORS_BYTES = ["sensor_rx_bytes", "sensor_tx_bytes"] +SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"] +SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"] +SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"] +SENSORS_TEMPERATURES = ["2.4GHz", "5.0GHz", "CPU"] diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index a0c7ec0e27a87..bb96cb184a106 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -1,11 +1,10 @@ """Support for ASUSWRT routers.""" from __future__ import annotations -from typing import Any - from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DEFAULT_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -22,7 +21,7 @@ async def async_setup_entry( ) -> None: """Set up device tracker for AsusWrt component.""" router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] - tracked = set() + tracked: set = set() @callback def update_router(): @@ -55,20 +54,20 @@ def add_entities(router, async_add_entities, tracked): class AsusWrtDevice(ScannerEntity): """Representation of a AsusWrt device.""" + _attr_should_poll = False + def __init__(self, router: AsusWrtRouter, device) -> None: """Initialize a AsusWrt device.""" self._router = router self._device = device - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.mac - - @property - def name(self) -> str: - """Return the name.""" - return self._device.name or DEFAULT_DEVICE_NAME + self._attr_unique_id = device.mac + self._attr_name = device.name or DEFAULT_DEVICE_NAME + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + default_model="ASUSWRT Tracked device", + ) + if device.name: + self._attr_device_info[ATTR_DEFAULT_NAME] = device.name @property def is_connected(self): @@ -80,21 +79,16 @@ def source_type(self) -> str: """Return the source type.""" return SOURCE_TYPE_ROUTER - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the attributes.""" - attrs = {} - if self._device.last_activity: - attrs["last_time_reachable"] = self._device.last_activity.isoformat( - timespec="seconds" - ) - return attrs - @property def hostname(self) -> str: """Return the hostname of device.""" return self._device.name + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:lan-connect" if self._device.is_connected else "mdi:lan-disconnect" + @property def ip_address(self) -> str: """Return the primary ip address of the device.""" @@ -105,26 +99,15 @@ def mac_address(self) -> str: """Return the mac address of the device.""" return self._device.mac - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - data = { - "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, - } - if self._device.name: - data["default_name"] = self._device.name - - return data - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - @callback def async_on_demand_update(self): """Update state.""" self._device = self._router.devices[self._device.mac] + self._attr_extra_state_attributes = {} + if self._device.last_activity: + self._attr_extra_state_attributes[ + "last_time_reachable" + ] = self._device.last_activity.isoformat(timespec="seconds") self.async_write_ha_state() async def async_added_to_hass(self): diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index b66c3bb5db951..1470c075b0438 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -3,7 +3,7 @@ "name": "ASUSWRT", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/asuswrt", - "requirements": ["aioasuswrt==1.3.4"], + "requirements": ["aioasuswrt==1.4.0"], "codeowners": ["@kennedyshead", "@ollo69"], "iot_class": "local_polling" } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index f82bf74e4a3e0..67e592c71ef93 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -1,6 +1,7 @@ """Represent the AsusWrt router.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import logging from typing import Any @@ -23,6 +24,8 @@ ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval @@ -40,11 +43,11 @@ DEFAULT_TRACK_UNKNOWN, DOMAIN, PROTOCOL_TELNET, - SENSOR_CONNECTED_DEVICE, - SENSOR_RX_BYTES, - SENSOR_RX_RATES, - SENSOR_TX_BYTES, - SENSOR_TX_RATES, + SENSORS_BYTES, + SENSORS_CONNECTED_DEVICE, + SENSORS_LOAD_AVG, + SENSORS_RATES, + SENSORS_TEMPERATURES, ) CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] @@ -56,11 +59,23 @@ SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" +SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" SENSORS_TYPE_RATES = "sensors_rates" +SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" _LOGGER = logging.getLogger(__name__) +def _get_dict(keys: list, values: list) -> dict[str, Any]: + """Create a dict from a list of keys and values.""" + ret_dict: dict[str, Any] = dict.fromkeys(keys) + + for index, key in enumerate(ret_dict): + ret_dict[key] = values[index] + + return ret_dict + + class AsusWrtSensorDataHandler: """Data handler for AsusWrt sensor.""" @@ -72,33 +87,43 @@ def __init__(self, hass, api): async def _get_connected_devices(self): """Return number of connected devices.""" - return {SENSOR_CONNECTED_DEVICE: self._connected_devices} + return {SENSORS_CONNECTED_DEVICE[0]: self._connected_devices} async def _get_bytes(self): """Fetch byte information from the router.""" - ret_dict: dict[str, Any] = {} try: datas = await self._api.async_get_bytes_total() - except OSError as exc: - raise UpdateFailed from exc + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc - ret_dict[SENSOR_RX_BYTES] = datas[0] - ret_dict[SENSOR_TX_BYTES] = datas[1] - - return ret_dict + return _get_dict(SENSORS_BYTES, datas) async def _get_rates(self): """Fetch rates information from the router.""" - ret_dict: dict[str, Any] = {} try: rates = await self._api.async_get_current_transfer_rates() - except OSError as exc: - raise UpdateFailed from exc + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc - ret_dict[SENSOR_RX_RATES] = rates[0] - ret_dict[SENSOR_TX_RATES] = rates[1] + return _get_dict(SENSORS_RATES, rates) - return ret_dict + async def _get_load_avg(self): + """Fetch load average information from the router.""" + try: + avg = await self._api.async_get_loadavg() + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_LOAD_AVG, avg) + + async def _get_temperatures(self): + """Fetch temperatures information from the router.""" + try: + temperatures = await self._api.async_get_temperature() + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return temperatures def update_device_count(self, conn_devices: int): """Update connected devices attribute.""" @@ -113,8 +138,12 @@ async def get_coordinator(self, sensor_type: str, should_poll=True): method = self._get_connected_devices elif sensor_type == SENSORS_TYPE_BYTES: method = self._get_bytes + elif sensor_type == SENSORS_TYPE_LOAD_AVG: + method = self._get_load_avg elif sensor_type == SENSORS_TYPE_RATES: method = self._get_rates + elif sensor_type == SENSORS_TYPE_TEMPERATURES: + method = self._get_temperatures else: raise RuntimeError(f"Invalid sensor type: {sensor_type}") @@ -195,15 +224,17 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._api: AsusWrt = None self._protocol = entry.data[CONF_PROTOCOL] self._host = entry.data[CONF_HOST] + self._model = "Asus Router" + self._sw_v: str | None = None self._devices: dict[str, Any] = {} self._connected_devices = 0 self._connect_error = False - self._sensors_data_handler: AsusWrtSensorDataHandler = None + self._sensors_data_handler: AsusWrtSensorDataHandler | None = None self._sensors_coordinator: dict[str, Any] = {} - self._on_close = [] + self._on_close: list[Callable] = [] self._options = { CONF_DNSMASQ: DEFAULT_DNSMASQ, @@ -214,7 +245,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: async def setup(self) -> None: """Set up a AsusWrt router.""" - self._api = get_api(self._entry.data, self._options) + self._api = get_api(dict(self._entry.data), self._options) try: await self._api.connection.async_connect() @@ -224,18 +255,41 @@ async def setup(self) -> None: if not self._api.is_connected: raise ConfigEntryNotReady + # System + model = await _get_nvram_info(self._api, "MODEL") + if model and "model" in model: + self._model = model["model"] + firmware = await _get_nvram_info(self._api, "FIRMWARE") + if firmware and "firmver" in firmware and "buildno" in firmware: + self._sw_v = f"{firmware['firmver']} (build {firmware['buildno']})" + # Load tracked entities from registry - entity_registry = await self.hass.helpers.entity_registry.async_get_registry() - track_entries = ( - self.hass.helpers.entity_registry.async_entries_for_config_entry( - entity_registry, self._entry.entry_id - ) + entity_reg = er.async_get(self.hass) + track_entries = er.async_entries_for_config_entry( + entity_reg, self._entry.entry_id ) for entry in track_entries: - if entry.domain == TRACKER_DOMAIN: - self._devices[entry.unique_id] = AsusWrtDevInfo( - entry.unique_id, entry.original_name + + if entry.domain != TRACKER_DOMAIN: + continue + device_mac = format_mac(entry.unique_id) + + # migrate entity unique ID if wrong formatted + if device_mac != entry.unique_id: + existing_entity_id = entity_reg.async_get_entity_id( + DOMAIN, TRACKER_DOMAIN, device_mac ) + if existing_entity_id: + # entity with uniqueid properly formatted already + # exists in the registry, we delete this duplicate + entity_reg.async_remove(entry.entity_id) + continue + + entity_reg.async_update_entity( + entry.entity_id, new_unique_id=device_mac + ) + + self._devices[device_mac] = AsusWrtDevInfo(device_mac, entry.original_name) # Update devices await self.update_devices() @@ -256,7 +310,7 @@ async def update_devices(self) -> None: new_device = False _LOGGER.debug("Checking devices for ASUS router %s", self._host) try: - wrt_devices = await self._api.async_get_connected_devices() + api_devices = await self._api.async_get_connected_devices() except OSError as exc: if not self._connect_error: self._connect_error = True @@ -271,18 +325,18 @@ async def update_devices(self) -> None: self._connect_error = False _LOGGER.info("Reconnected to ASUS router %s", self._host) + self._connected_devices = len(api_devices) consider_home = self._options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ) track_unknown = self._options.get(CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN) - for device_mac in self._devices: - dev_info = wrt_devices.get(device_mac) - self._devices[device_mac].update(dev_info, consider_home) + wrt_devices = {format_mac(mac): dev for mac, dev in api_devices.items()} + for device_mac, device in self._devices.items(): + dev_info = wrt_devices.pop(device_mac, None) + device.update(dev_info, consider_home) for device_mac, dev_info in wrt_devices.items(): - if device_mac in self._devices: - continue if not track_unknown and not dev_info.name: continue new_device = True @@ -293,8 +347,6 @@ async def update_devices(self) -> None: async_dispatcher_send(self.hass, self.signal_device_update) if new_device: async_dispatcher_send(self.hass, self.signal_device_new) - - self._connected_devices = len(wrt_devices) await self._update_unpolled_sensors() async def init_sensors_coordinator(self) -> None: @@ -305,29 +357,26 @@ async def init_sensors_coordinator(self) -> None: self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api) self._sensors_data_handler.update_device_count(self._connected_devices) - conn_dev_coordinator = await self._sensors_data_handler.get_coordinator( - SENSORS_TYPE_COUNT, False - ) - self._sensors_coordinator[SENSORS_TYPE_COUNT] = { - KEY_COORDINATOR: conn_dev_coordinator, - KEY_SENSORS: [SENSOR_CONNECTED_DEVICE], - } - - bytes_coordinator = await self._sensors_data_handler.get_coordinator( - SENSORS_TYPE_BYTES - ) - self._sensors_coordinator[SENSORS_TYPE_BYTES] = { - KEY_COORDINATOR: bytes_coordinator, - KEY_SENSORS: [SENSOR_RX_BYTES, SENSOR_TX_BYTES], + sensors_types = { + SENSORS_TYPE_BYTES: SENSORS_BYTES, + SENSORS_TYPE_COUNT: SENSORS_CONNECTED_DEVICE, + SENSORS_TYPE_LOAD_AVG: SENSORS_LOAD_AVG, + SENSORS_TYPE_RATES: SENSORS_RATES, } + sensors_types[ + SENSORS_TYPE_TEMPERATURES + ] = await self._get_available_temperature_sensors() - rates_coordinator = await self._sensors_data_handler.get_coordinator( - SENSORS_TYPE_RATES - ) - self._sensors_coordinator[SENSORS_TYPE_RATES] = { - KEY_COORDINATOR: rates_coordinator, - KEY_SENSORS: [SENSOR_RX_RATES, SENSOR_TX_RATES], - } + for sensor_type, sensor_names in sensors_types.items(): + if not sensor_names: + continue + coordinator = await self._sensors_data_handler.get_coordinator( + sensor_type, sensor_type != SENSORS_TYPE_COUNT + ) + self._sensors_coordinator[sensor_type] = { + KEY_COORDINATOR: coordinator, + KEY_SENSORS: sensor_names, + } async def _update_unpolled_sensors(self) -> None: """Request refresh for AsusWrt unpolled sensors.""" @@ -339,6 +388,23 @@ async def _update_unpolled_sensors(self) -> None: if self._sensors_data_handler.update_device_count(self._connected_devices): await coordinator.async_refresh() + async def _get_available_temperature_sensors(self): + """Check which temperature information is available on the router.""" + try: + availability = await self._api.async_find_temperature_commands() + available_sensors = [ + SENSORS_TEMPERATURES[i] for i in range(3) if availability[i] + ] + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Failed checking temperature sensor availability for ASUS router %s. Exception: %s", + self._host, + exc, + ) + return [] + + return available_sensors + async def close(self) -> None: """Close the connection.""" if self._api is not None and self._protocol == PROTOCOL_TELNET: @@ -358,7 +424,7 @@ def update_options(self, new_options: dict) -> bool: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): - if name in (CONF_REQ_RELOAD): + if name in CONF_REQ_RELOAD: old_opt = self._options.get(name) if not old_opt or old_opt != new_opt: req_reload = True @@ -370,12 +436,14 @@ def update_options(self, new_options: dict) -> bool: @property def device_info(self) -> DeviceInfo: """Return the device information.""" - return { - "identifiers": {(DOMAIN, "AsusWRT")}, - "name": self._host, - "model": "Asus Router", - "manufacturer": "Asus", - } + return DeviceInfo( + identifiers={(DOMAIN, "AsusWRT")}, + name=self._host, + model=self._model, + manufacturer="Asus", + sw_version=self._sw_v, + configuration_url=f"http://{self._host}", + ) @property def signal_device_new(self) -> str: @@ -408,6 +476,17 @@ def api(self) -> AsusWrt: return self._api +async def _get_nvram_info(api: AsusWrt, info_type: str) -> dict[str, Any]: + """Get AsusWrt router info from nvram.""" + info = {} + try: + info = await api.async_get_nvram(info_type) + except OSError as exc: + _LOGGER.warning("Error calling method async_get_nvram(%s): %s", info_type, exc) + + return info + + def get_api(conf: dict, options: dict | None = None) -> AsusWrt: """Get the AsusWrt API.""" opt = options or {} diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 6ec077620f632..2c3b022cb9dd4 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -1,15 +1,23 @@ """Asuswrt status sensors.""" from __future__ import annotations -import logging -from numbers import Number -from typing import Any - -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass +from numbers import Real + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND +from homeassistant.const import ( + DATA_GIGABYTES, + DATA_RATE_MEGABITS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -18,65 +26,134 @@ from .const import ( DATA_ASUSWRT, DOMAIN, - SENSOR_CONNECTED_DEVICE, - SENSOR_RX_BYTES, - SENSOR_RX_RATES, - SENSOR_TX_BYTES, - SENSOR_TX_RATES, + SENSORS_BYTES, + SENSORS_CONNECTED_DEVICE, + SENSORS_LOAD_AVG, + SENSORS_RATES, + SENSORS_TEMPERATURES, ) from .router import KEY_COORDINATOR, KEY_SENSORS, AsusWrtRouter -DEFAULT_PREFIX = "Asuswrt" -SENSOR_DEVICE_CLASS = "device_class" -SENSOR_ICON = "icon" -SENSOR_NAME = "name" -SENSOR_UNIT = "unit" -SENSOR_FACTOR = "factor" -SENSOR_DEFAULT_ENABLED = "default_enabled" +@dataclass +class AsusWrtSensorEntityDescription(SensorEntityDescription): + """A class that describes AsusWrt sensor entities.""" + + factor: int | None = None + precision: int = 2 + +DEFAULT_PREFIX = "Asuswrt" UNIT_DEVICES = "Devices" -CONNECTION_SENSORS = { - SENSOR_CONNECTED_DEVICE: { - SENSOR_NAME: "Devices Connected", - SENSOR_UNIT: UNIT_DEVICES, - SENSOR_FACTOR: 0, - SENSOR_ICON: "mdi:router-network", - SENSOR_DEVICE_CLASS: None, - SENSOR_DEFAULT_ENABLED: True, - }, - SENSOR_RX_RATES: { - SENSOR_NAME: "Download Speed", - SENSOR_UNIT: DATA_RATE_MEGABITS_PER_SECOND, - SENSOR_FACTOR: 125000, - SENSOR_ICON: "mdi:download-network", - SENSOR_DEVICE_CLASS: None, - }, - SENSOR_TX_RATES: { - SENSOR_NAME: "Upload Speed", - SENSOR_UNIT: DATA_RATE_MEGABITS_PER_SECOND, - SENSOR_FACTOR: 125000, - SENSOR_ICON: "mdi:upload-network", - SENSOR_DEVICE_CLASS: None, - }, - SENSOR_RX_BYTES: { - SENSOR_NAME: "Download", - SENSOR_UNIT: DATA_GIGABYTES, - SENSOR_FACTOR: 1000000000, - SENSOR_ICON: "mdi:download", - SENSOR_DEVICE_CLASS: None, - }, - SENSOR_TX_BYTES: { - SENSOR_NAME: "Upload", - SENSOR_UNIT: DATA_GIGABYTES, - SENSOR_FACTOR: 1000000000, - SENSOR_ICON: "mdi:upload", - SENSOR_DEVICE_CLASS: None, - }, -} - -_LOGGER = logging.getLogger(__name__) +CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( + AsusWrtSensorEntityDescription( + key=SENSORS_CONNECTED_DEVICE[0], + name="Devices Connected", + icon="mdi:router-network", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UNIT_DEVICES, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_RATES[0], + name="Download Speed", + icon="mdi:download-network", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + entity_registry_enabled_default=False, + factor=125000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_RATES[1], + name="Upload Speed", + icon="mdi:upload-network", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + entity_registry_enabled_default=False, + factor=125000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_BYTES[0], + name="Download", + icon="mdi:download", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + factor=1000000000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_BYTES[1], + name="Upload", + icon="mdi:upload", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + factor=1000000000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[0], + name="Load Avg (1m)", + icon="mdi:cpu-32-bit", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[1], + name="Load Avg (5m)", + icon="mdi:cpu-32-bit", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[2], + name="Load Avg (15m)", + icon="mdi:cpu-32-bit", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[0], + name="2.4GHz Temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[1], + name="5GHz Temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[2], + name="CPU Temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), +) async def async_setup_entry( @@ -89,13 +166,13 @@ async def async_setup_entry( for sensor_data in router.sensors_coordinator.values(): coordinator = sensor_data[KEY_COORDINATOR] sensors = sensor_data[KEY_SENSORS] - for sensor_key in sensors: - if sensor_key in CONNECTION_SENSORS: - entities.append( - AsusWrtSensor( - coordinator, router, sensor_key, CONNECTION_SENSORS[sensor_key] - ) - ) + entities.extend( + [ + AsusWrtSensor(coordinator, router, sensor_descr) + for sensor_descr in CONNECTION_SENSORS + if sensor_descr.key in sensors + ] + ) async_add_entities(entities, True) @@ -107,67 +184,22 @@ def __init__( self, coordinator: DataUpdateCoordinator, router: AsusWrtRouter, - sensor_type: str, - sensor: dict[str, Any], + description: AsusWrtSensorEntityDescription, ) -> None: """Initialize a AsusWrt sensor.""" super().__init__(coordinator) - self._router = router - self._sensor_type = sensor_type - self._name = f"{DEFAULT_PREFIX} {sensor[SENSOR_NAME]}" - self._unique_id = f"{DOMAIN} {self._name}" - self._unit = sensor[SENSOR_UNIT] - self._factor = sensor[SENSOR_FACTOR] - self._icon = sensor[SENSOR_ICON] - self._device_class = sensor[SENSOR_DEVICE_CLASS] - self._default_enabled = sensor.get(SENSOR_DEFAULT_ENABLED, False) + self.entity_description: AsusWrtSensorEntityDescription = description - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._default_enabled + self._attr_name = f"{DEFAULT_PREFIX} {description.name}" + self._attr_unique_id = f"{DOMAIN} {self.name}" + self._attr_device_info = router.device_info + self._attr_extra_state_attributes = {"hostname": router.host} @property - def state(self) -> str: + def native_value(self) -> float | str | None: """Return current state.""" - state = self.coordinator.data.get(self._sensor_type) - if state is None: - return None - if self._factor and isinstance(state, Number): - return round(state / self._factor, 2) + descr = self.entity_description + state = self.coordinator.data.get(descr.key) + if state is not None and descr.factor and isinstance(state, Real): + return round(state / descr.factor, descr.precision) return state - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def unit_of_measurement(self) -> str: - """Return the unit.""" - return self._unit - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def device_class(self) -> str: - """Return the device_class.""" - return self._device_class - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the attributes.""" - return {"hostname": self._router.host} - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info diff --git a/homeassistant/components/asuswrt/translations/de.json b/homeassistant/components/asuswrt/translations/de.json index 36699d9575379..6a860311a32ca 100644 --- a/homeassistant/components/asuswrt/translations/de.json +++ b/homeassistant/components/asuswrt/translations/de.json @@ -23,7 +23,8 @@ "ssh_key": "Pfad zu deiner SSH-Schl\u00fcsseldatei (anstelle des Passworts)", "username": "Benutzername" }, - "title": "" + "description": "Einstellen der erforderlichen Parameter f\u00fcr die Verbindung mit deinem Router.", + "title": "AsusWRT" } } }, @@ -31,8 +32,11 @@ "step": { "init": { "data": { + "consider_home": "Sekunden, um ein Ger\u00e4t als 'abwesend' zu betrachten", + "dnsmasq": "Der Speicherort der dnsmasq.leases-Dateien im Router", "interface": "Schnittstelle, von der du Statistiken haben m\u00f6chtest (z.B. eth0, eth1 usw.)", - "require_ip": "Ger\u00e4te m\u00fcssen IP haben (f\u00fcr Zugangspunkt-Modus)" + "require_ip": "Ger\u00e4te m\u00fcssen IP haben (f\u00fcr Zugangspunkt-Modus)", + "track_unknown": "Unbekannte / unbenannte Ger\u00e4te tracken" }, "title": "AsusWRT Optionen" } diff --git a/homeassistant/components/asuswrt/translations/es-419.json b/homeassistant/components/asuswrt/translations/es-419.json new file mode 100644 index 0000000000000..1c6b80588cd1c --- /dev/null +++ b/homeassistant/components/asuswrt/translations/es-419.json @@ -0,0 +1,33 @@ +{ + "config": { + "error": { + "pwd_and_ssh": "Solo proporcione la contrase\u00f1a o el archivo de clave SSH", + "pwd_or_ssh": "Proporcione la contrase\u00f1a o el archivo de clave SSH", + "ssh_not_file": "No se encontr\u00f3 el archivo de clave SSH" + }, + "step": { + "user": { + "data": { + "protocol": "Protocolo de comunicaci\u00f3n a utilizar", + "ssh_key": "Ruta a su archivo de clave SSH (en lugar de contrase\u00f1a)" + }, + "description": "Establezca el par\u00e1metro requerido para conectarse a su enrutador", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos de espera antes de considerar un dispositivo ausente", + "dnsmasq": "La ubicaci\u00f3n en el enrutador de los archivos dnsmasq.leases", + "interface": "La interfaz de la que desea obtener estad\u00edsticas (por ejemplo, eth0, eth1, etc.)", + "require_ip": "Los dispositivos deben tener IP (para el modo de punto de acceso)", + "track_unknown": "Seguimiento de dispositivos desconocidos / sin nombre" + }, + "title": "Opciones de AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/he.json b/homeassistant/components/asuswrt/translations/he.json new file mode 100644 index 0000000000000..2d2cebaa7e3ee --- /dev/null +++ b/homeassistant/components/asuswrt/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", + "pwd_and_ssh": "\u05e1\u05e4\u05e7 \u05e8\u05e7 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d5 \u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH", + "pwd_or_ssh": "\u05d0\u05e0\u05d0 \u05e1\u05e4\u05e7 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d5 \u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH", + "ssh_not_file": "\u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "mode": "\u05de\u05e6\u05d1", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "ssh_key": "\u05e0\u05ea\u05d9\u05d1 \u05dc\u05e7\u05d5\u05d1\u05e5 \u05d4\u05de\u05e4\u05ea\u05d7 \u05e9\u05dc SSH (\u05d1\u05de\u05e7\u05d5\u05dd \u05dc\u05e1\u05d9\u05e1\u05de\u05d4)", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/hu.json b/homeassistant/components/asuswrt/translations/hu.json index 4f47781a15c56..ff64372f1b0d6 100644 --- a/homeassistant/components/asuswrt/translations/hu.json +++ b/homeassistant/components/asuswrt/translations/hu.json @@ -6,19 +6,24 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", + "pwd_and_ssh": "Csak jelsz\u00f3 vagy SSH kulcsf\u00e1jlt adjon meg", + "pwd_or_ssh": "K\u00e9rj\u00fck, adja meg a jelsz\u00f3t vagy az SSH kulcsf\u00e1jlt", "ssh_not_file": "Az SSH kulcsf\u00e1jl nem tal\u00e1lhat\u00f3", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "mode": "M\u00f3d", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", + "protocol": "Haszn\u00e1lhat\u00f3 kommunik\u00e1ci\u00f3s protokoll", + "ssh_key": "Az SSH kulcsf\u00e1jl el\u00e9r\u00e9si \u00fatja (jelsz\u00f3 helyett)", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "\u00c1ll\u00edtsa be a sz\u00fcks\u00e9ges param\u00e9tert az \u00fatv\u00e1laszt\u00f3hoz val\u00f3 csatlakoz\u00e1shoz", "title": "AsusWRT" } } @@ -26,6 +31,13 @@ "options": { "step": { "init": { + "data": { + "consider_home": "V\u00e1rakoz\u00e1si m\u00e1sodpercek, miel\u0151tt egy eszk\u00f6zt lecsatlakoztat", + "dnsmasq": "A dnsmasq.leasing f\u00e1jlok helye az \u00fatv\u00e1laszt\u00f3n", + "interface": "Az a fel\u00fclet, amelyr\u0151l statisztik\u00e1kat szeretne kapni (pl. eth0, eth1 stb.)", + "require_ip": "Az eszk\u00f6z\u00f6knek IP-vel kell rendelkezni\u00fck (hozz\u00e1f\u00e9r\u00e9si pont m\u00f3dhoz)", + "track_unknown": "Az ismeretlen / n\u00e9v n\u00e9lk\u00fcli eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se" + }, "title": "AsusWRT Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/asuswrt/translations/ja.json b/homeassistant/components/asuswrt/translations/ja.json new file mode 100644 index 0000000000000..ab253e324ceb3 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/ja.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "pwd_and_ssh": "\u30d1\u30b9\u30ef\u30fc\u30c9\u307e\u305f\u306fSSH\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u306e\u307f\u63d0\u4f9b", + "pwd_or_ssh": "\u30d1\u30b9\u30ef\u30fc\u30c9\u307e\u305f\u306fSSH\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u3092\u63d0\u4f9b\u3057\u3066\u304f\u3060\u3055\u3044", + "ssh_not_file": "SSH\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "mode": "\u30e2\u30fc\u30c9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "protocol": "\u4f7f\u7528\u3059\u308b\u901a\u4fe1\u30d7\u30ed\u30c8\u30b3\u30eb", + "ssh_key": "SSH\u30ad\u30fc \u30d5\u30a1\u30a4\u30eb\u3078\u306e\u30d1\u30b9 (\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u4ee3\u308f\u308a)", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30eb\u30fc\u30bf\u30fc\u306b\u63a5\u7d9a\u3059\u308b\u305f\u3081\u306b\u5fc5\u8981\u306a\u30d1\u30e9\u30e1\u30fc\u30bf\u30fc\u3092\u8a2d\u5b9a\u3057\u307e\u3059", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u30c7\u30d0\u30a4\u30b9\u306e\u96e2\u8131\u3092\u691c\u8a0e\u3059\u308b\u307e\u3067\u306e\u5f85\u3061\u6642\u9593(\u79d2)", + "dnsmasq": "dnsmasq.leases\u30d5\u30a1\u30a4\u30eb\u306e\u30eb\u30fc\u30bf\u30fc\u5185\u306e\u5834\u6240", + "interface": "\u7d71\u8a08\u3092\u53d6\u5f97\u3057\u305f\u3044\u30a4\u30f3\u30bf\u30d5\u30a7\u30fc\u30b9(\u4f8b: eth0\u3001eth1\u306a\u3069)", + "require_ip": "\u30c7\u30d0\u30a4\u30b9\u306b\u306fIP\u304c\u5fc5\u8981\u3067\u3059(\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u30e2\u30fc\u30c9\u306e\u5834\u5408)", + "track_unknown": "\u8ffd\u8de1\u4e0d\u660e/\u540d\u524d\u306e\u306a\u3044\u30c7\u30d0\u30a4\u30b9" + }, + "title": "AsusWRT\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/ru.json b/homeassistant/components/asuswrt/translations/ru.json index a2090b1faf637..35254821f2356 100644 --- a/homeassistant/components/asuswrt/translations/ru.json +++ b/homeassistant/components/asuswrt/translations/ru.json @@ -20,10 +20,10 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "protocol": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b \u0441\u0432\u044f\u0437\u0438", - "ssh_key": "\u041f\u0443\u0442\u044c \u0444\u0430\u0439\u043b\u0443 \u043a\u043b\u044e\u0447\u0435\u0439 SSH (\u0432\u043c\u0435\u0441\u0442\u043e \u043f\u0430\u0440\u043e\u043b\u044f)", + "ssh_key": "\u041f\u0443\u0442\u044c \u043a \u0444\u0430\u0439\u043b\u0443 \u043a\u043b\u044e\u0447\u0435\u0439 SSH (\u0432\u043c\u0435\u0441\u0442\u043e \u043f\u0430\u0440\u043e\u043b\u044f)", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u0440\u043e\u0443\u0442\u0435\u0440\u0443.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u0440\u043e\u0443\u0442\u0435\u0440\u043e\u043c AsusWRT.", "title": "AsusWRT" } } @@ -32,7 +32,7 @@ "step": { "init": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "dnsmasq": "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435 \u0444\u0430\u0439\u043b\u043e\u0432 dnsmasq.leases", "interface": "\u0418\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441, \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0443 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, eth0, eth1 \u0438 \u0442. \u0434.)", "require_ip": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0441 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u043c (\u0434\u043b\u044f \u0440\u0435\u0436\u0438\u043c\u0430 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", diff --git a/homeassistant/components/asuswrt/translations/tr.json b/homeassistant/components/asuswrt/translations/tr.json new file mode 100644 index 0000000000000..19012b706feba --- /dev/null +++ b/homeassistant/components/asuswrt/translations/tr.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", + "pwd_and_ssh": "Yaln\u0131zca parola veya SSH anahtar dosyas\u0131 sa\u011flay\u0131n", + "pwd_or_ssh": "L\u00fctfen parola veya SSH anahtar dosyas\u0131 sa\u011flay\u0131n", + "ssh_not_file": "SSH anahtar dosyas\u0131 bulunamad\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Sunucu", + "mode": "Mod", + "name": "Ad", + "password": "Parola", + "port": "Port", + "protocol": "Kullan\u0131lacak ileti\u015fim protokol\u00fc", + "ssh_key": "SSH anahtar dosyan\u0131z\u0131n yolu (\u015fifre yerine)", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Y\u00f6nlendiricinize ba\u011flanmak i\u00e7in gerekli parametreyi ayarlay\u0131n", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Bir cihaz uzakta iken beklenecek saniyeler", + "dnsmasq": "dnsmasq.leases dosyalar\u0131n\u0131n y\u00f6nlendiricisindeki konum", + "interface": "\u0130statistik almak istedi\u011finiz aray\u00fcz (\u00f6rn. eth0,eth1 vb.)", + "require_ip": "Cihazlar\u0131n IP'si olmal\u0131d\u0131r (eri\u015fim noktas\u0131 modu i\u00e7in)", + "track_unknown": "Bilinmeyen / ads\u0131z cihazlar\u0131 takip edin" + }, + "title": "AsusWRT Se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/zh-Hans.json b/homeassistant/components/asuswrt/translations/zh-Hans.json new file mode 100644 index 0000000000000..69f7bf98df39d --- /dev/null +++ b/homeassistant/components/asuswrt/translations/zh-Hans.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86\uff0c\u4e14\u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_host": "\u65e0\u6548\u7684\u4e3b\u673a\u5730\u5740\u6216 IP \u5730\u5740", + "pwd_and_ssh": "\u53ea\u63d0\u4f9b\u5bc6\u7801\u6216 SSH \u5bc6\u94a5\u6587\u4ef6", + "pwd_or_ssh": "\u8bf7\u63d0\u4f9b\u5bc6\u7801\u6216 SSH \u5bc6\u94a5\u6587\u4ef6", + "ssh_not_file": "\u672a\u627e\u5230 SSH \u5bc6\u94a5\u6587\u4ef6", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "mode": "\u4f7f\u7528\u6a21\u5f0f", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "protocol": "\u901a\u4fe1\u534f\u8bae", + "ssh_key": "SSH \u5bc6\u94a5\u6587\u4ef6\u8def\u5f84 (\u4e0d\u662f\u5bc6\u7801)", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8bbe\u7f6e\u8fde\u63a5\u5230\u8def\u7531\u5668\u6240\u9700\u7684\u53c2\u6570", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u7b49\u5f85\u591a\u5c11\u79d2\u540e\u5219\u5224\u5b9a\u8bbe\u5907\u79bb\u5f00", + "dnsmasq": "\u8def\u7531\u5668\u4e2d\u7684 dnsmasq.leases \u6587\u4ef6\u4f4d\u7f6e", + "interface": "\u60f3\u8981\u76d1\u6d4b\u7684\u7aef\u53e3(\u4f8b\u5982: eth0,eth1 \u7b49)", + "require_ip": "\u8bbe\u5907\u5fc5\u987b\u5177\u6709 IP (\u7528\u4e8e\u63a5\u5165\u70b9\u6a21\u5f0f)" + }, + "title": "AsusWRT \u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/zh-Hant.json b/homeassistant/components/asuswrt/translations/zh-Hant.json index 8caddacd23e15..7aabf592ee383 100644 --- a/homeassistant/components/asuswrt/translations/zh-Hant.json +++ b/homeassistant/components/asuswrt/translations/zh-Hant.json @@ -6,9 +6,9 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740", - "pwd_and_ssh": "\u50c5\u63d0\u4f9b\u5bc6\u78bc\u6216 SSH \u5bc6\u9470\u6a94\u6848", - "pwd_or_ssh": "\u8acb\u8f38\u5165\u5bc6\u78bc\u6216 SSH \u5bc6\u9470\u6a94\u6848", - "ssh_not_file": "\u627e\u4e0d\u5230 SSH \u5bc6\u9470\u6a94\u6848", + "pwd_and_ssh": "\u50c5\u63d0\u4f9b\u5bc6\u78bc\u6216 SSH \u91d1\u9470\u6a94\u6848", + "pwd_or_ssh": "\u8acb\u8f38\u5165\u5bc6\u78bc\u6216 SSH \u91d1\u9470\u6a94\u6848", + "ssh_not_file": "\u627e\u4e0d\u5230 SSH \u91d1\u9470\u6a94\u6848", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { @@ -20,7 +20,7 @@ "password": "\u5bc6\u78bc", "port": "\u901a\u8a0a\u57e0", "protocol": "\u4f7f\u7528\u901a\u8a0a\u606f\u5354\u5b9a", - "ssh_key": "SSH \u5bc6\u9470\u6a94\u6848\u8def\u5f91\uff08\u975e\u5bc6\u78bc\uff09", + "ssh_key": "SSH \u91d1\u9470\u6a94\u6848\u8def\u5f91\uff08\u975e\u5bc6\u78bc\uff09", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, "description": "\u8a2d\u5b9a\u6240\u9700\u53c3\u6578\u4ee5\u9023\u7dda\u81f3\u8def\u7531\u5668", diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index e6347563bc2c6..82340032a1a45 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -5,10 +5,8 @@ import async_timeout from pyatag import AtagException, AtagOne -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.water_heater import DOMAIN as WATER_HEATER from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -21,15 +19,15 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "atag" -PLATFORMS = [CLIMATE, WATER_HEATER, SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.WATER_HEATER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Atag integration from a config entry.""" async def _async_update_data(): """Update data via library.""" - with async_timeout.timeout(20): + async with async_timeout.timeout(20): try: await atag.update() except AtagException as err: @@ -75,27 +73,16 @@ def __init__(self, coordinator: DataUpdateCoordinator, atag_id: str) -> None: super().__init__(coordinator) self._id = atag_id - self._name = DOMAIN.title() + self._attr_name = DOMAIN.title() + self._attr_unique_id = f"{coordinator.data.id}-{atag_id}" @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - device = self.coordinator.data.id - version = self.coordinator.data.apiversion - return { - "identifiers": {(DOMAIN, device)}, - "name": "Atag Thermostat", - "model": "Atag One", - "sw_version": version, - "manufacturer": "Atag", - } - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self.coordinator.data.id}-{self._id}" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data.id)}, + manufacturer="Atag", + model="Atag One", + name="Atag Thermostat", + sw_version=self.coordinator.data.apiversion, + ) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index da7e6a14a73ef..d58d475d50678 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -12,9 +12,9 @@ SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, Platform -from . import CLIMATE, DOMAIN, AtagEntity +from . import DOMAIN, AtagEntity PRESET_MAP = { "Manual": "manual", @@ -31,40 +31,34 @@ async def async_setup_entry(hass, entry, async_add_entities): """Load a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([AtagThermostat(coordinator, CLIMATE)]) + async_add_entities([AtagThermostat(coordinator, Platform.CLIMATE)]) class AtagThermostat(AtagEntity, ClimateEntity): """Atag climate device.""" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS + _attr_hvac_modes = HVAC_MODES + _attr_preset_modes = list(PRESET_MAP.keys()) + _attr_supported_features = SUPPORT_FLAGS + + def __init__(self, coordinator, atag_id): + """Initialize an Atag climate device.""" + super().__init__(coordinator, atag_id) + self._attr_temperature_unit = coordinator.data.climate.temp_unit @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> str | None: # type: ignore[override] """Return hvac operation ie. heat, cool mode.""" if self.coordinator.data.climate.hvac_mode in HVAC_MODES: return self.coordinator.data.climate.hvac_mode return None - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return HVAC_MODES - @property def hvac_action(self) -> str | None: """Return the current running hvac operation.""" is_active = self.coordinator.data.climate.status return CURRENT_HVAC_HEAT if is_active else CURRENT_HVAC_IDLE - @property - def temperature_unit(self) -> str | None: - """Return the unit of measurement.""" - return self.coordinator.data.climate.temp_unit - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -81,11 +75,6 @@ def preset_mode(self) -> str | None: preset = self.coordinator.data.climate.preset_mode return PRESET_INVERTED.get(preset) - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return list(PRESET_MAP.keys()) - async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" await self.coordinator.data.climate.set_temp(kwargs.get(ATTR_TEMPERATURE)) diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 88ccbdc899ff2..0f1599098a6aa 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -1,8 +1,6 @@ """Initialization of ATAG One sensor platform.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import ( - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRESSURE_BAR, TEMP_CELSIUS, @@ -36,10 +34,25 @@ class AtagSensor(AtagEntity, SensorEntity): def __init__(self, coordinator, sensor): """Initialize Atag sensor.""" super().__init__(coordinator, SENSORS[sensor]) - self._name = sensor + self._attr_name = sensor + if coordinator.data.report[self._id].sensorclass in ( + SensorDeviceClass.PRESSURE, + SensorDeviceClass.TEMPERATURE, + ): + self._attr_device_class = coordinator.data.report[self._id].sensorclass + if coordinator.data.report[self._id].measure in ( + PRESSURE_BAR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + PERCENTAGE, + TIME_HOURS, + ): + self._attr_native_unit_of_measurement = coordinator.data.report[ + self._id + ].measure @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data.report[self._id].state @@ -47,26 +60,3 @@ def state(self): def icon(self): """Return icon.""" return self.coordinator.data.report[self._id].icon - - @property - def device_class(self): - """Return deviceclass.""" - if self.coordinator.data.report[self._id].sensorclass in [ - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - ]: - return self.coordinator.data.report[self._id].sensorclass - return None - - @property - def unit_of_measurement(self): - """Return measure.""" - if self.coordinator.data.report[self._id].measure in [ - PRESSURE_BAR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - PERCENTAGE, - TIME_HOURS, - ]: - return self.coordinator.data.report[self._id].measure - return None diff --git a/homeassistant/components/atag/translations/bg.json b/homeassistant/components/atag/translations/bg.json new file mode 100644 index 0000000000000..527adb67bf7be --- /dev/null +++ b/homeassistant/components/atag/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/ca.json b/homeassistant/components/atag/translations/ca.json index dbd73d0bf4376..537d347a2286f 100644 --- a/homeassistant/components/atag/translations/ca.json +++ b/homeassistant/components/atag/translations/ca.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "Correu electr\u00f2nic", "host": "Amfitri\u00f3", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/cs.json b/homeassistant/components/atag/translations/cs.json index 105c53e9a467d..a94ba7fe391ae 100644 --- a/homeassistant/components/atag/translations/cs.json +++ b/homeassistant/components/atag/translations/cs.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "E-mail", "host": "Hostitel", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/de.json b/homeassistant/components/atag/translations/de.json index 8b2b7ce4dff99..976faaa370dbb 100644 --- a/homeassistant/components/atag/translations/de.json +++ b/homeassistant/components/atag/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Ger\u00e4t wurde bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -10,11 +10,10 @@ "step": { "user": { "data": { - "email": "E-Mail", "host": "Host", "port": "Port" }, - "title": "Stellen Sie eine Verbindung zum Ger\u00e4t her" + "title": "Verbinden mit dem Ger\u00e4t" } } } diff --git a/homeassistant/components/atag/translations/en.json b/homeassistant/components/atag/translations/en.json index ea354acffde4a..1cd25c2a9b24a 100644 --- a/homeassistant/components/atag/translations/en.json +++ b/homeassistant/components/atag/translations/en.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "Email", "host": "Host", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/es-419.json b/homeassistant/components/atag/translations/es-419.json index 68da80cbb7e7b..358bc754c9762 100644 --- a/homeassistant/components/atag/translations/es-419.json +++ b/homeassistant/components/atag/translations/es-419.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "Solo se puede agregar un dispositivo Atag a Home Assistant" }, + "error": { + "unauthorized": "Emparejamiento denegado, verifique el dispositivo para obtener una solicitud de autenticaci\u00f3n" + }, "step": { "user": { "data": { - "email": "Correo electr\u00f3nico (opcional)", "host": "Host", "port": "Puerto (10000)" }, diff --git a/homeassistant/components/atag/translations/es.json b/homeassistant/components/atag/translations/es.json index b71c91693d153..ed89c8c385c66 100644 --- a/homeassistant/components/atag/translations/es.json +++ b/homeassistant/components/atag/translations/es.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "Correo electr\u00f3nico (Opcional)", "host": "Host", "port": "Puerto" }, diff --git a/homeassistant/components/atag/translations/et.json b/homeassistant/components/atag/translations/et.json index fd0651a219c62..fd157a3d5412e 100644 --- a/homeassistant/components/atag/translations/et.json +++ b/homeassistant/components/atag/translations/et.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "E-post", "host": "", "port": "" }, diff --git a/homeassistant/components/atag/translations/fr.json b/homeassistant/components/atag/translations/fr.json index c8a19a44eb432..c42b64a16c804 100644 --- a/homeassistant/components/atag/translations/fr.json +++ b/homeassistant/components/atag/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Un seul appareil Atag peut \u00eatre ajout\u00e9 \u00e0 Home Assistant" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -10,8 +10,7 @@ "step": { "user": { "data": { - "email": "Courriel (facultatif)", - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Se connecter \u00e0 l'appareil" diff --git a/homeassistant/components/atag/translations/he.json b/homeassistant/components/atag/translations/he.json index 9212fe8f93f9d..c3a67844fdd6e 100644 --- a/homeassistant/components/atag/translations/he.json +++ b/homeassistant/components/atag/translations/he.json @@ -1,9 +1,16 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, "step": { "user": { "data": { - "email": "Payload (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" } } } diff --git a/homeassistant/components/atag/translations/hu.json b/homeassistant/components/atag/translations/hu.json index 98e947ae64387..aa605923dfdb6 100644 --- a/homeassistant/components/atag/translations/hu.json +++ b/homeassistant/components/atag/translations/hu.json @@ -4,15 +4,16 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unauthorized": "A p\u00e1ros\u00edt\u00e1s megtagadva, ellen\u0151rizze az eszk\u00f6z hiteles\u00edt\u00e9si k\u00e9r\u00e9s\u00e9t" }, "step": { "user": { "data": { - "email": "E-mail", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" - } + }, + "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } } diff --git a/homeassistant/components/atag/translations/id.json b/homeassistant/components/atag/translations/id.json index 24732f8c235bd..33f4cf62b2e9e 100644 --- a/homeassistant/components/atag/translations/id.json +++ b/homeassistant/components/atag/translations/id.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "Email", "host": "Host", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/it.json b/homeassistant/components/atag/translations/it.json index 060f9d21b2036..1bc473a60018c 100644 --- a/homeassistant/components/atag/translations/it.json +++ b/homeassistant/components/atag/translations/it.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "E-mail", "host": "Host", "port": "Porta" }, diff --git a/homeassistant/components/atag/translations/ja.json b/homeassistant/components/atag/translations/ja.json new file mode 100644 index 0000000000000..5aecec86168f1 --- /dev/null +++ b/homeassistant/components/atag/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unauthorized": "\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u62d2\u5426\u3055\u308c\u307e\u3057\u305f\u3002\u30c7\u30d0\u30a4\u30b9\u3067\u8a8d\u8a3c\u8981\u6c42\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/ko.json b/homeassistant/components/atag/translations/ko.json index 9b0c1ea1b3665..25de83f70c354 100644 --- a/homeassistant/components/atag/translations/ko.json +++ b/homeassistant/components/atag/translations/ko.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "\uc774\uba54\uc77c", "host": "\ud638\uc2a4\ud2b8", "port": "\ud3ec\ud2b8" }, diff --git a/homeassistant/components/atag/translations/lb.json b/homeassistant/components/atag/translations/lb.json index afb8aea1697f6..1238d2bcf52da 100644 --- a/homeassistant/components/atag/translations/lb.json +++ b/homeassistant/components/atag/translations/lb.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "E-Mail", "host": "Apparat", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/nl.json b/homeassistant/components/atag/translations/nl.json index 55478f765e2dd..98200dd3f6e8c 100644 --- a/homeassistant/components/atag/translations/nl.json +++ b/homeassistant/components/atag/translations/nl.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "Email", "host": "Host", "port": "Poort " }, diff --git a/homeassistant/components/atag/translations/no.json b/homeassistant/components/atag/translations/no.json index 650605c270fca..6a2736ae8b47c 100644 --- a/homeassistant/components/atag/translations/no.json +++ b/homeassistant/components/atag/translations/no.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "E-post", "host": "Vert", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/pl.json b/homeassistant/components/atag/translations/pl.json index 4c690ba057ef5..bdd38a3d9805f 100644 --- a/homeassistant/components/atag/translations/pl.json +++ b/homeassistant/components/atag/translations/pl.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "Adres e-mail", "host": "Nazwa hosta lub adres IP", "port": "Port" }, diff --git a/homeassistant/components/atag/translations/pt-BR.json b/homeassistant/components/atag/translations/pt-BR.json index a98060320fc27..5d9d507911090 100644 --- a/homeassistant/components/atag/translations/pt-BR.json +++ b/homeassistant/components/atag/translations/pt-BR.json @@ -2,13 +2,6 @@ "config": { "abort": { "already_configured": "Este dispositivo j\u00e1 foi adicionado ao Home Assistant" - }, - "step": { - "user": { - "data": { - "email": "E-mail (Opcional)" - } - } } } } \ No newline at end of file diff --git a/homeassistant/components/atag/translations/pt.json b/homeassistant/components/atag/translations/pt.json index 16752dd0071dc..fa5aa3de3179d 100644 --- a/homeassistant/components/atag/translations/pt.json +++ b/homeassistant/components/atag/translations/pt.json @@ -9,7 +9,6 @@ "step": { "user": { "data": { - "email": "E-mail (opcional)", "host": "Servidor", "port": "Porta" } diff --git a/homeassistant/components/atag/translations/ru.json b/homeassistant/components/atag/translations/ru.json index beb0ee904cd01..feb21d3addd59 100644 --- a/homeassistant/components/atag/translations/ru.json +++ b/homeassistant/components/atag/translations/ru.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" }, diff --git a/homeassistant/components/atag/translations/tr.json b/homeassistant/components/atag/translations/tr.json index f7c94d0a97646..218835b93259f 100644 --- a/homeassistant/components/atag/translations/tr.json +++ b/homeassistant/components/atag/translations/tr.json @@ -10,8 +10,7 @@ "step": { "user": { "data": { - "email": "E-posta", - "host": "Ana Bilgisayar", + "host": "Sunucu", "port": "Port" }, "title": "Cihaza ba\u011flan\u0131n" diff --git a/homeassistant/components/atag/translations/uk.json b/homeassistant/components/atag/translations/uk.json index ee0a077d90085..d6b259debc623 100644 --- a/homeassistant/components/atag/translations/uk.json +++ b/homeassistant/components/atag/translations/uk.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "\u0410\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438", "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" }, diff --git a/homeassistant/components/atag/translations/zh-Hant.json b/homeassistant/components/atag/translations/zh-Hant.json index 8eb427b95eec7..c9904a954d7dc 100644 --- a/homeassistant/components/atag/translations/zh-Hant.json +++ b/homeassistant/components/atag/translations/zh-Hant.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "email": "\u96fb\u5b50\u90f5\u4ef6", "host": "\u4e3b\u6a5f\u7aef", "port": "\u901a\u8a0a\u57e0" }, diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index dac56edf89d59..b45e877a31013 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -5,9 +5,9 @@ STATE_PERFORMANCE, WaterHeaterEntity, ) -from homeassistant.const import STATE_OFF, TEMP_CELSIUS +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, Platform -from . import DOMAIN, WATER_HEATER, AtagEntity +from . import DOMAIN, AtagEntity SUPPORT_FLAGS_HEATER = 0 OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE] @@ -16,21 +16,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Initialize DHW device from config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([AtagWaterHeater(coordinator, WATER_HEATER)]) + async_add_entities([AtagWaterHeater(coordinator, Platform.WATER_HEATER)]) class AtagWaterHeater(AtagEntity, WaterHeaterEntity): """Representation of an ATAG water heater.""" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS_HEATER - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS + _attr_operation_list = OPERATION_LIST + _attr_supported_features = SUPPORT_FLAGS_HEATER + _attr_temperature_unit = TEMP_CELSIUS @property def current_temperature(self): @@ -43,11 +37,6 @@ def current_operation(self): operation = self.coordinator.data.dhw.current_operation return operation if operation in self.operation_list else STATE_OFF - @property - def operation_list(self): - """List of available operation modes.""" - return OPERATION_LIST - async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if await self.coordinator.data.dhw.set_temp(kwargs.get(ATTR_TEMPERATURE)): diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 1bf540850649b..afe776694585f 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -6,8 +6,8 @@ import voluptuous as vol from homeassistant.components.switch import ( - DEVICE_CLASS_OUTLET, PLATFORM_SCHEMA, + SwitchDeviceClass, SwitchEntity, ) from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME @@ -61,62 +61,37 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async for outlet in outlets: switches.append(AtenSwitch(dev, mac, outlet.id, outlet.name)) - async_add_entities(switches) + async_add_entities(switches, True) class AtenSwitch(SwitchEntity): """Represents an ATEN PE switch.""" + _attr_device_class = SwitchDeviceClass.OUTLET + def __init__(self, device, mac, outlet, name): """Initialize an ATEN PE switch.""" self._device = device - self._mac = mac self._outlet = outlet - self._name = name or f"Outlet {outlet}" - self._enabled = False - self._outlet_power = 0.0 - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._mac}-{self._outlet}" - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_OUTLET - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._enabled - - @property - def current_power_w(self) -> float: - """Return the current power usage in W.""" - return self._outlet_power + self._attr_unique_id = f"{mac}-{outlet}" + self._attr_name = name or f"Outlet {outlet}" async def async_turn_on(self, **kwargs): """Turn the switch on.""" await self._device.setOutletStatus(self._outlet, "on") - self._enabled = True + self._attr_is_on = True async def async_turn_off(self, **kwargs): """Turn the switch off.""" await self._device.setOutletStatus(self._outlet, "off") - self._enabled = False + self._attr_is_on = False async def async_update(self): """Process update from entity.""" status = await self._device.displayOutletStatus(self._outlet) if status == "on": - self._enabled = True - self._outlet_power = await self._device.outletPower(self._outlet) - elif status == "off": - self._enabled = False - self._outlet_power = 0.0 + self._attr_is_on = True + self._attr_current_power_w = await self._device.outletPower(self._outlet) + else: + self._attr_is_on = False + self._attr_current_power_w = 0.0 diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index d10024f64c244..e9a55d0e0893f 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -5,12 +5,16 @@ from pyatome.client import AtomeClient, PyAtomeError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, ) @@ -39,8 +43,6 @@ MONTHLY_TYPE = "month" YEARLY_TYPE = "year" -ICON = "mdi:flash" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -77,7 +79,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AtomeData: """Stores data retrieved from Neurio sensor.""" - def __init__(self, client: AtomeClient): + def __init__(self, client: AtomeClient) -> None: """Initialize the data.""" self.atome_client = client self._live_power = None @@ -107,11 +109,13 @@ def is_connected(self): """Return latest active power value.""" return self._is_connected - @Throttle(LIVE_SCAN_INTERVAL) - def update_live_usage(self): - """Return current power value.""" - try: - values = self.atome_client.get_live() + def _retrieve_live(self): + values = self.atome_client.get_live() + if ( + values.get("last") + and values.get("subscribed") + and (values.get("isConnected") is not None) + ): self._live_power = values["last"] self._subscribed_power = values["subscribed"] self._is_connected = values["isConnected"] @@ -121,9 +125,47 @@ def update_live_usage(self): self._is_connected, self._subscribed_power, ) + return True - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + _LOGGER.error("Live Data : Missing last value in values: %s", values) + return False + + @Throttle(LIVE_SCAN_INTERVAL) + def update_live_usage(self): + """Return current power value.""" + if not self._retrieve_live(): + _LOGGER.debug("Perform Reconnect during live request") + self.atome_client.login() + self._retrieve_live() + + def _retrieve_period_usage(self, period_type): + """Return current daily/weekly/monthly/yearly power usage.""" + values = self.atome_client.get_consumption(period_type) + if values.get("total") and values.get("price"): + period_usage = values["total"] / 1000 + period_price = values["price"] + _LOGGER.debug("Updating Atome %s data. Got: %d", period_type, period_usage) + return True, period_usage, period_price + + _LOGGER.error("%s : Missing last value in values: %s", period_type, values) + return False, None, None + + def _retrieve_period_usage_with_retry(self, period_type): + """Return current daily/weekly/monthly/yearly power usage with one retry.""" + ( + retrieve_success, + period_usage, + period_price, + ) = self._retrieve_period_usage(period_type) + if not retrieve_success: + _LOGGER.debug("Perform Reconnect during %s", period_type) + self.atome_client.login() + ( + retrieve_success, + period_usage, + period_price, + ) = self._retrieve_period_usage(period_type) + return (period_usage, period_price) @property def day_usage(self): @@ -138,14 +180,10 @@ def day_price(self): @Throttle(DAILY_SCAN_INTERVAL) def update_day_usage(self): """Return current daily power usage.""" - try: - values = self.atome_client.get_consumption(DAILY_TYPE) - self._day_usage = values["total"] / 1000 - self._day_price = values["price"] - _LOGGER.debug("Updating Atome daily data. Got: %d", self._day_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._day_usage, + self._day_price, + ) = self._retrieve_period_usage_with_retry(DAILY_TYPE) @property def week_usage(self): @@ -160,14 +198,10 @@ def week_price(self): @Throttle(WEEKLY_SCAN_INTERVAL) def update_week_usage(self): """Return current weekly power usage.""" - try: - values = self.atome_client.get_consumption(WEEKLY_TYPE) - self._week_usage = values["total"] / 1000 - self._week_price = values["price"] - _LOGGER.debug("Updating Atome weekly data. Got: %d", self._week_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._week_usage, + self._week_price, + ) = self._retrieve_period_usage_with_retry(WEEKLY_TYPE) @property def month_usage(self): @@ -182,14 +216,10 @@ def month_price(self): @Throttle(MONTHLY_SCAN_INTERVAL) def update_month_usage(self): """Return current monthly power usage.""" - try: - values = self.atome_client.get_consumption(MONTHLY_TYPE) - self._month_usage = values["total"] / 1000 - self._month_price = values["price"] - _LOGGER.debug("Updating Atome monthly data. Got: %d", self._month_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._month_usage, + self._month_price, + ) = self._retrieve_period_usage_with_retry(MONTHLY_TYPE) @property def year_usage(self): @@ -204,14 +234,10 @@ def year_price(self): @Throttle(YEARLY_SCAN_INTERVAL) def update_year_usage(self): """Return current yearly power usage.""" - try: - values = self.atome_client.get_consumption(YEARLY_TYPE) - self._year_usage = values["total"] / 1000 - self._year_price = values["price"] - _LOGGER.debug("Updating Atome yearly data. Got: %d", self._year_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._year_usage, + self._year_price, + ) = self._retrieve_period_usage_with_retry(YEARLY_TYPE) class AtomeSensor(SensorEntity): @@ -219,47 +245,19 @@ class AtomeSensor(SensorEntity): def __init__(self, data, name, sensor_type): """Initialize the sensor.""" - self._name = name + self._attr_name = name self._data = data - self._state = None - self._attributes = {} self._sensor_type = sensor_type if sensor_type == LIVE_TYPE: - self._unit_of_measurement = POWER_WATT + self._attr_device_class = SensorDeviceClass.POWER + self._attr_native_unit_of_measurement = POWER_WATT + self._attr_state_class = SensorStateClass.MEASUREMENT else: - self._unit_of_measurement = ENERGY_KILO_WATT_HOUR - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_POWER + self._attr_device_class = SensorDeviceClass.ENERGY + self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_state_class = SensorStateClass.TOTAL_INCREASING def update(self): """Update device state.""" @@ -267,11 +265,13 @@ def update(self): update_function() if self._sensor_type == LIVE_TYPE: - self._state = self._data.live_power - self._attributes["subscribed_power"] = self._data.subscribed_power - self._attributes["is_connected"] = self._data.is_connected + self._attr_native_value = self._data.live_power + self._attr_extra_state_attributes = { + "subscribed_power": self._data.subscribed_power, + "is_connected": self._data.is_connected, + } else: - self._state = getattr(self._data, f"{self._sensor_type}_usage") - self._attributes["price"] = getattr( - self._data, f"{self._sensor_type}_price" - ) + self._attr_native_value = getattr(self._data, f"{self._sensor_type}_usage") + self._attr_extra_state_attributes = { + "price": getattr(self._data, f"{self._sensor_type}_price") + } diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 30374dcb2202c..474e69db435cf 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -33,7 +33,7 @@ ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" august_gateway = AugustGateway(hass) @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): raise ConfigEntryNotReady from err -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass.data[DOMAIN][entry.entry_id][DATA_AUGUST].async_stop() @@ -173,10 +173,10 @@ async def _async_refresh(self, time): async def _async_refresh_device_detail_by_ids(self, device_ids_list): await asyncio.gather( - *[ + *( self._async_refresh_device_detail_by_id(device_id) for device_id in device_ids_list - ] + ) ) async def _async_refresh_device_detail_by_id(self, device_id): @@ -272,19 +272,13 @@ async def _async_call_api_op_requires_bridge( def _remove_inoperative_doorbells(self): for doorbell in list(self.doorbells): device_id = doorbell.device_id - doorbell_is_operative = False - doorbell_detail = self._device_detail_by_id.get(device_id) - if doorbell_detail is None: - _LOGGER.info( - "The doorbell %s could not be setup because the system could not fetch details about the doorbell", - doorbell.device_name, - ) - else: - doorbell_is_operative = True - - if not doorbell_is_operative: - del self._doorbells_by_id[device_id] - del self._device_detail_by_id[device_id] + if self._device_detail_by_id.get(device_id): + continue + _LOGGER.info( + "The doorbell %s could not be setup because the system could not fetch details about the doorbell", + doorbell.device_name, + ) + del self._doorbells_by_id[device_id] def _remove_inoperative_locks(self): # Remove non-operative locks as there must @@ -292,7 +286,6 @@ def _remove_inoperative_locks(self): # be usable for lock in list(self.locks): device_id = lock.device_id - lock_is_operative = False lock_detail = self._device_detail_by_id.get(device_id) if lock_detail is None: _LOGGER.info( @@ -304,14 +297,12 @@ def _remove_inoperative_locks(self): "The lock %s could not be setup because it does not have a bridge (Connect)", lock.device_name, ) + del self._device_detail_by_id[device_id] # Bridge may come back online later so we still add the device since we will # have a pubnub subscription to tell use when it recovers else: - lock_is_operative = True - - if not lock_is_operative: - del self._locks_by_id[device_id] - del self._device_detail_by_id[device_id] + continue + del self._locks_by_id[device_id] def _save_live_attrs(lock_detail): diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 402a2ccd610b6..f64b27c7c85e2 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -61,9 +61,9 @@ def async_stop(self): """Cleanup any debounces.""" for debouncer in self._update_debounce.values(): debouncer.async_cancel() - for house_id in self._schedule_updates: - if self._schedule_updates[house_id] is not None: - self._schedule_updates[house_id]() + for house_id, updater in self._schedule_updates.items(): + if updater is not None: + updater() self._schedule_updates[house_id] = None def get_latest_device_activity(self, device_id, activity_types): @@ -98,10 +98,10 @@ async def _async_refresh(self, time): async def _async_update_device_activities(self, time): _LOGGER.debug("Start retrieving device activities") await asyncio.gather( - *[ + *( self._update_debounce[house_id].async_call() for house_id in self._house_ids - ] + ) ) self._last_update_time = time diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index e72d4b186a5fe..b748c0994a143 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -1,21 +1,32 @@ """Support for August binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timedelta import logging +from typing import cast -from yalexs.activity import ACTION_DOORBELL_CALL_MISSED, SOURCE_PUBNUB, ActivityType +from yalexs.activity import ( + ACTION_DOORBELL_CALL_MISSED, + SOURCE_PUBNUB, + Activity, + ActivityType, +) +from yalexs.doorbell import DoorbellDetail from yalexs.lock import LockDoorStatus from yalexs.util import update_lock_detail_from_activity from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import callback +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.event import async_call_later +from . import AugustData from .const import ACTIVITY_UPDATE_INTERVAL, DATA_AUGUST, DOMAIN from .entity import AugustEntityMixin @@ -27,7 +38,7 @@ ) -def _retrieve_online_state(data, detail): +def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool: """Get the latest state of the sensor.""" # The doorbell will go into standby mode when there is no motion # for a short while. It will wake by itself when needed so we need @@ -36,7 +47,7 @@ def _retrieve_online_state(data, detail): return detail.is_online or detail.is_standby -def _retrieve_motion_state(data, detail): +def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_MOTION} ) @@ -47,7 +58,18 @@ def _retrieve_motion_state(data, detail): return _activity_time_based_state(latest) -def _retrieve_ding_state(data, detail): +def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> bool: + latest = data.activity_stream.get_latest_device_activity( + detail.device_id, {ActivityType.DOORBELL_IMAGE_CAPTURE} + ) + + if latest is None: + return False + + return _activity_time_based_state(latest) + + +def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool: latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_DING} ) @@ -64,34 +86,70 @@ def _retrieve_ding_state(data, detail): return _activity_time_based_state(latest) -def _activity_time_based_state(latest): +def _activity_time_based_state(latest: Activity) -> bool: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION return start <= _native_datetime() <= end -def _native_datetime(): +def _native_datetime() -> datetime: """Return time in the format august uses without timezone.""" return datetime.now() -SENSOR_NAME = 0 -SENSOR_DEVICE_CLASS = 1 -SENSOR_STATE_PROVIDER = 2 -SENSOR_STATE_IS_TIME_BASED = 3 +@dataclass +class AugustRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[AugustData, DoorbellDetail], bool] + is_time_based: bool -# sensor_type: [name, device_class, state_provider, is_time_based] -SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True], - "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True], - "doorbell_online": [ - "Online", - DEVICE_CLASS_CONNECTIVITY, - _retrieve_online_state, - False, - ], -} + +@dataclass +class AugustBinarySensorEntityDescription( + BinarySensorEntityDescription, AugustRequiredKeysMixin +): + """Describes August binary_sensor entity.""" + + +SENSOR_TYPE_DOOR = BinarySensorEntityDescription( + key="door_open", + name="Open", +) + + +SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = ( + AugustBinarySensorEntityDescription( + key="doorbell_ding", + name="Ding", + device_class=BinarySensorDeviceClass.OCCUPANCY, + value_fn=_retrieve_ding_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_motion", + name="Motion", + device_class=BinarySensorDeviceClass.MOTION, + value_fn=_retrieve_motion_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_image_capture", + name="Image Capture", + icon="mdi:file-image", + value_fn=_retrieve_image_capture_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_online", + name="Online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_online_state, + is_time_based=False, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -109,16 +167,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): continue _LOGGER.debug("Adding sensor class door for %s", door.device_name) - entities.append(AugustDoorBinarySensor(data, "door_open", door)) + entities.append(AugustDoorBinarySensor(data, door, SENSOR_TYPE_DOOR)) for doorbell in data.doorbells: - for sensor_type in SENSOR_TYPES_DOORBELL: + for description in SENSOR_TYPES_DOORBELL: _LOGGER.debug( "Adding doorbell sensor class %s for %s", - SENSOR_TYPES_DOORBELL[sensor_type][SENSOR_DEVICE_CLASS], + description.device_class, doorbell.device_name, ) - entities.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) + entities.append(AugustDoorbellBinarySensor(data, doorbell, description)) async_add_entities(entities) @@ -126,34 +184,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August Door binary sensor.""" - def __init__(self, data, sensor_type, device): + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, data, device, description: BinarySensorEntityDescription): """Initialize the sensor.""" super().__init__(data, device) + self.entity_description = description self._data = data - self._sensor_type = sensor_type self._device = device + self._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = ( + f"{self._device_id}_{cast(str, description.name).lower()}" + ) self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._detail.bridge_is_online - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._detail.door_state == LockDoorStatus.OPEN - - @property - def device_class(self): - """Return the class of this device.""" - return DEVICE_CLASS_DOOR - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._device.device_name} Open" - @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" @@ -173,79 +217,44 @@ def _update_from_data(self): if bridge_activity is not None: update_lock_detail_from_activity(self._detail, bridge_activity) - - @property - def unique_id(self) -> str: - """Get the unique of the door open binary sensor.""" - return f"{self._device_id}_open" + self._attr_available = self._detail.bridge_is_online + self._attr_is_on = self._detail.door_state == LockDoorStatus.OPEN class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August binary sensor.""" - def __init__(self, data, sensor_type, device): + entity_description: AugustBinarySensorEntityDescription + + def __init__(self, data, device, description: AugustBinarySensorEntityDescription): """Initialize the sensor.""" super().__init__(data, device) + self.entity_description = description self._check_for_off_update_listener = None self._data = data - self._sensor_type = sensor_type - self._device = device - self._state = None - self._available = False + self._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = ( + f"{self._device_id}_{cast(str, description.name).lower()}" + ) self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def _sensor_config(self): - """Return the config for the sensor.""" - return SENSOR_TYPES_DOORBELL[self._sensor_type] - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._sensor_config[SENSOR_DEVICE_CLASS] - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._device.device_name} {self._sensor_config[SENSOR_NAME]}" - - @property - def _state_provider(self): - """Return the state provider for the binary sensor.""" - return self._sensor_config[SENSOR_STATE_PROVIDER] - - @property - def _is_time_based(self): - """Return true of false if the sensor is time based.""" - return self._sensor_config[SENSOR_STATE_IS_TIME_BASED] - @callback def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - self._state = self._state_provider(self._data, self._detail) + self._attr_is_on = self.entity_description.value_fn(self._data, self._detail) - if self._is_time_based: - self._available = _retrieve_online_state(self._data, self._detail) + if self.entity_description.is_time_based: + self._attr_available = _retrieve_online_state(self._data, self._detail) self._schedule_update_to_recheck_turn_off_sensor() else: - self._available = True + self._attr_available = True def _schedule_update_to_recheck_turn_off_sensor(self): """Schedule an update to recheck the sensor to see if it is ready to turn off.""" # If the sensor is already off there is nothing to do - if not self._state: + if not self.is_on: return # self.hass is only available after setup is completed @@ -258,7 +267,7 @@ def _scheduled_update(now): """Timer callback for sensor update.""" self._check_for_off_update_listener = None self._update_from_data() - if not self._state: + if not self.is_on: self.async_write_ha_state() self._check_for_off_update_listener = async_call_later( @@ -277,8 +286,3 @@ async def async_added_to_hass(self): """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed.""" self._schedule_update_to_recheck_turn_off_sensor() await super().async_added_to_hass() - - @property - def unique_id(self) -> str: - """Get the unique id of the doorbell sensor.""" - return f"{self._device_id}_{self._sensor_config[SENSOR_NAME].lower()}" diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index daaa7624aa3e2..6c1f31c4b9c8e 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,4 +1,5 @@ """Support for August doorbell camera.""" +from __future__ import annotations from yalexs.activity import ActivityType from yalexs.util import update_doorbell_image_from_activity @@ -35,11 +36,8 @@ def __init__(self, data, device, session, timeout): self._session = session self._image_url = None self._image_content = None - - @property - def name(self): - """Return the name of this device.""" - return f"{self._device.device_name} Camera" + self._attr_name = f"{device.device_name} Camera" + self._attr_unique_id = f"{self._device_id:s}_camera" @property def is_recording(self): @@ -65,13 +63,16 @@ def model(self): def _update_from_data(self): """Get the latest state of the sensor.""" doorbell_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, {ActivityType.DOORBELL_MOTION} + self._device_id, + {ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_IMAGE_CAPTURE}, ) if doorbell_activity is not None: update_doorbell_image_from_activity(self._detail, doorbell_activity) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self._update_from_data() @@ -81,8 +82,3 @@ async def async_camera_image(self): self._session, timeout=self._timeout ) return self._image_content - - @property - def unique_id(self) -> str: - """Get the unique id of the camera.""" - return f"{self._device_id:s}_camera" diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index af048f9dc4601..eb7bac9ae1ab9 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -14,20 +14,14 @@ _LOGGER = logging.getLogger(__name__) -async def async_validate_input( - data, - august_gateway, -): +async def async_validate_input(data, august_gateway): """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. Request configuration steps from the user. """ - - code = data.get(VERIFICATION_CODE_KEY) - - if code is not None: + if (code := data.get(VERIFICATION_CODE_KEY)) is not None: result = await august_gateway.authenticator.async_validate_verification_code( code ) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 57e0d5a7fb7cb..dfe9cc0f7002a 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -2,6 +2,8 @@ from datetime import timedelta +from homeassistant.const import Platform + DEFAULT_TIMEOUT = 10 CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" @@ -43,4 +45,9 @@ LOGIN_METHODS = ["phone", "email"] -PLATFORMS = ["camera", "binary_sensor", "lock", "sensor"] +PLATFORMS = [ + Platform.CAMERA, + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, +] diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index b2a9394844900..a0fe44838c227 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -1,6 +1,6 @@ """Base class for August entity.""" from homeassistant.core import callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from . import DOMAIN from .const import MANUFACTURER @@ -11,16 +11,22 @@ class AugustEntityMixin(Entity): """Base implementation for August device.""" + _attr_should_poll = False + def __init__(self, data, device): """Initialize an August device.""" super().__init__() self._data = data self._device = device - - @property - def should_poll(self): - """Return False, updates are controlled via the hub.""" - return False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=MANUFACTURER, + model=self._detail.model, + name=device.device_name, + sw_version=self._detail.firmware_version, + suggested_area=_remove_device_types(device.device_name, DEVICE_TYPES), + configuration_url="https://account.august.com", + ) @property def _device_id(self): @@ -30,19 +36,6 @@ def _device_id(self): def _detail(self): return self._data.get_device_detail(self._device.device_id) - @property - def device_info(self): - """Return the device_info of the device.""" - name = self._device.device_name - return { - "identifiers": {(DOMAIN, self._device_id)}, - "name": name, - "manufacturer": MANUFACTURER, - "sw_version": self._detail.firmware_version, - "model": self._detail.model, - "suggested_area": _remove_device_types(name, DEVICE_TYPES), - } - @callback def _update_from_data_and_write_state(self): self._update_from_data() diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 5499246a1871e..6c9f9113d98b0 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -1,6 +1,7 @@ """Handle August connection setup and authentication.""" import asyncio +from http import HTTPStatus import logging import os @@ -8,12 +9,7 @@ from yalexs.api_async import ApiAsync from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_USERNAME, - HTTP_UNAUTHORIZED, -) +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.helpers import aiohttp_client from .const import ( @@ -97,7 +93,7 @@ async def async_authenticate(self): # by have no access await self.api.async_get_operable_locks(self.access_token) except ClientResponseError as ex: - if ex.status == HTTP_UNAUTHORIZED: + if ex.status == HTTPStatus.UNAUTHORIZED: raise InvalidAuth from ex raise CannotConnect from ex diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 6e4ee7e6f5c92..665b00365577b 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,6 +1,7 @@ """Support for August lock.""" import logging +from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType from yalexs.lock import LockStatus from yalexs.util import update_lock_detail_from_activity @@ -9,12 +10,15 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.util.dt as dt_util from .const import DATA_AUGUST, DOMAIN from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) +LOCK_JAMMED_ERR = 531 + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" @@ -31,8 +35,8 @@ def __init__(self, data, device): self._data = data self._device = device self._lock_status = None - self._changed_by = None - self._available = False + self._attr_name = device.device_name + self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() async def async_lock(self, **kwargs): @@ -44,9 +48,17 @@ async def async_unlock(self, **kwargs): await self._call_lock_operation(self._data.async_unlock) async def _call_lock_operation(self, lock_operation): - activities = await lock_operation(self._device_id) - for lock_activity in activities: - update_lock_detail_from_activity(self._detail, lock_activity) + try: + activities = await lock_operation(self._device_id) + except ClientResponseError as err: + if err.status == LOCK_JAMMED_ERR: + self._detail.lock_status = LockStatus.JAMMED + self._detail.lock_status_datetime = dt_util.utcnow() + else: + raise + else: + for lock_activity in activities: + update_lock_detail_from_activity(self._detail, lock_activity) if self._update_lock_status_from_detail(): _LOGGER.debug( @@ -56,7 +68,7 @@ async def _call_lock_operation(self, lock_operation): self._data.async_signal_device_id_update(self._device_id) def _update_lock_status_from_detail(self): - self._available = self._detail.bridge_is_online + self._attr_available = self._detail.bridge_is_online if self._lock_status != self._detail.lock_status: self._lock_status = self._detail.lock_status @@ -72,7 +84,7 @@ def _update_from_data(self): ) if lock_activity is not None: - self._changed_by = lock_activity.operated_by + self._attr_changed_by = lock_activity.operated_by update_lock_detail_from_activity(self._detail, lock_activity) # If the source is pubnub the lock must be online since its a live update if lock_activity.source == SOURCE_PUBNUB: @@ -86,51 +98,29 @@ def _update_from_data(self): update_lock_detail_from_activity(self._detail, bridge_activity) self._update_lock_status_from_detail() - - @property - def name(self): - """Return the name of this device.""" - return self._device.device_name - - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def is_locked(self): - """Return true if device is on.""" if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: - return None - return self._lock_status is LockStatus.LOCKED - - @property - def changed_by(self): - """Last change triggered by.""" - return self._changed_by + self._attr_is_locked = None + else: + self._attr_is_locked = self._lock_status is LockStatus.LOCKED - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - attributes = {ATTR_BATTERY_LEVEL: self._detail.battery_level} + self._attr_is_jammed = self._lock_status is LockStatus.JAMMED + self._attr_is_locking = self._lock_status is LockStatus.LOCKING + self._attr_is_unlocking = self._lock_status is LockStatus.UNLOCKING + self._attr_extra_state_attributes = { + ATTR_BATTERY_LEVEL: self._detail.battery_level + } if self._detail.keypad is not None: - attributes["keypad_battery_level"] = self._detail.keypad.battery_level - - return attributes + self._attr_extra_state_attributes[ + "keypad_battery_level" + ] = self._detail.keypad.battery_level async def async_added_to_hass(self): """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ATTR_CHANGED_BY in last_state.attributes: - self._changed_by = last_state.attributes[ATTR_CHANGED_BY] - - @property - def unique_id(self) -> str: - """Get the unique id of the lock.""" - return f"{self._device_id:s}_lock" + self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e966338f287cd..201c9ce89aa36 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["yalexs==1.1.11"], + "requirements": ["yalexs==1.1.16"], "codeowners": ["@bdraco"], "dhcp": [ { @@ -13,6 +13,10 @@ "hostname": "connect", "macaddress": "B8B7F1*" }, + { + "hostname": "connect", + "macaddress": "2C9FFB*" + }, { "hostname": "august*", "macaddress": "E076D0*" diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 1d973a83fc3a4..a7b28070f8eb7 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,14 +1,27 @@ """Support for August sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Generic, TypeVar from yalexs.activity import ActivityType +from yalexs.keypad import KeypadDetail +from yalexs.lock import LockDetail -from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE from homeassistant.core import callback +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.restore_state import RestoreEntity +from . import AugustData from .const import ( ATTR_OPERATION_AUTORELOCK, ATTR_OPERATION_KEYPAD, @@ -26,20 +39,46 @@ _LOGGER = logging.getLogger(__name__) -def _retrieve_device_battery_state(detail): +def _retrieve_device_battery_state(detail: LockDetail) -> int: """Get the latest state of the sensor.""" return detail.battery_level -def _retrieve_linked_keypad_battery_state(detail): +def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: """Get the latest state of the sensor.""" return detail.battery_percentage -SENSOR_TYPES_BATTERY = { - "device_battery": {"state_provider": _retrieve_device_battery_state}, - "linked_keypad_battery": {"state_provider": _retrieve_linked_keypad_battery_state}, -} +T = TypeVar("T", LockDetail, KeypadDetail) + + +@dataclass +class AugustRequiredKeysMixin(Generic[T]): + """Mixin for required keys.""" + + value_fn: Callable[[T], int | None] + + +@dataclass +class AugustSensorEntityDescription( + SensorEntityDescription, AugustRequiredKeysMixin[T] +): + """Describes August sensor entity.""" + + +SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( + key="device_battery", + name="Battery", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_device_battery_state, +) + +SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( + key="linked_keypad_battery", + name="Battery", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_linked_keypad_battery_state, +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -60,9 +99,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): operation_sensors.append(device) for device in batteries["device_battery"]: - state_provider = SENSOR_TYPES_BATTERY["device_battery"]["state_provider"] detail = data.get_device_detail(device.device_id) - if detail is None or state_provider(detail) is None: + if detail is None or SENSOR_TYPE_DEVICE_BATTERY.value_fn(detail) is None: _LOGGER.debug( "Not adding battery sensor for %s because it is not present", device.device_name, @@ -72,7 +110,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Adding battery sensor for %s", device.device_name, ) - entities.append(AugustBatterySensor(data, "device_battery", device, device)) + entities.append( + AugustBatterySensor[LockDetail]( + data, device, device, SENSOR_TYPE_DEVICE_BATTERY + ) + ) for device in batteries["linked_keypad_battery"]: detail = data.get_device_detail(device.device_id) @@ -87,8 +129,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Adding keypad battery sensor for %s", device.device_name, ) - keypad_battery_sensor = AugustBatterySensor( - data, "linked_keypad_battery", detail.keypad, device + keypad_battery_sensor = AugustBatterySensor[KeypadDetail]( + data, detail.keypad, device, SENSOR_TYPE_KEYPAD_BATTERY ) entities.append(keypad_battery_sensor) migrate_unique_id_devices.append(keypad_battery_sensor) @@ -125,25 +167,13 @@ def __init__(self, data, device): super().__init__(data, device) self._data = data self._device = device - self._state = None self._operated_remote = None self._operated_keypad = None self._operated_autorelock = None self._operated_time = None - self._available = False self._entity_picture = None self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - @property def name(self): """Return the name of the sensor.""" @@ -156,9 +186,9 @@ def _update_from_data(self): self._device_id, {ActivityType.LOCK_OPERATION} ) - self._available = True + self._attr_available = True if lock_activity is not None: - self._state = lock_activity.operated_by + self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad self._operated_autorelock = lock_activity.operated_autorelock @@ -195,7 +225,7 @@ async def async_added_to_hass(self): if not last_state or last_state.state == STATE_UNAVAILABLE: return - self._state = last_state.state + self._attr_state = last_state.state if ATTR_ENTITY_PICTURE in last_state.attributes: self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_state.attributes: @@ -216,59 +246,35 @@ def unique_id(self) -> str: return f"{self._device_id}_lock_operator" -class AugustBatterySensor(AugustEntityMixin, SensorEntity): +class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[T]): """Representation of an August sensor.""" - def __init__(self, data, sensor_type, device, old_device): + entity_description: AugustSensorEntityDescription[T] + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__( + self, + data: AugustData, + device, + old_device, + description: AugustSensorEntityDescription[T], + ): """Initialize the sensor.""" super().__init__(data, device) - self._data = data - self._sensor_type = sensor_type - self._device = device + self.entity_description = description self._old_device = old_device - self._state = None - self._available = False + self._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = f"{self._device_id}_{description.key}" self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return PERCENTAGE - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_BATTERY - - @property - def name(self): - """Return the name of the sensor.""" - device_name = self._device.device_name - return f"{device_name} Battery" - @callback def _update_from_data(self): """Get the latest state of the sensor.""" - state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - self._state = state_provider(self._detail) - self._available = self._state is not None - - @property - def unique_id(self) -> str: - """Get the unique id of the device sensor.""" - return f"{self._device_id}_{self._sensor_type}" + self._attr_native_value = self.entity_description.value_fn(self._detail) + self._attr_available = self._attr_native_value is not None @property def old_unique_id(self) -> str: """Get the old unique id of the device sensor.""" - return f"{self._old_device.device_id}_{self._sensor_type}" + return f"{self._old_device.device_id}_{self.entity_description.key}" diff --git a/homeassistant/components/august/translations/ca.json b/homeassistant/components/august/translations/ca.json index 8faa12e27573f..ee9a860b1d175 100644 --- a/homeassistant/components/august/translations/ca.json +++ b/homeassistant/components/august/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { @@ -17,16 +17,6 @@ "description": "Introdueix la contrasenya per a {username}.", "title": "Torna a autenticar compte d'August" }, - "user": { - "data": { - "login_method": "M\u00e8tode d'inici de sessi\u00f3", - "password": "Contrasenya", - "timeout": "Temps d'espera (segons)", - "username": "Nom d'usuari" - }, - "description": "Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'email', el nom d'usuari \u00e9s l'adre\u00e7a de correu electr\u00f2nic. Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'phone', el nom d'usuari \u00e9s el n\u00famero de tel\u00e8fon en el format \"+NNNNNNNNN\".", - "title": "Configuraci\u00f3 de compte August" - }, "user_validate": { "data": { "login_method": "M\u00e8tode d'inici de sessi\u00f3", diff --git a/homeassistant/components/august/translations/cs.json b/homeassistant/components/august/translations/cs.json index 4176da8f1bf7e..6cb0fbd238d81 100644 --- a/homeassistant/components/august/translations/cs.json +++ b/homeassistant/components/august/translations/cs.json @@ -15,16 +15,6 @@ "password": "Heslo" } }, - "user": { - "data": { - "login_method": "Zp\u016fsob p\u0159ihl\u00e1\u0161en\u00ed", - "password": "Heslo", - "timeout": "\u010casov\u00fd limit (v sekund\u00e1ch)", - "username": "U\u017eivatelsk\u00e9 jm\u00e9no" - }, - "description": "Pokud je metoda p\u0159ihl\u00e1\u0161en\u00ed \"e-mail\", je e-mailovou adresou u\u017eivatelsk\u00e9 jm\u00e9no. Pokud je p\u0159ihla\u0161ovac\u00ed metoda \"telefon\", u\u017eivatelsk\u00e9 jm\u00e9no je telefonn\u00ed \u010d\u00edslo ve form\u00e1tu \"+NNNNNNNNN\".", - "title": "Nastavte \u00fa\u010det August" - }, "user_validate": { "data": { "password": "Heslo", diff --git a/homeassistant/components/august/translations/da.json b/homeassistant/components/august/translations/da.json index e022fac379069..de3fe4e8639e9 100644 --- a/homeassistant/components/august/translations/da.json +++ b/homeassistant/components/august/translations/da.json @@ -9,16 +9,6 @@ "unknown": "Uventet fejl" }, "step": { - "user": { - "data": { - "login_method": "Loginmetode", - "password": "Adgangskode", - "timeout": "Timeout (sekunder)", - "username": "Brugernavn" - }, - "description": "Hvis loginmetoden er 'e-mail', er brugernavn e-mailadressen. Hvis loginmetoden er 'telefon', er brugernavn telefonnummeret i formatet '+NNNNNNNNNN'.", - "title": "Konfigurer en August-konto" - }, "validation": { "data": { "code": "Bekr\u00e6ftelseskode" diff --git a/homeassistant/components/august/translations/de.json b/homeassistant/components/august/translations/de.json index ef525fb665df3..7f52aa083e74e 100644 --- a/homeassistant/components/august/translations/de.json +++ b/homeassistant/components/august/translations/de.json @@ -17,16 +17,6 @@ "description": "Gib das Passwort f\u00fcr {username} ein.", "title": "August-Konto erneut authentifizieren" }, - "user": { - "data": { - "login_method": "Anmeldemethode", - "password": "Passwort", - "timeout": "Zeit\u00fcberschreitung (Sekunden)", - "username": "Benutzername" - }, - "description": "Wenn die Anmeldemethode \"E-Mail\" lautet, ist Benutzername die E-Mail-Adresse. Wenn die Anmeldemethode \"Telefon\" ist, ist Benutzername die Telefonnummer im Format \"+ NNNNNNNNN\".", - "title": "Richten Sie ein August-Konto ein" - }, "user_validate": { "data": { "login_method": "Anmeldemethode", @@ -40,7 +30,7 @@ "data": { "code": "Verifizierungs-Code" }, - "description": "Bitte \u00fcberpr\u00fcfen Sie Ihre {login_method} ({username}) und geben Sie den Best\u00e4tigungscode ein", + "description": "Bitte \u00fcberpr\u00fcfe deine {login_method} ({username}) und gib den Best\u00e4tigungscode ein", "title": "Zwei-Faktor-Authentifizierung" } } diff --git a/homeassistant/components/august/translations/en.json b/homeassistant/components/august/translations/en.json index 0b8d1511244f3..f2ceef78d4865 100644 --- a/homeassistant/components/august/translations/en.json +++ b/homeassistant/components/august/translations/en.json @@ -17,16 +17,6 @@ "description": "Enter the password for {username}.", "title": "Reauthenticate an August account" }, - "user": { - "data": { - "login_method": "Login Method", - "password": "Password", - "timeout": "Timeout (seconds)", - "username": "Username" - }, - "description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", - "title": "Setup an August account" - }, "user_validate": { "data": { "login_method": "Login Method", diff --git a/homeassistant/components/august/translations/es-419.json b/homeassistant/components/august/translations/es-419.json index 914aea1b80194..7e5fe76d3afbc 100644 --- a/homeassistant/components/august/translations/es-419.json +++ b/homeassistant/components/august/translations/es-419.json @@ -9,16 +9,6 @@ "unknown": "Error inesperado" }, "step": { - "user": { - "data": { - "login_method": "M\u00e9todo de inicio de sesi\u00f3n", - "password": "Contrase\u00f1a", - "timeout": "Tiempo de espera (segundos)", - "username": "Nombre de usuario" - }, - "description": "Si el M\u00e9todo de inicio de sesi\u00f3n es 'correo electr\u00f3nico', Nombre de usuario es la direcci\u00f3n de correo electr\u00f3nico. Si el M\u00e9todo de inicio de sesi\u00f3n es 'tel\u00e9fono', Nombre de usuario es el n\u00famero de tel\u00e9fono en el formato '+NNNNNNNNN'.", - "title": "Configurar una cuenta de August" - }, "validation": { "data": { "code": "C\u00f3digo de verificaci\u00f3n" diff --git a/homeassistant/components/august/translations/es.json b/homeassistant/components/august/translations/es.json index d30db423db64b..a660d11f99632 100644 --- a/homeassistant/components/august/translations/es.json +++ b/homeassistant/components/august/translations/es.json @@ -17,16 +17,6 @@ "description": "Introduzca la contrase\u00f1a de {username}.", "title": "Reautorizar una cuenta de August" }, - "user": { - "data": { - "login_method": "M\u00e9todo de inicio de sesi\u00f3n", - "password": "Contrase\u00f1a", - "timeout": "Tiempo de espera (segundos)", - "username": "Usuario" - }, - "description": "Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'correo electr\u00f3nico', Usuario es la direcci\u00f3n de correo electr\u00f3nico. Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'tel\u00e9fono', Usuario es el n\u00famero de tel\u00e9fono en formato '+NNNNNNNNN'.", - "title": "Configurar una cuenta de August" - }, "user_validate": { "data": { "login_method": "M\u00e9todo de inicio de sesi\u00f3n", diff --git a/homeassistant/components/august/translations/et.json b/homeassistant/components/august/translations/et.json index 69cd9e66ce380..e310df863747e 100644 --- a/homeassistant/components/august/translations/et.json +++ b/homeassistant/components/august/translations/et.json @@ -17,16 +17,6 @@ "description": "Sisesta kasutaja {username} salas\u00f5na.", "title": "Autendi Augusti konto uuesti" }, - "user": { - "data": { - "login_method": "Sisselogimismeetod", - "password": "Salas\u00f5na", - "timeout": "Ajal\u00f5pp (sekundites)", - "username": "Kasutajanimi" - }, - "description": "Kui sisselogimismeetod on \"e-post\" on kasutajanimi e-posti aadress. Kui sisselogimismeetod on \"telefon\" on kasutajanimi telefoninumber vormingus \"+NNNNNNNNN\".", - "title": "Seadista Augusti sidumise konto" - }, "user_validate": { "data": { "login_method": "Sisselogimismeetod", diff --git a/homeassistant/components/august/translations/fr.json b/homeassistant/components/august/translations/fr.json index 967fb249d971c..8b61f7b3267ed 100644 --- a/homeassistant/components/august/translations/fr.json +++ b/homeassistant/components/august/translations/fr.json @@ -5,8 +5,8 @@ "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { @@ -17,16 +17,6 @@ "description": "Saisissez le mot de passe de {username} .", "title": "R\u00e9authentifier un compte August" }, - "user": { - "data": { - "login_method": "M\u00e9thode de connexion", - "password": "Mot de passe", - "timeout": "D\u00e9lai d'expiration (secondes)", - "username": "Nom d'utilisateur" - }, - "description": "Si la m\u00e9thode de connexion est \u00abe-mail\u00bb, le nom d'utilisateur est l'adresse e-mail. Si la m\u00e9thode de connexion est \u00abt\u00e9l\u00e9phone\u00bb, le nom d'utilisateur est le num\u00e9ro de t\u00e9l\u00e9phone au format \u00ab+ NNNNNNNNN\u00bb.", - "title": "Configurer un compte August" - }, "user_validate": { "data": { "login_method": "M\u00e9thode de connexion", diff --git a/homeassistant/components/august/translations/he.json b/homeassistant/components/august/translations/he.json index ac90b3264eab3..aeb3c6a9f1401 100644 --- a/homeassistant/components/august/translations/he.json +++ b/homeassistant/components/august/translations/he.json @@ -1,11 +1,31 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, "step": { - "user": { + "reauth_validate": { "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "description": "\u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e2\u05d1\u05d5\u05e8 {username} .", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d7\u05e9\u05d1\u05d5\u05df August" + }, + "user_validate": { + "data": { + "login_method": "\u05e9\u05d9\u05d8\u05ea \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } + }, + "validation": { + "description": "\u05e0\u05d0 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea {login_method} ( {username} ) \u05d5\u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05d4\u05dc\u05df" } } } diff --git a/homeassistant/components/august/translations/hu.json b/homeassistant/components/august/translations/hu.json index 1bced4e1036cd..42f9860bdc2ee 100644 --- a/homeassistant/components/august/translations/hu.json +++ b/homeassistant/components/august/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -14,28 +14,23 @@ "data": { "password": "Jelsz\u00f3" }, - "description": "Add meg a(z) {username} jelszav\u00e1t.", + "description": "Adja meg {username} jelszav\u00e1t.", "title": "August fi\u00f3k \u00fajrahiteles\u00edt\u00e9se" }, - "user": { - "data": { - "password": "Jelsz\u00f3", - "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s (m\u00e1sodperc)", - "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } - }, "user_validate": { "data": { "login_method": "Bejelentkez\u00e9si m\u00f3d", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Ha a bejelentkez\u00e9si m\u00f3dszer \u201ee-mail\u201d, akkor a felhaszn\u00e1l\u00f3n\u00e9v az e-mail c\u00edm. Ha a bejelentkez\u00e9si m\u00f3dszer \u201etelefon\u201d, akkor a felhaszn\u00e1l\u00f3n\u00e9v a \u201e+ NNNNNNNNNN\u201d form\u00e1tum\u00fa telefonsz\u00e1m.", "title": "August fi\u00f3k be\u00e1ll\u00edt\u00e1sa" }, "validation": { "data": { "code": "Ellen\u0151rz\u0151 k\u00f3d" }, + "description": "K\u00e9rj\u00fck, ellen\u0151rizze a {login_method} ({username}), \u00e9s \u00edrja be al\u00e1bb az ellen\u0151rz\u0151 k\u00f3dot", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" } } diff --git a/homeassistant/components/august/translations/id.json b/homeassistant/components/august/translations/id.json index 5408c2c0f70b0..19c1309d8edba 100644 --- a/homeassistant/components/august/translations/id.json +++ b/homeassistant/components/august/translations/id.json @@ -17,16 +17,6 @@ "description": "Masukkan sandi untuk {username}.", "title": "Autentikasi ulang akun August" }, - "user": { - "data": { - "login_method": "Metode Masuk", - "password": "Kata Sandi", - "timeout": "Tenggang waktu (detik)", - "username": "Nama Pengguna" - }, - "description": "Jika Metode Masuk adalah 'email', Nama Pengguna adalah alamat email. Jika Metode Masuk adalah 'telepon', Nama Pengguna adalah nomor telepon dalam format '+NNNNNNNNN'.", - "title": "Siapkan akun August" - }, "user_validate": { "data": { "login_method": "Metode Masuk", diff --git a/homeassistant/components/august/translations/it.json b/homeassistant/components/august/translations/it.json index c20f95b90adc8..aebfc1b14cdbf 100644 --- a/homeassistant/components/august/translations/it.json +++ b/homeassistant/components/august/translations/it.json @@ -15,17 +15,7 @@ "password": "Password" }, "description": "Inserisci la password per {username}.", - "title": "Riautentica un account di August" - }, - "user": { - "data": { - "login_method": "Metodo di accesso", - "password": "Password", - "timeout": "Timeout (in secondi)", - "username": "Nome utente" - }, - "description": "Se il metodo di accesso \u00e8 \"e-mail\", il nome utente \u00e8 l'indirizzo e-mail. Se il metodo di accesso \u00e8 \"telefono\", il nome utente \u00e8 il numero di telefono nel formato \"+NNNNNNNNN\".", - "title": "Configura un account di August" + "title": "Autentica nuovamente un account di August" }, "user_validate": { "data": { diff --git a/homeassistant/components/august/translations/ja.json b/homeassistant/components/august/translations/ja.json new file mode 100644 index 0000000000000..f9d62163a8b50 --- /dev/null +++ b/homeassistant/components/august/translations/ja.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_validate": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "August\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u518d\u8a8d\u8a3c" + }, + "user_validate": { + "data": { + "login_method": "\u30ed\u30b0\u30a4\u30f3\u65b9\u6cd5", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30ed\u30b0\u30a4\u30f3\u65b9\u6cd5\u304c \"\u96fb\u5b50\u30e1\u30fc\u30eb\" \u306e\u5834\u5408\u3001\u30e6\u30fc\u30b6\u30fc\u540d\u306f\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u3067\u3059\u3002\u30ed\u30b0\u30a4\u30f3\u65b9\u6cd5\u304c \"\u96fb\u8a71\" \u306e\u5834\u5408\u3001\u30e6\u30fc\u30b6\u30fc\u540d\u306f \"+NNNNNNNNN\" \u5f62\u5f0f\u306e\u96fb\u8a71\u756a\u53f7\u3067\u3059\u3002", + "title": "August account\u306e\u8a2d\u5b9a" + }, + "validation": { + "data": { + "code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9" + }, + "description": "{login_method} ({username}) \u3092\u78ba\u8a8d\u3057\u3066\u3001\u4ee5\u4e0b\u306b\u78ba\u8a8d\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "2\u8981\u7d20\u8a8d\u8a3c" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/ko.json b/homeassistant/components/august/translations/ko.json index f3bc64a706c67..1902d0112ff1e 100644 --- a/homeassistant/components/august/translations/ko.json +++ b/homeassistant/components/august/translations/ko.json @@ -17,16 +17,6 @@ "description": "{username}\uc758 \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "title": "August \uacc4\uc815 \uc7ac\uc778\uc99d\ud558\uae30" }, - "user": { - "data": { - "login_method": "\ub85c\uadf8\uc778 \ubc29\ubc95", - "password": "\ube44\ubc00\ubc88\ud638", - "timeout": "\uc81c\ud55c \uc2dc\uac04 (\ucd08)", - "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" - }, - "description": "\ub85c\uadf8\uc778 \ubc29\ubc95\uc774 '\uc774\uba54\uc77c'\uc778 \uacbd\uc6b0, \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 \uc774\uba54\uc77c \uc8fc\uc18c\uc785\ub2c8\ub2e4. \ub85c\uadf8\uc778 \ubc29\ubc95\uc774 '\uc804\ud654\ubc88\ud638'\uc778 \uacbd\uc6b0, \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 '+NNNNNNNNN' \ud615\uc2dd\uc758 \uc804\ud654\ubc88\ud638\uc785\ub2c8\ub2e4.", - "title": "August \uacc4\uc815 \uc124\uc815\ud558\uae30" - }, "user_validate": { "data": { "login_method": "\ub85c\uadf8\uc778 \ubc29\ubc95", diff --git a/homeassistant/components/august/translations/lb.json b/homeassistant/components/august/translations/lb.json index 569771dc393f9..169349646514f 100644 --- a/homeassistant/components/august/translations/lb.json +++ b/homeassistant/components/august/translations/lb.json @@ -13,16 +13,6 @@ "reauth_validate": { "description": "G\u00ebff Passwuert an fir {username}." }, - "user": { - "data": { - "login_method": "Login Method", - "password": "Passwuert", - "timeout": "Z\u00e4itiwwerscheidung (sekonnen)", - "username": "Benotzernumm" - }, - "description": "Wann d'Login Method 'E-Mail' ass, dannn ass de Benotzernumm d'E-Mail Adress. Wann d'Login-Method 'Telefon' ass, ass den Benotzernumm d'Telefonsnummer am Format '+ NNNNNNNNN'.", - "title": "August Kont ariichten" - }, "user_validate": { "data": { "login_method": "Login Method" diff --git a/homeassistant/components/august/translations/lv.json b/homeassistant/components/august/translations/lv.json deleted file mode 100644 index b2afeaf08745b..0000000000000 --- a/homeassistant/components/august/translations/lv.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "login_method": "Pieteik\u0161an\u0101s metode", - "password": "Parole" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/august/translations/nl.json b/homeassistant/components/august/translations/nl.json index 0ebd8ad3ba65a..2d9a4202f748e 100644 --- a/homeassistant/components/august/translations/nl.json +++ b/homeassistant/components/august/translations/nl.json @@ -17,16 +17,6 @@ "description": "Voer het wachtwoord in voor {username}.", "title": "Verifieer opnieuw een August-account" }, - "user": { - "data": { - "login_method": "Aanmeldmethode", - "password": "Wachtwoord", - "timeout": "Time-out (seconden)", - "username": "Gebruikersnaam" - }, - "description": "Als de aanmeldingsmethode 'e-mail' is, is gebruikersnaam het e-mailadres. Als de aanmeldingsmethode 'telefoon' is, is gebruikersnaam het telefoonnummer in de indeling '+ NNNNNNNNN'.", - "title": "Stel een augustus-account in" - }, "user_validate": { "data": { "login_method": "Inlogmethode", diff --git a/homeassistant/components/august/translations/no.json b/homeassistant/components/august/translations/no.json index d90e7f8080a1a..8ea4cd7141f35 100644 --- a/homeassistant/components/august/translations/no.json +++ b/homeassistant/components/august/translations/no.json @@ -17,16 +17,6 @@ "description": "Skriv inn passordet for {username} .", "title": "Godkjenn en August-konto p\u00e5 nytt" }, - "user": { - "data": { - "login_method": "P\u00e5loggingsmetode", - "password": "Passord", - "timeout": "Tidsavbrudd (sekunder)", - "username": "Brukernavn" - }, - "description": "Hvis p\u00e5loggingsmetoden er 'e-post', er brukernavnet e-postadressen. Hvis p\u00e5loggingsmetoden er 'telefon', er brukernavn telefonnummeret i formatet '+ NNNNNNNNN'.", - "title": "Sett opp en August konto" - }, "user_validate": { "data": { "login_method": "P\u00e5loggingsmetode", diff --git a/homeassistant/components/august/translations/pl.json b/homeassistant/components/august/translations/pl.json index a5539bea93ab5..d7deafd228dff 100644 --- a/homeassistant/components/august/translations/pl.json +++ b/homeassistant/components/august/translations/pl.json @@ -17,16 +17,6 @@ "description": "Wprowad\u017a has\u0142o dla {username}", "title": "Ponownie uwierzytelnij konto August" }, - "user": { - "data": { - "login_method": "Metoda logowania", - "password": "Has\u0142o", - "timeout": "Limit czasu (sekundy)", - "username": "Nazwa u\u017cytkownika" - }, - "description": "Je\u015bli metod\u0105 logowania jest 'e-mail', nazw\u0105 u\u017cytkownika b\u0119dzie adres e-mail. Je\u015bli metod\u0105 logowania jest 'telefon', nazw\u0105 u\u017cytkownika b\u0119dzie numer telefonu w formacie '+NNNNNNNNN'.", - "title": "Konfiguracja konta August" - }, "user_validate": { "data": { "login_method": "Metoda logowania", diff --git a/homeassistant/components/august/translations/pt-BR.json b/homeassistant/components/august/translations/pt-BR.json index efb4b3db35f05..7186be6216cba 100644 --- a/homeassistant/components/august/translations/pt-BR.json +++ b/homeassistant/components/august/translations/pt-BR.json @@ -1,13 +1,6 @@ { "config": { "step": { - "user": { - "data": { - "timeout": "Tempo limite (segundos)" - }, - "description": "Se o m\u00e9todo de login for 'email', Nome de usu\u00e1rio \u00e9 o endere\u00e7o de email. Se o m\u00e9todo de login for 'telefone', Nome de usu\u00e1rio ser\u00e1 o n\u00famero de telefone no formato '+NNNNNNNNN'.", - "title": "Configurar uma conta de August" - }, "validation": { "data": { "code": "C\u00f3digo de verifica\u00e7\u00e3o" diff --git a/homeassistant/components/august/translations/pt.json b/homeassistant/components/august/translations/pt.json index 7daa90fad2cb0..6c6765da70ef0 100644 --- a/homeassistant/components/august/translations/pt.json +++ b/homeassistant/components/august/translations/pt.json @@ -10,14 +10,6 @@ "unknown": "Erro inesperado" }, "step": { - "user": { - "data": { - "login_method": "M\u00e9todo de login", - "password": "Palavra-passe", - "username": "Nome de Utilizador" - }, - "description": "Se o m\u00e9todo de login for 'email', Nome do utilizador \u00e9 o endere\u00e7o de email. Se o m\u00e9todo de login for 'telefone', Nome do utilizador ser\u00e1 o n\u00famero de telefone no formato '+NNNNNNNNN'." - }, "validation": { "data": { "code": "C\u00f3digo de verifica\u00e7\u00e3o" diff --git a/homeassistant/components/august/translations/ru.json b/homeassistant/components/august/translations/ru.json index 0263ef6ee18a5..0f57924aef74d 100644 --- a/homeassistant/components/august/translations/ru.json +++ b/homeassistant/components/august/translations/ru.json @@ -17,16 +17,6 @@ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f {username}.", "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b\u044f" }, - "user": { - "data": { - "login_method": "\u0421\u043f\u043e\u0441\u043e\u0431 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" - }, - "description": "\u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'email', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b. \u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'phone', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430 \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 '+NNNNNNNNN'.", - "title": "August" - }, "user_validate": { "data": { "login_method": "\u0421\u043f\u043e\u0441\u043e\u0431 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438", diff --git a/homeassistant/components/august/translations/sl.json b/homeassistant/components/august/translations/sl.json index 5d78dac5ef12b..41be45271c8ca 100644 --- a/homeassistant/components/august/translations/sl.json +++ b/homeassistant/components/august/translations/sl.json @@ -9,16 +9,6 @@ "unknown": "Nepri\u010dakovana napaka" }, "step": { - "user": { - "data": { - "login_method": "Na\u010din prijave", - "password": "Geslo", - "timeout": "\u010casovna omejitev (sekunde)", - "username": "Uporabni\u0161ko ime" - }, - "description": "\u010ce je metoda za prijavo 'e-po\u0161ta', je e-po\u0161tni naslov uporabni\u0161ko ime. V kolikor je na\u010din prijave \"telefon\", je uporabni\u0161ko ime telefonska \u0161tevilka v obliki \" +NNNNNNNNN\".", - "title": "Nastavite ra\u010dun August" - }, "validation": { "data": { "code": "Koda za preverjanje" diff --git a/homeassistant/components/august/translations/sv.json b/homeassistant/components/august/translations/sv.json index 1ebdfab9fd21a..762d5fd764063 100644 --- a/homeassistant/components/august/translations/sv.json +++ b/homeassistant/components/august/translations/sv.json @@ -14,16 +14,6 @@ "password": "L\u00f6senord" } }, - "user": { - "data": { - "login_method": "Inloggningsmetod", - "password": "L\u00f6senord", - "timeout": "Timeout (sekunder)", - "username": "Anv\u00e4ndarnamn" - }, - "description": "Om inloggningsmetoden \u00e4r \"e-post\" \u00e4r anv\u00e4ndarnamnet e-postadressen. Om inloggningsmetoden \u00e4r \"telefon\" \u00e4r anv\u00e4ndarnamnet telefonnumret i formatet \"+ NNNNNNNN\".", - "title": "St\u00e4ll in ett August-konto" - }, "user_validate": { "data": { "password": "L\u00f6senord", diff --git a/homeassistant/components/august/translations/tr.json b/homeassistant/components/august/translations/tr.json index ccb9e200c820c..93c21154faa42 100644 --- a/homeassistant/components/august/translations/tr.json +++ b/homeassistant/components/august/translations/tr.json @@ -10,14 +10,21 @@ "unknown": "Beklenmeyen hata" }, "step": { - "user": { + "reauth_validate": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7in \u015fifreyi girin.", + "title": "Bir August hesab\u0131n\u0131 yeniden do\u011frulay\u0131n" + }, + "user_validate": { "data": { "login_method": "Giri\u015f Y\u00f6ntemi", "password": "Parola", - "timeout": "Zaman a\u015f\u0131m\u0131 (saniye)", "username": "Kullan\u0131c\u0131 Ad\u0131" }, - "description": "Giri\u015f Y\u00f6ntemi 'e-posta' ise, Kullan\u0131c\u0131 Ad\u0131 e-posta adresidir. Giri\u015f Y\u00f6ntemi 'telefon' ise, Kullan\u0131c\u0131 Ad\u0131 '+ NNNNNNNNN' bi\u00e7imindeki telefon numaras\u0131d\u0131r." + "description": "Giri\u015f Y\u00f6ntemi 'e-posta' ise, Kullan\u0131c\u0131 Ad\u0131 e-posta adresidir. Giri\u015f Y\u00f6ntemi 'telefon' ise, Kullan\u0131c\u0131 Ad\u0131 '+ NNNNNNNNN' bi\u00e7imindeki telefon numaras\u0131d\u0131r.", + "title": "Bir August hesab\u0131 olu\u015fturun" }, "validation": { "data": { diff --git a/homeassistant/components/august/translations/uk.json b/homeassistant/components/august/translations/uk.json index e06c5347d7336..5f4729d02b267 100644 --- a/homeassistant/components/august/translations/uk.json +++ b/homeassistant/components/august/translations/uk.json @@ -10,16 +10,6 @@ "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" }, "step": { - "user": { - "data": { - "login_method": "\u0421\u043f\u043e\u0441\u0456\u0431 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", - "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" - }, - "description": "\u042f\u043a\u0449\u043e \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043c \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457 \u0432\u0438\u0431\u0440\u0430\u043d\u043e 'email', \u0442\u043e \u043b\u043e\u0433\u0456\u043d\u043e\u043c \u0454 \u0430\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438. \u042f\u043a\u0449\u043e \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043c \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457 \u0432\u0438\u0431\u0440\u0430\u043d\u043e 'phone', \u0442\u043e \u043b\u043e\u0433\u0456\u043d\u043e\u043c \u0454 \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0443 \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0456 '+ NNNNNNNNN'.", - "title": "August" - }, "validation": { "data": { "code": "\u041a\u043e\u0434 \u043f\u0456\u0434\u0442\u0432\u0435\u0440\u0434\u0436\u0435\u043d\u043d\u044f" diff --git a/homeassistant/components/august/translations/zh-Hans.json b/homeassistant/components/august/translations/zh-Hans.json index a5f4ff11f09ef..b932dae2511aa 100644 --- a/homeassistant/components/august/translations/zh-Hans.json +++ b/homeassistant/components/august/translations/zh-Hans.json @@ -1,9 +1,14 @@ { "config": { "step": { - "user": { + "reauth_validate": { "data": { - "username": "\u7528\u6237\u540d" + "password": "\u5bc6\u7801" + } + }, + "user_validate": { + "data": { + "password": "\u5bc6\u7801" } } } diff --git a/homeassistant/components/august/translations/zh-Hant.json b/homeassistant/components/august/translations/zh-Hant.json index ab157e3da3c08..17fd85a2d58b6 100644 --- a/homeassistant/components/august/translations/zh-Hant.json +++ b/homeassistant/components/august/translations/zh-Hant.json @@ -17,16 +17,6 @@ "description": "\u8f38\u5165{username} \u5bc6\u78bc", "title": "\u91cd\u65b0\u8a8d\u8b49 August \u5e33\u865f" }, - "user": { - "data": { - "login_method": "\u767b\u5165\u65b9\u5f0f", - "password": "\u5bc6\u78bc", - "timeout": "\u903e\u6642\uff08\u79d2\uff09", - "username": "\u4f7f\u7528\u8005\u540d\u7a31" - }, - "description": "\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u90f5\u4ef6\u300cemail\u300d\u3001\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u96fb\u5b50\u90f5\u4ef6\u4f4d\u5740\u3002\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u96fb\u8a71\u300cphone\u300d\u3001\u5247\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u5305\u542b\u570b\u78bc\u4e4b\u96fb\u8a71\u865f\u78bc\uff0c\u5982\u300c+NNNNNNNNN\u300d\u3002", - "title": "\u8a2d\u5b9a August \u5e33\u865f" - }, "user_validate": { "data": { "login_method": "\u767b\u5165\u65b9\u5f0f", diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index e565071eae269..a029f2cf61b17 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -7,9 +7,11 @@ from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -17,10 +19,6 @@ ) from .const import ( - ATTR_ENTRY_TYPE, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTRIBUTION, AURORA_API, CONF_THRESHOLD, @@ -32,10 +30,10 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aurora from a config entry.""" conf = entry.data @@ -73,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -95,7 +93,7 @@ def __init__( latitude: float, longitude: float, threshold: float, - ): + ) -> None: """Initialize the data updater.""" super().__init__( @@ -123,47 +121,29 @@ async def _async_update_data(self): class AuroraEntity(CoordinatorEntity): """Implementation of the base Aurora Entity.""" + _attr_extra_state_attributes = {"attribution": ATTRIBUTION} + def __init__( self, coordinator: AuroraDataUpdateCoordinator, name: str, icon: str, - ): + ) -> None: """Initialize the Aurora Entity.""" super().__init__(coordinator=coordinator) - self._name = name - self._unique_id = f"{self.coordinator.latitude}_{self.coordinator.longitude}" - self._icon = icon - - @property - def unique_id(self): - """Define the unique id based on the latitude and longitude.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"attribution": ATTRIBUTION} + self._attr_name = name + self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" + self._attr_icon = icon @property - def icon(self): - """Return the icon for the sensor.""" - return self._icon - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Define the device based on name.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._unique_id)}, - ATTR_NAME: self.coordinator.name, - ATTR_MANUFACTURER: "NOAA", - ATTR_MODEL: "Aurora Visibility Sensor", - ATTR_ENTRY_TYPE: "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(self.unique_id))}, + manufacturer="NOAA", + model="Aurora Visibility Sensor", + name=self.coordinator.name, + ) diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index cd6f54a3d0cd0..d2f91fb122269 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -3,10 +3,6 @@ DOMAIN = "aurora" COORDINATOR = "coordinator" AURORA_API = "aurora_api" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" -ATTR_ENTRY_TYPE = "entry_type" DEFAULT_POLLING_INTERVAL = 5 CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index d7024cc630a43..96bdbbf137075 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -22,12 +22,9 @@ async def async_setup_entry(hass, entry, async_add_entries): class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" + _attr_native_unit_of_measurement = PERCENTAGE + @property - def state(self): + def native_value(self): """Return % chance the aurora is visible.""" return self.coordinator.data - - @property - def unit_of_measurement(self): - """Return the unit of measure.""" - return PERCENTAGE diff --git a/homeassistant/components/aurora/translations/bg.json b/homeassistant/components/aurora/translations/bg.json new file mode 100644 index 0000000000000..fea56662ef358 --- /dev/null +++ b/homeassistant/components/aurora/translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "threshold": "\u041f\u0440\u0430\u0433 (%)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora/translations/fr.json b/homeassistant/components/aurora/translations/fr.json index 473ecefdbd969..aa334f074c648 100644 --- a/homeassistant/components/aurora/translations/fr.json +++ b/homeassistant/components/aurora/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/aurora/translations/he.json b/homeassistant/components/aurora/translations/he.json new file mode 100644 index 0000000000000..a11e0a722548b --- /dev/null +++ b/homeassistant/components/aurora/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora/translations/ja.json b/homeassistant/components/aurora/translations/ja.json new file mode 100644 index 0000000000000..a4d9b83096871 --- /dev/null +++ b/homeassistant/components/aurora/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "threshold": "\u3057\u304d\u3044\u5024(%)" + } + } + } + }, + "title": "NOAA Aurora Sensor" +} \ No newline at end of file diff --git a/homeassistant/components/aurora/translations/tr.json b/homeassistant/components/aurora/translations/tr.json index 0c3bb75ed6e91..78dc0f86d0f01 100644 --- a/homeassistant/components/aurora/translations/tr.json +++ b/homeassistant/components/aurora/translations/tr.json @@ -12,5 +12,15 @@ } } } - } + }, + "options": { + "step": { + "init": { + "data": { + "threshold": "E\u015fik (%)" + } + } + } + }, + "title": "NOAA Aurora Sens\u00f6r\u00fc" } \ No newline at end of file diff --git a/homeassistant/components/aurora/translations/zh-Hant.json b/homeassistant/components/aurora/translations/zh-Hant.json index e1824a7ff4aa6..d12e833237310 100644 --- a/homeassistant/components/aurora/translations/zh-Hant.json +++ b/homeassistant/components/aurora/translations/zh-Hant.json @@ -22,5 +22,5 @@ } } }, - "title": "NOAA Aurora \u50b3\u611f\u5668" + "title": "NOAA Aurora \u611f\u6e2c\u5668" } \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 087172d1bb546..08103193e7b63 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -1 +1,85 @@ """The Aurora ABB Powerone PV inverter sensor integration.""" + +# Reference info: +# https://s1.solacity.com/docs/PVI-3.0-3.6-4.2-OUTD-US%20Manual.pdf +# http://www.drhack.it/images/PDF/AuroraCommunicationProtocol_4_2.pdf +# +# Developer note: +# vscode devcontainer: use the following to access USB device: +# "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"], + +import logging + +from aurorapy.client import AuroraError, AuroraSerialClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .config_flow import validate_and_connect +from .const import ATTR_SERIAL_NUMBER, DOMAIN + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Aurora ABB PowerOne from a config entry.""" + + comport = entry.data[CONF_PORT] + address = entry.data[CONF_ADDRESS] + ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1) + # To handle yaml import attempts in darkeness, (re)try connecting only if + # unique_id not yet assigned. + if entry.unique_id is None: + try: + res = await hass.async_add_executor_job( + validate_and_connect, hass, entry.data + ) + except AuroraError as error: + if "No response after" in str(error): + raise ConfigEntryNotReady("No response (could be dark)") from error + _LOGGER.error("Failed to connect to inverter: %s", error) + return False + except OSError as error: + if error.errno == 19: # No such device. + _LOGGER.error("Failed to connect to inverter: no such COM port") + return False + _LOGGER.error("Failed to connect to inverter: %s", error) + return False + else: + # If we got here, the device is now communicating (maybe after + # being in darkness). But there's a small risk that the user has + # configured via the UI since we last attempted the yaml setup, + # which means we'd get a duplicate unique ID. + new_id = res[ATTR_SERIAL_NUMBER] + # Check if this unique_id has already been used + for existing_entry in hass.config_entries.async_entries(DOMAIN): + if existing_entry.unique_id == new_id: + _LOGGER.debug( + "Remove already configured config entry for id %s", new_id + ) + hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id) + ) + return False + hass.config_entries.async_update_entry(entry, unique_id=new_id) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + # It should not be necessary to close the serial port because we close + # it after every use in sensor.py, i.e. no need to do entry["client"].close() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py new file mode 100644 index 0000000000000..d9cfb7442314e --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py @@ -0,0 +1,57 @@ +"""Top level class for AuroraABBPowerOneSolarPV inverters and sensors.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aurorapy.client import AuroraSerialClient + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import ( + ATTR_DEVICE_NAME, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + DEFAULT_DEVICE_NAME, + DOMAIN, + MANUFACTURER, +) + +_LOGGER = logging.getLogger(__name__) + + +class AuroraEntity(Entity): + """Representation of an Aurora ABB PowerOne device.""" + + def __init__(self, client: AuroraSerialClient, data: Mapping[str, Any]) -> None: + """Initialise the basic device.""" + self._data = data + self.type = "device" + self.client = client + self._available = True + + @property + def unique_id(self) -> str | None: + """Return the unique id for this device.""" + serial = self._data.get(ATTR_SERIAL_NUMBER) + if serial is None: + return None + return f"{serial}_{self.entity_description.key}" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return { + "identifiers": {(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, + "manufacturer": MANUFACTURER, + "model": self._data[ATTR_MODEL], + "name": self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), + "sw_version": self._data[ATTR_FIRMWARE], + } diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py new file mode 100644 index 0000000000000..f2e0aab5b0787 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Aurora ABB PowerOne integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aurorapy.client import AuroraError, AuroraSerialClient +import serial.tools.list_ports +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + DEFAULT_ADDRESS, + DEFAULT_INTEGRATION_TITLE, + DOMAIN, + MAX_ADDRESS, + MIN_ADDRESS, +) + +_LOGGER = logging.getLogger(__name__) + + +def validate_and_connect( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + comport = data[CONF_PORT] + address = data[CONF_ADDRESS] + _LOGGER.debug("Intitialising com port=%s", comport) + ret = {} + ret["title"] = DEFAULT_INTEGRATION_TITLE + try: + client = AuroraSerialClient(address, comport, parity="N", timeout=1) + client.connect() + ret[ATTR_SERIAL_NUMBER] = client.serial_number() + ret[ATTR_MODEL] = f"{client.version()} ({client.pn()})" + ret[ATTR_FIRMWARE] = client.firmware(1) + _LOGGER.info("Returning device info=%s", ret) + except AuroraError as err: + _LOGGER.warning("Could not connect to device=%s", comport) + raise err + finally: + if client.serline.isOpen(): + client.close() + + # Return info we want to store in the config entry. + return ret + + +def scan_comports() -> tuple[list[str] | None, str | None]: + """Find and store available com ports for the GUI dropdown.""" + com_ports = serial.tools.list_ports.comports(include_links=True) + com_ports_list = [] + for port in com_ports: + com_ports_list.append(port.device) + _LOGGER.debug("COM port option: %s", port.device) + if len(com_ports_list) > 0: + return com_ports_list, com_ports_list[0] + _LOGGER.warning("No com ports found. Need a valid RS485 device to communicate") + return None, None + + +class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aurora ABB PowerOne.""" + + VERSION = 1 + + def __init__(self): + """Initialise the config flow.""" + self.config = None + self._com_ports_list = None + self._default_com_port = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialised by the user.""" + + errors = {} + if self._com_ports_list is None: + result = await self.hass.async_add_executor_job(scan_comports) + self._com_ports_list, self._default_com_port = result + if self._default_com_port is None: + return self.async_abort(reason="no_serial_ports") + + # Handle the initial step. + if user_input is not None: + try: + info = await self.hass.async_add_executor_job( + validate_and_connect, self.hass, user_input + ) + except OSError as error: + if error.errno == 19: # No such device. + errors["base"] = "invalid_serial_port" + except AuroraError as error: + if "could not open port" in str(error): + errors["base"] = "cannot_open_serial_port" + elif "No response after" in str(error): + errors["base"] = "cannot_connect" # could be dark + else: + _LOGGER.error( + "Unable to communicate with Aurora ABB Inverter at %s: %s %s", + user_input[CONF_PORT], + type(error), + error, + ) + errors["base"] = "cannot_connect" + else: + info.update(user_input) + # Bomb out early if someone has already set up this device. + device_unique_id = info["serial_number"] + await self.async_set_unique_id(device_unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=info) + + # If no user input, must be first pass through the config. Show initial form. + config_options = { + vol.Required(CONF_PORT, default=self._default_com_port): vol.In( + self._com_ports_list + ), + vol.Required(CONF_ADDRESS, default=DEFAULT_ADDRESS): vol.In( + range(MIN_ADDRESS, MAX_ADDRESS + 1) + ), + } + schema = vol.Schema(config_options) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/aurora_abb_powerone/const.py b/homeassistant/components/aurora_abb_powerone/const.py new file mode 100644 index 0000000000000..3711dd6d80098 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/const.py @@ -0,0 +1,22 @@ +"""Constants for the Aurora ABB PowerOne integration.""" + +DOMAIN = "aurora_abb_powerone" + +# Min max addresses and default according to here: +# https://library.e.abb.com/public/e57212c407344a16b4644cee73492b39/PVI-3.0_3.6_4.2-TL-OUTD-Product%20manual%20EN-RevB(M000016BG).pdf + +MIN_ADDRESS = 2 +MAX_ADDRESS = 63 +DEFAULT_ADDRESS = 2 + +DEFAULT_INTEGRATION_TITLE = "PhotoVoltaic Inverters" +DEFAULT_DEVICE_NAME = "Solar Inverter" + +DEVICES = "devices" +MANUFACTURER = "ABB" + +ATTR_DEVICE_NAME = "device_name" +ATTR_DEVICE_ID = "device_id" +ATTR_SERIAL_NUMBER = "serial_number" +ATTR_MODEL = "model" +ATTR_FIRMWARE = "firmware" diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json index 69798ce49061a..9849c0d84ee7b 100644 --- a/homeassistant/components/aurora_abb_powerone/manifest.json +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -1,8 +1,11 @@ { "domain": "aurora_abb_powerone", - "name": "Aurora ABB Solar PV", - "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone/", - "codeowners": ["@davet2001"], + "name": "Aurora ABB PowerOne Solar PV", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone", "requirements": ["aurorapy==0.2.6"], + "codeowners": [ + "@davet2001" + ], "iot_class": "local_polling" } diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index f4640e7c01422..b96674caadbf8 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -1,76 +1,79 @@ """Support for Aurora ABB PowerOne Solar Photvoltaic (PV) inverter.""" +from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from aurorapy.client import AuroraError, AuroraSerialClient -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE, - CONF_NAME, - DEVICE_CLASS_POWER, - POWER_WATT, -) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -DEFAULT_ADDRESS = 2 -DEFAULT_NAME = "Solar PV" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, ) +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, TEMP_CELSIUS +from homeassistant.helpers.entity import EntityCategory +from .aurora_device import AuroraEntity +from .const import DOMAIN -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Aurora ABB PowerOne device.""" - devices = [] - comport = config[CONF_DEVICE] - address = config[CONF_ADDRESS] - name = config[CONF_NAME] - - _LOGGER.debug("Intitialising com port=%s address=%s", comport, address) - client = AuroraSerialClient(address, comport, parity="N", timeout=1) - - devices.append(AuroraABBSolarPVMonitorSensor(client, name, "Power")) - add_entities(devices, True) - - -class AuroraABBSolarPVMonitorSensor(SensorEntity): - """Representation of a Sensor.""" +_LOGGER = logging.getLogger(__name__) - def __init__(self, client, name, typename): +SENSOR_TYPES = [ + SensorEntityDescription( + key="instantaneouspower", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + name="Power Output", + ), + SensorEntityDescription( + key="temp", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + name="Temperature", + ), + SensorEntityDescription( + key="totalenergy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + name="Total Energy", + ), +] + + +async def async_setup_entry(hass, config_entry, async_add_entities) -> None: + """Set up aurora_abb_powerone sensor based on a config entry.""" + entities = [] + + client = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.data + + for sens in SENSOR_TYPES: + entities.append(AuroraSensor(client, data, sens)) + + _LOGGER.debug("async_setup_entry adding %d entities", len(entities)) + async_add_entities(entities, True) + + +class AuroraSensor(AuroraEntity, SensorEntity): + """Representation of a Sensor on a Aurora ABB PowerOne Solar inverter.""" + + def __init__( + self, + client: AuroraSerialClient, + data: Mapping[str, Any], + entity_description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" - self._name = f"{name} {typename}" - self.client = client - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return POWER_WATT - - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_POWER + super().__init__(client, data) + self.entity_description = entity_description + self.available_prev = True def update(self): """Fetch new state data for the sensor. @@ -78,12 +81,24 @@ def update(self): This is the only method that should fetch new data for Home Assistant. """ try: + self.available_prev = self._attr_available self.client.connect() - # read ADC channel 3 (grid power output) - power_watts = self.client.measure(3, True) - self._state = round(power_watts, 1) - # _LOGGER.debug("Got reading %fW" % self._state) + if self.entity_description.key == "instantaneouspower": + # read ADC channel 3 (grid power output) + power_watts = self.client.measure(3, True) + self._attr_native_value = round(power_watts, 1) + elif self.entity_description.key == "temp": + temperature_c = self.client.measure(21) + self._attr_native_value = round(temperature_c, 1) + elif self.entity_description.key == "totalenergy": + energy_wh = self.client.cumulated_energy(5) + self._attr_native_value = round(energy_wh / 1000, 2) + self._attr_available = True + except AuroraError as error: + self._attr_state = None + self._attr_native_value = None + self._attr_available = False # aurorapy does not have different exceptions (yet) for dealing # with timeout vs other comms errors. # This means the (normal) situation of no response during darkness @@ -96,7 +111,14 @@ def update(self): _LOGGER.debug("No response from inverter (could be dark)") else: raise error - self._state = None finally: + if self._attr_available != self.available_prev: + if self._attr_available: + _LOGGER.info("Communication with %s back online", self.name) + else: + _LOGGER.warning( + "Communication with %s lost", + self.name, + ) if self.client.serline.isOpen(): self.client.close() diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json new file mode 100644 index 0000000000000..bed403bd641b8 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel", + "data": { + "port": "RS485 or USB-RS485 Adaptor Port", + "address": "Inverter Address" + } + } + }, + "error": { + "cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)", + "invalid_serial_port": "Serial port is not a valid device or could not be openned", + "cannot_open_serial_port": "Cannot open serial port, please check and try again" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." + } + } +} diff --git a/homeassistant/components/aurora_abb_powerone/translations/bg.json b/homeassistant/components/aurora_abb_powerone/translations/bg.json new file mode 100644 index 0000000000000..88f52d8426956 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/bg.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/ca.json b/homeassistant/components/aurora_abb_powerone/translations/ca.json new file mode 100644 index 0000000000000..98f77dad13b1f --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "no_serial_ports": "No s'han trobat ports COM. Es necessita un dispositiu de comunicaci\u00f3 RS485 v\u00e0lid." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, comprova el port s\u00e8rie, l'adre\u00e7a, la connexi\u00f3 el\u00e8ctrica i que l'inversor estigui enc\u00e8s", + "cannot_open_serial_port": "No s'ha pogut obrir el port s\u00e8rie, comprova'l i torna-ho a provar", + "invalid_serial_port": "El port s\u00e8rie no t\u00e9 un dispositiu v\u00e0lid o no s'ha pogut obrir", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "address": "Adre\u00e7a de l'inversor", + "port": "Port RS485 o adaptador USB-RS485" + }, + "description": "L'inversor ha d'estar connectat mitjan\u00e7ant un adaptador RS485. Selecciona el port s\u00e8rie i l'adre\u00e7a de l'inversor tal com estan configurats a la pantalla LCD de l'aparell" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/de.json b/homeassistant/components/aurora_abb_powerone/translations/de.json new file mode 100644 index 0000000000000..a60138da16db5 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "no_serial_ports": "Keine COM-Ports gefunden. Man ben\u00f6tigt ein g\u00fcltiges RS485-Ger\u00e4t, um zu kommunizieren." + }, + "error": { + "cannot_connect": "Verbindung kann nicht hergestellt werden, bitte \u00fcberpr\u00fcfe den seriellen Anschluss, die Adresse, die elektrische Verbindung und ob der Wechselrichter eingeschaltet ist (bei Tageslicht)", + "cannot_open_serial_port": "Serielle Schnittstelle kann nicht ge\u00f6ffnet werden, bitte pr\u00fcfen und erneut versuchen", + "invalid_serial_port": "Serielle Schnittstelle ist kein g\u00fcltiges Ger\u00e4t oder konnte nicht ge\u00f6ffnet werden", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "address": "Wechselrichter Adresse", + "port": "RS485- oder USB-RS485-Adapteranschluss" + }, + "description": "Der Wechselrichter muss \u00fcber einen RS485-Adapter angeschlossen werden, bitte w\u00e4hle die serielle Schnittstelle und die Adresse des Wechselrichters wie auf dem LCD-Panel konfiguriert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/en.json b/homeassistant/components/aurora_abb_powerone/translations/en.json new file mode 100644 index 0000000000000..fe5d668f573a4 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." + }, + "error": { + "cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)", + "cannot_open_serial_port": "Cannot open serial port, please check and try again", + "invalid_serial_port": "Serial port is not a valid device or could not be openned", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "address": "Inverter Address", + "port": "RS485 or USB-RS485 Adaptor Port" + }, + "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/en_GB.json b/homeassistant/components/aurora_abb_powerone/translations/en_GB.json new file mode 100644 index 0000000000000..59c6263bd140d --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/en_GB.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "address": "Inverter Address" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/et.json b/homeassistant/components/aurora_abb_powerone/translations/et.json new file mode 100644 index 0000000000000..b7764fa0f3394 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "no_serial_ports": "Jadaporte ei leitud. Suhtlemiseks on vaja kehtivat RS485 seadet." + }, + "error": { + "cannot_connect": "\u00dchendust ei saa luua, palun kontrolli jadaporti, aadressi, elektri\u00fchendust ja et inverter on sisse l\u00fclitatud (p\u00e4evavalguses)", + "cannot_open_serial_port": "Jadaporti ei saa avada, kontrolli ja proovi uuesti", + "invalid_serial_port": "Jadaport pole sobiv seade v\u00f5i seda ei saa avada", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "address": "Inverteri aadress", + "port": "RS485 v\u00f5i USB-RS485 adapteri port" + }, + "description": "Inverter peab olema \u00fchendatud RS485 adapteri kaudu, vali jadaport ja muunduri aadress nagu on konfigureeritud LCD paneelil" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/fr.json b/homeassistant/components/aurora_abb_powerone/translations/fr.json new file mode 100644 index 0000000000000..d87822fb7c3ca --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "no_serial_ports": "Aucun port com trouv\u00e9. Besoin d'un p\u00e9riph\u00e9rique RS485 valide pour communiquer." + }, + "error": { + "cannot_connect": "Connexion impossible, veuillez v\u00e9rifier le port s\u00e9rie, l'adresse, la connexion \u00e9lectrique et que l'onduleur est allum\u00e9 (\u00e0 la lumi\u00e8re du jour)", + "cannot_open_serial_port": "Impossible d'ouvrir le port s\u00e9rie, veuillez v\u00e9rifier et r\u00e9essayer", + "invalid_serial_port": "Le port s\u00e9rie n'est pas un p\u00e9riph\u00e9rique valide ou n'a pas pu \u00eatre ouvert", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "address": "Adresse de l'onduleur", + "port": "Port adaptateur RS485 ou USB-RS485" + }, + "description": "L'onduleur doit \u00eatre connect\u00e9 via un adaptateur RS485, veuillez s\u00e9lectionner le port s\u00e9rie et l'adresse de l'onduleur comme configur\u00e9 sur le panneau LCD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/he.json b/homeassistant/components/aurora_abb_powerone/translations/he.json new file mode 100644 index 0000000000000..ea40181bd9a52 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/he.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/hu.json b/homeassistant/components/aurora_abb_powerone/translations/hu.json new file mode 100644 index 0000000000000..ffc812dfd4b04 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "no_serial_ports": "Nem tal\u00e1lhat\u00f3 soros port. A kommunik\u00e1ci\u00f3hoz egy \u00e9rv\u00e9nyes RS485-\u00f6s csatlakoz\u00e1si lehet\u0151s\u00e9gre van sz\u00fcks\u00e9g." + }, + "error": { + "cannot_connect": "A csatlakoz\u00e1s sikertelen. Ellen\u0151rizze a soros portot, a c\u00edmet, az elektromos csatlakoz\u00e1st \u00e9s azt, hogy az inverter be van-e kapcsolva (nappal).", + "cannot_open_serial_port": "A soros port nem nyithat\u00f3 meg, k\u00e9rem ellen\u0151rizze \u00e9s pr\u00f3b\u00e1lkozzon \u00fajra", + "invalid_serial_port": "A soros port nem \u00e9rv\u00e9nyes eszk\u00f6z, vagy nem nyithat\u00f3 meg", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "address": "Inverter c\u00edm", + "port": "RS485 vagy USB-RS485 adapter port" + }, + "description": "Az invertert RS485 adapteren kereszt\u00fcl kell csatlakoztatni, v\u00e1lassza ki a soros portot \u00e9s az inverter c\u00edm\u00e9t az LCD panelen konfigur\u00e1ltak szerint." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/id.json b/homeassistant/components/aurora_abb_powerone/translations/id.json new file mode 100644 index 0000000000000..4157502c2ad13 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "no_serial_ports": "Tidak ada port com yang ditemukan. Perlu perangkat RS485 yang valid untuk berkomunikasi." + }, + "error": { + "cannot_connect": "Tidak dapat terhubung, periksa port serial, alamat, koneksi listrik dan apakah inverter sedang nyala (di siang hari)", + "cannot_open_serial_port": "Tidak dapat membuka port serial, periksa dan coba lagi", + "invalid_serial_port": "Port serial bukan perangkat yang valid atau tidak dapat dibuka", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "address": "Alamat Inverter", + "port": "Port Adaptor RS485 atau USB-RS485" + }, + "description": "Inverter harus terhubung melalui adaptor RS485, pilih port serial dan alamat inverter seperti yang dikonfigurasi pada panel LCD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/it.json b/homeassistant/components/aurora_abb_powerone/translations/it.json new file mode 100644 index 0000000000000..bb534d079ccc5 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "no_serial_ports": "Nessuna porta COM trovata. Serve un dispositivo RS485 valido per comunicare." + }, + "error": { + "cannot_connect": "Impossibile connettersi, controllare la porta seriale, l'indirizzo, la connessione elettrica e che l'inverter sia acceso (alla luce del giorno)", + "cannot_open_serial_port": "Impossibile aprire la porta seriale, controllare e riprovare", + "invalid_serial_port": "La porta seriale non \u00e8 un dispositivo valido o non pu\u00f2 essere aperta", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "address": "Indirizzo dell'inverter", + "port": "Porta adattatore RS485 o USB-RS485" + }, + "description": "L'inverter deve essere collegato tramite un adattatore RS485, seleziona la porta seriale e l'indirizzo dell'inverter come configurato sul pannello LCD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/ja.json b/homeassistant/components/aurora_abb_powerone/translations/ja.json new file mode 100644 index 0000000000000..a558f088f0722 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_serial_ports": "COM\u30dd\u30fc\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002\u901a\u4fe1\u3059\u308b\u306b\u306f\u6709\u52b9\u306aRS485\u30c7\u30d0\u30a4\u30b9\u304c\u5fc5\u8981\u3067\u3059\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u3067\u304d\u306a\u3044\u5834\u5408\u306f\u3001\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3001\u30a2\u30c9\u30ec\u30b9\u3001\u96fb\u6c17\u7684\u63a5\u7d9a\u3092\u78ba\u8a8d\u3057\u3001\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044(\u663c\u9593)", + "cannot_open_serial_port": "\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3092\u958b\u3051\u307e\u305b\u3093\u3002\u78ba\u8a8d\u3057\u3066\u304b\u3089\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044", + "invalid_serial_port": "\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u304c\u6709\u52b9\u306a\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u306a\u3044\u3001\u3082\u3057\u304f\u306f\u958b\u304f\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "address": "\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u30a2\u30c9\u30ec\u30b9", + "port": "RS485\u3001\u307e\u305f\u306f USB-RS485 \u30a2\u30c0\u30d7\u30bf\u30fc \u30dd\u30fc\u30c8" + }, + "description": "\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u306fRS485\u30a2\u30c0\u30d7\u30bf\u30fc\u3092\u4ecb\u3057\u3066\u63a5\u7d9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002LCD\u30d1\u30cd\u30eb\u3067\u8a2d\u5b9a\u3057\u305f\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3068\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u306e\u30a2\u30c9\u30ec\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/nl.json b/homeassistant/components/aurora_abb_powerone/translations/nl.json new file mode 100644 index 0000000000000..d70113e9c19b7 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "no_serial_ports": "Geen com-poorten gevonden. Een geldig RS485-apparaat is nodig om te communiceren." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken, controleer de seri\u00eble poort, het adres, de elektrische aansluiting en of de omvormer aan staat (bij daglicht)", + "cannot_open_serial_port": "Kan seri\u00eble poort niet openen, controleer en probeer het opnieuw", + "invalid_serial_port": "Seri\u00eble poort is geen geldig apparaat of kan niet worden geopend", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "address": "Omvormer adres", + "port": "RS485 of USB-RS485 adapter poort" + }, + "description": "De omvormer moet worden aangesloten via een RS485-adapter, selecteer de seri\u00eble poort en het adres van de omvormer zoals geconfigureerd op het LCD-paneel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/no.json b/homeassistant/components/aurora_abb_powerone/translations/no.json new file mode 100644 index 0000000000000..9d4cd656f459d --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "no_serial_ports": "Ingen com-porter funnet. Trenger en gyldig RS485-enhet for \u00e5 kommunisere." + }, + "error": { + "cannot_connect": "Kan ikke koble til, sjekk seriell port, adresse, elektrisk tilkobling og at omformeren er p\u00e5 (i dagslys)", + "cannot_open_serial_port": "Kan ikke \u00e5pne serieporten, sjekk og pr\u00f8v igjen", + "invalid_serial_port": "Seriell port er ikke en gyldig enhet eller kunne ikke \u00e5pnes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "address": "Inverter adresse", + "port": "RS485- eller USB-RS485-adapterport" + }, + "description": "Omformeren m\u00e5 kobles til via en RS485-adapter, velg seriell port og omformerens adresse som konfigurert p\u00e5 LCD-panelet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/pl.json b/homeassistant/components/aurora_abb_powerone/translations/pl.json new file mode 100644 index 0000000000000..d6131cfa1959b --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "no_serial_ports": "Nie znaleziono port\u00f3w COM. Do komunikacji potrzebne jest prawid\u0142owe urz\u0105dzenie RS485." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, sprawd\u017a port szeregowy, adres, po\u0142\u0105czenie elektryczne i czy falownik jest w\u0142\u0105czony (w \u015bwietle dziennym)", + "cannot_open_serial_port": "Nie mo\u017cna otworzy\u0107 portu szeregowego, sprawd\u017a i spr\u00f3buj ponownie", + "invalid_serial_port": "Port szeregowy nie jest prawid\u0142owym urz\u0105dzeniem lub nie mo\u017cna go otworzy\u0107", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "address": "Adres falownika", + "port": "Port RS485 lub adaptera USB-RS485" + }, + "description": "Falownik musi by\u0107 pod\u0142\u0105czony przez adapter RS485. Wybierz port szeregowy i adres falownika zgodnie z konfiguracj\u0105 na panelu LCD." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/ru.json b/homeassistant/components/aurora_abb_powerone/translations/ru.json new file mode 100644 index 0000000000000..6baec9324d2e8 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "no_serial_ports": "COM-\u043f\u043e\u0440\u0442\u044b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b. \u0414\u043b\u044f \u0441\u0432\u044f\u0437\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0434\u0435\u0439\u0441\u0442\u0432\u0443\u044e\u0449\u0435\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e RS485." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442, \u0430\u0434\u0440\u0435\u0441, \u044d\u043b\u0435\u043a\u0442\u0440\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0438 \u0447\u0442\u043e \u0438\u043d\u0432\u0435\u0440\u0442\u043e\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d (\u043f\u0440\u0438 \u0434\u043d\u0435\u0432\u043d\u043e\u043c \u0441\u0432\u0435\u0442\u0435).", + "cannot_open_serial_port": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u0442\u043a\u0440\u044b\u0442\u044c \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_serial_port": "\u041f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u043c \u0438\u043b\u0438 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "address": "\u0410\u0434\u0440\u0435\u0441 \u0438\u043d\u0432\u0435\u0440\u0442\u043e\u0440\u0430", + "port": "\u041f\u043e\u0440\u0442 \u0430\u0434\u0430\u043f\u0442\u0435\u0440\u0430 RS485 \u0438\u043b\u0438 USB-RS485" + }, + "description": "\u0418\u043d\u0432\u0435\u0440\u0442\u043e\u0440 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u0447\u0435\u0440\u0435\u0437 \u0430\u0434\u0430\u043f\u0442\u0435\u0440 RS485. \u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 \u0438 \u0430\u0434\u0440\u0435\u0441 \u0438\u043d\u0432\u0435\u0440\u0442\u043e\u0440\u0430, \u043a\u0430\u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043e \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0438\u043d\u0432\u0435\u0440\u0442\u043e\u0440\u0430." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/sl.json b/homeassistant/components/aurora_abb_powerone/translations/sl.json new file mode 100644 index 0000000000000..87fce100c77d8 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/sl.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "unknown": "Nepri\u010dakovana napaka" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/th.json b/homeassistant/components/aurora_abb_powerone/translations/th.json new file mode 100644 index 0000000000000..5db99ad99e4bd --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "\u0e2d\u0e30\u0e41\u0e14\u0e1b\u0e40\u0e15\u0e2d\u0e23\u0e4c \u0e1e\u0e2d\u0e23\u0e4c\u0e15 RS485 \u0e2b\u0e23\u0e37\u0e2d USB-RS485" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/tr.json b/homeassistant/components/aurora_abb_powerone/translations/tr.json new file mode 100644 index 0000000000000..ec8ee6da4a383 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_serial_ports": "com ba\u011flant\u0131 noktas\u0131 bulunamad\u0131. \u0130leti\u015fim kurmak i\u00e7in ge\u00e7erli bir RS485 cihaz\u0131na ihtiyac\u0131n\u0131z var." + }, + "error": { + "cannot_connect": "Ba\u011flant\u0131 kurulam\u0131yor, l\u00fctfen seri portu, adresi, elektrik ba\u011flant\u0131s\u0131n\u0131 ve invert\u00f6r\u00fcn a\u00e7\u0131k oldu\u011funu (g\u00fcn \u0131\u015f\u0131\u011f\u0131nda) kontrol edin.", + "cannot_open_serial_port": "Seri ba\u011flant\u0131 noktas\u0131 a\u00e7\u0131lam\u0131yor, l\u00fctfen kontrol edip tekrar deneyin", + "invalid_serial_port": "Seri ba\u011flant\u0131 noktas\u0131 ge\u00e7erli bir ayg\u0131t de\u011fil veya a\u00e7\u0131lamad\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "address": "R\u00f6le Adresi", + "port": "RS485 veya USB-RS485 Adapt\u00f6r Ba\u011flant\u0131 Noktas\u0131" + }, + "description": "\u0130nverter bir RS485 adapt\u00f6r\u00fc ile ba\u011flanmal\u0131d\u0131r, l\u00fctfen seri portu ve inverterin adresini LCD panelde konfig\u00fcre edildi\u011fi \u015fekilde se\u00e7iniz." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/zh-Hant.json b/homeassistant/components/aurora_abb_powerone/translations/zh-Hant.json new file mode 100644 index 0000000000000..0a05e640994a0 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_serial_ports": "\u627e\u4e0d\u5230\u901a\u8a0a\u57e0\u3002\u9700\u8981\u6709\u6548\u7684 RS485 \u88dd\u7f6e\u9032\u884c\u901a\u8a0a\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u63a5\uff0c\u8acb\u6aa2\u67e5\u5e8f\u5217\u57e0\u3001\u4f4d\u5740\u3001\u96fb\u529b\u9023\u63a5\uff0c\u4e26\u78ba\u5b9a\u8a72\u8b8a\u6d41\u5668\u70ba\u958b\u555f\u72c0\u614b\uff08\u767d\u5929\uff09", + "cannot_open_serial_port": "\u7121\u6cd5\u958b\u555f\u5e8f\u5217\u57e0\u3001\u8acb\u6aa2\u67e5\u5f8c\u518d\u8a66\u4e00\u6b21", + "invalid_serial_port": "\u5e8f\u5217\u57e0\u70ba\u7121\u6548\u88dd\u7f6e\u6216\u7121\u6cd5\u958b\u555f", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "address": "\u8b8a\u6d41\u5668\u4f4d\u5740", + "port": "RS485 \u6216 USB-RS485 \u8f49\u63a5\u5668\u901a\u8a0a\u57e0" + }, + "description": "\u8b8a\u6d41\u5668\u5fc5\u9808\u900f\u904e RS485 \u8f49\u63a5\u5668\u9032\u884c\u9023\u63a5\u3001\u8acb\u9078\u64c7 LCD \u756b\u9762\u4e0a\u6240\u8a2d\u5b9a\u7684\u5e8f\u5217\u57e0\u53ca\u8b8a\u6d41\u5668\u4f4d\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 7381be5e9de61..374a36683dad0 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -117,23 +117,22 @@ from __future__ import annotations from datetime import timedelta +from http import HTTPStatus import uuid from aiohttp import web import voluptuous as vol from homeassistant.auth import InvalidAuthError -from homeassistant.auth.models import ( - TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, - Credentials, - User, -) +from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, Credentials from homeassistant.components import websocket_api -from homeassistant.components.http.auth import async_sign_path +from homeassistant.components.http.auth import ( + async_sign_path, + async_user_not_allowed_do_auth, +) from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_OK from homeassistant.core import HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -179,15 +178,12 @@ ) RESULT_TYPE_CREDENTIALS = "credentials" -RESULT_TYPE_USER = "user" @bind_hass -def create_auth_code( - hass, client_id: str, credential_or_user: Credentials | User -) -> str: +def create_auth_code(hass, client_id: str, credential: Credentials) -> str: """Create an authorization code to fetch tokens.""" - return hass.data[DOMAIN](client_id, credential_or_user) + return hass.data[DOMAIN](client_id, credential) async def async_setup(hass, config): @@ -259,27 +255,27 @@ async def post(self, request): return await self._async_handle_refresh_token(hass, data, request.remote) return self.json( - {"error": "unsupported_grant_type"}, status_code=HTTP_BAD_REQUEST + {"error": "unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST ) async def _async_handle_revoke_token(self, hass, data): """Handle revoke token request.""" + # pylint: disable=no-self-use + # OAuth 2.0 Token Revocation [RFC7009] # 2.2 The authorization server responds with HTTP status code 200 # if the token has been revoked successfully or if the client # submitted an invalid token. - token = data.get("token") - - if token is None: - return web.Response(status=HTTP_OK) + if (token := data.get("token")) is None: + return web.Response(status=HTTPStatus.OK) refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: - return web.Response(status=HTTP_OK) + return web.Response(status=HTTPStatus.OK) await hass.auth.async_remove_refresh_token(refresh_token) - return web.Response(status=HTTP_OK) + return web.Response(status=HTTPStatus.OK) async def _async_handle_auth_code(self, hass, data, remote_addr): """Handle authorization code request.""" @@ -287,31 +283,32 @@ async def _async_handle_auth_code(self, hass, data, remote_addr): if client_id is None or not indieauth.verify_client_id(client_id): return self.json( {"error": "invalid_request", "error_description": "Invalid client id"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) - code = data.get("code") - - if code is None: + if (code := data.get("code")) is None: return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) - credential = self._retrieve_auth(client_id, RESULT_TYPE_CREDENTIALS, code) + credential = self._retrieve_auth(client_id, code) if credential is None or not isinstance(credential, Credentials): return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) user = await hass.auth.async_get_or_create_user(credential) - if not user.is_active: + if user_access_error := async_user_not_allowed_do_auth(hass, user): return self.json( - {"error": "access_denied", "error_description": "User is not active"}, - status_code=HTTP_FORBIDDEN, + { + "error": "access_denied", + "error_description": user_access_error, + }, + status_code=HTTPStatus.FORBIDDEN, ) refresh_token = await hass.auth.async_create_refresh_token( @@ -324,7 +321,7 @@ async def _async_handle_auth_code(self, hass, data, remote_addr): except InvalidAuthError as exc: return self.json( {"error": "access_denied", "error_description": str(exc)}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) return self.json( @@ -344,21 +341,36 @@ async def _async_handle_refresh_token(self, hass, data, remote_addr): if client_id is not None and not indieauth.verify_client_id(client_id): return self.json( {"error": "invalid_request", "error_description": "Invalid client id"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) - token = data.get("refresh_token") - - if token is None: - return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) + if (token := data.get("refresh_token")) is None: + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: - return self.json({"error": "invalid_grant"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_grant"}, status_code=HTTPStatus.BAD_REQUEST + ) if refresh_token.client_id != client_id: - return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) + + if user_access_error := async_user_not_allowed_do_auth( + hass, refresh_token.user + ): + return self.json( + { + "error": "access_denied", + "error_description": user_access_error, + }, + status_code=HTTPStatus.FORBIDDEN, + ) try: access_token = hass.auth.async_create_access_token( @@ -367,7 +379,7 @@ async def _async_handle_refresh_token(self, hass, data, remote_addr): except InvalidAuthError as exc: return self.json( {"error": "access_denied", "error_description": str(exc)}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) return self.json( @@ -397,14 +409,20 @@ async def post(self, request, data): hass = request.app["hass"] user = request["hass_user"] - credentials = self._retrieve_credentials( - data["client_id"], RESULT_TYPE_CREDENTIALS, data["code"] - ) + credentials = self._retrieve_credentials(data["client_id"], data["code"]) if credentials is None: - return self.json_message("Invalid code", status_code=HTTP_BAD_REQUEST) + return self.json_message("Invalid code", status_code=HTTPStatus.BAD_REQUEST) + + linked_user = await hass.auth.async_get_user_by_credentials(credentials) + if linked_user != user and linked_user is not None: + return self.json_message( + "Credential already linked", status_code=HTTPStatus.BAD_REQUEST + ) - await hass.auth.async_link_user(user, credentials) + # No-op if credential is already linked to the user it will be linked to + if linked_user != user: + await hass.auth.async_link_user(user, credentials) return self.json_message("User linked") @@ -416,30 +434,25 @@ def _create_auth_code_store(): @callback def store_result(client_id, result): """Store flow result and return a code to retrieve it.""" - if isinstance(result, User): - result_type = RESULT_TYPE_USER - elif isinstance(result, Credentials): - result_type = RESULT_TYPE_CREDENTIALS - else: - raise ValueError("result has to be either User or Credentials") + if not isinstance(result, Credentials): + raise ValueError("result has to be a Credentials instance") code = uuid.uuid4().hex - temp_results[(client_id, result_type, code)] = ( + temp_results[(client_id, code)] = ( dt_util.utcnow(), - result_type, result, ) return code @callback - def retrieve_result(client_id, result_type, code): + def retrieve_result(client_id, code): """Retrieve flow result.""" - key = (client_id, result_type, code) + key = (client_id, code) if key not in temp_results: return None - created, _, result = temp_results.pop(key) + created, result = temp_results.pop(key) # OAuth 4.2.1 # The authorization code MUST expire shortly after it is issued to diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 725450a0a1208..a21854b77707b 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -52,7 +52,7 @@ Progress the flow. Most flows will be 1 page, but could optionally add extra login challenges, like TFA. Once the flow has finished, the returned step will -have type "create_entry" and "result" key will contain an authorization code. +have type RESULT_TYPE_CREATE_ENTRY and "result" key will contain an authorization code. The authorization code associated with an authorized user by default, it will associate with an credential if "type" set to "link_user" in "/auth/login_flow" @@ -66,6 +66,7 @@ "version": 1 } """ +from http import HTTPStatus from ipaddress import ip_address from aiohttp import web @@ -73,6 +74,8 @@ import voluptuous_serialize from homeassistant import data_entry_flow +from homeassistant.auth.models import Credentials +from homeassistant.components.http.auth import async_user_not_allowed_do_auth from homeassistant.components.http.ban import ( log_invalid_auth, process_success_login, @@ -80,11 +83,7 @@ ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import ( - HTTP_BAD_REQUEST, - HTTP_METHOD_NOT_ALLOWED, - HTTP_NOT_FOUND, -) +from homeassistant.core import HomeAssistant from . import indieauth @@ -109,7 +108,7 @@ async def get(self, request): if not hass.components.onboarding.async_is_user_onboarded(): return self.json_message( message="Onboarding not finished", - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, message_code="onboarding_required", ) @@ -134,8 +133,7 @@ def _prepare_result_json(result): data = result.copy() - schema = data["data_schema"] - if schema is None: + if (schema := data["data_schema"]) is None: data["data_schema"] = [] else: data["data_schema"] = voluptuous_serialize.convert(schema) @@ -143,11 +141,9 @@ def _prepare_result_json(result): return data -class LoginFlowIndexView(HomeAssistantView): - """View to create a config flow.""" +class LoginFlowBaseView(HomeAssistantView): + """Base class for the login views.""" - url = "/auth/login_flow" - name = "api:auth:login_flow" requires_auth = False def __init__(self, flow_mgr, store_result): @@ -155,9 +151,54 @@ def __init__(self, flow_mgr, store_result): self._flow_mgr = flow_mgr self._store_result = store_result + async def _async_flow_result_to_response(self, request, client_id, result): + """Convert the flow result to a response.""" + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + # @log_invalid_auth does not work here since it returns HTTP 200. + # We need to manually log failed login attempts. + if ( + result["type"] == data_entry_flow.RESULT_TYPE_FORM + and (errors := result.get("errors")) + and errors.get("base") + in ( + "invalid_auth", + "invalid_code", + ) + ): + await process_wrong_login(request) + return self.json(_prepare_result_json(result)) + + result.pop("data") + + hass: HomeAssistant = request.app["hass"] + result_obj: Credentials = result.pop("result") + + # Result can be None if credential was never linked to a user before. + user = await hass.auth.async_get_user_by_credentials(result_obj) + + if user is not None and ( + user_access_error := async_user_not_allowed_do_auth(hass, user) + ): + return self.json_message( + f"Login blocked: {user_access_error}", HTTPStatus.FORBIDDEN + ) + + await process_success_login(request) + result["result"] = self._store_result(client_id, result_obj) + + return self.json(result) + + +class LoginFlowIndexView(LoginFlowBaseView): + """View to create a config flow.""" + + url = "/auth/login_flow" + name = "api:auth:login_flow" + async def get(self, request): """Do not allow index of flows in progress.""" - return web.Response(status=HTTP_METHOD_NOT_ALLOWED) + # pylint: disable=no-self-use + return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED) @RequestDataValidator( vol.Schema( @@ -176,7 +217,7 @@ async def post(self, request, data): request.app["hass"], data["client_id"], data["redirect_uri"] ): return self.json_message( - "invalid client id or redirect uri", HTTP_BAD_REQUEST + "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) if isinstance(data["handler"], list): @@ -193,34 +234,26 @@ async def post(self, request, data): }, ) except data_entry_flow.UnknownHandler: - return self.json_message("Invalid handler specified", HTTP_NOT_FOUND) + return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) except data_entry_flow.UnknownStep: - return self.json_message("Handler does not support init", HTTP_BAD_REQUEST) - - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - await process_success_login(request) - result.pop("data") - result["result"] = self._store_result(data["client_id"], result["result"]) - return self.json(result) + return self.json_message( + "Handler does not support init", HTTPStatus.BAD_REQUEST + ) - return self.json(_prepare_result_json(result)) + return await self._async_flow_result_to_response( + request, data["client_id"], result + ) -class LoginFlowResourceView(HomeAssistantView): +class LoginFlowResourceView(LoginFlowBaseView): """View to interact with the flow manager.""" url = "/auth/login_flow/{flow_id}" name = "api:auth:login_flow:resource" - requires_auth = False - - def __init__(self, flow_mgr, store_result): - """Initialize the login flow resource view.""" - self._flow_mgr = flow_mgr - self._store_result = store_result async def get(self, request): """Do not allow getting status of a flow in progress.""" - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) @RequestDataValidator(vol.Schema({"client_id": str}, extra=vol.ALLOW_EXTRA)) @log_invalid_auth @@ -229,42 +262,26 @@ async def post(self, request, flow_id, data): client_id = data.pop("client_id") if not indieauth.verify_client_id(client_id): - return self.json_message("Invalid client id", HTTP_BAD_REQUEST) + return self.json_message("Invalid client id", HTTPStatus.BAD_REQUEST) try: # do not allow change ip during login flow - for flow in self._flow_mgr.async_progress(): - if flow["flow_id"] == flow_id and flow["context"][ - "ip_address" - ] != ip_address(request.remote): - return self.json_message("IP address changed", HTTP_BAD_REQUEST) - + flow = self._flow_mgr.async_get(flow_id) + if flow["context"]["ip_address"] != ip_address(request.remote): + return self.json_message("IP address changed", HTTPStatus.BAD_REQUEST) result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("User input malformed", HTTP_BAD_REQUEST) - - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - # @log_invalid_auth does not work here since it returns HTTP 200 - # need manually log failed login attempts - if result.get("errors") is not None and result["errors"].get("base") in [ - "invalid_auth", - "invalid_code", - ]: - await process_wrong_login(request) - return self.json(_prepare_result_json(result)) + return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) - result.pop("data") - result["result"] = self._store_result(client_id, result["result"]) - - return self.json(result) + return await self._async_flow_result_to_response(request, client_id, result) async def delete(self, request, flow_id): """Cancel a flow in progress.""" try: self._flow_mgr.async_abort(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) return self.json_message("Flow aborted") diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 1b199551a14ef..61c06a3c16e3d 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -70,8 +70,7 @@ async def async_setup_flow(msg): """Return a setup flow for mfa auth module.""" flow_manager = hass.data[DATA_SETUP_FLOW_MGR] - flow_id = msg.get("flow_id") - if flow_id is not None: + if (flow_id := msg.get("flow_id")) is not None: result = await flow_manager.async_configure(flow_id, msg.get("user_input")) connection.send_message( websocket_api.result_message(msg["id"], _prepare_result_json(result)) @@ -139,8 +138,7 @@ def _prepare_result_json(result): data = result.copy() - schema = data["data_schema"] - if schema is None: + if (schema := data["data_schema"]) is None: data["data_schema"] = [] else: data["data_schema"] = voluptuous_serialize.convert(schema) diff --git a/homeassistant/components/auth/translations/fi.json b/homeassistant/components/auth/translations/fi.json index 92e4f03c0f9eb..83aeeb4538c87 100644 --- a/homeassistant/components/auth/translations/fi.json +++ b/homeassistant/components/auth/translations/fi.json @@ -1,10 +1,16 @@ { "mfa_setup": { "notify": { + "abort": { + "no_available_service": "Ilmoituspalveluita ei ole saatavilla." + }, "error": { "invalid_code": "Virheellinen koodi. Yrit\u00e4 uudelleen." }, "step": { + "init": { + "description": "Valitse jokin ilmoituspalveluista:" + }, "setup": { "title": "Varmista asetukset" } @@ -12,6 +18,14 @@ "title": "Ilmoita kertaluonteinen salasana" }, "totp": { + "error": { + "invalid_code": "Virheellinen koodi, yrit\u00e4 uudelleen. Jos saat t\u00e4m\u00e4n virheen jatkuvasti, varmista, ett\u00e4 Home Assistant -j\u00e4rjestelm\u00e4si kello on ajassa." + }, + "step": { + "init": { + "title": "M\u00e4\u00e4rit\u00e4 kaksivaiheinen todennus TOTP:n avulla" + } + }, "title": "TOTP" } } diff --git a/homeassistant/components/auth/translations/he.json b/homeassistant/components/auth/translations/he.json index bc1826d4d7975..6bbf472a14b27 100644 --- a/homeassistant/components/auth/translations/he.json +++ b/homeassistant/components/auth/translations/he.json @@ -2,18 +2,18 @@ "mfa_setup": { "notify": { "abort": { - "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." + "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05d4\u05ea\u05e8\u05d0\u05d5\u05ea \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." }, "error": { "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." }, "step": { "init": { - "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify", + "description": "\u05e0\u05d0 \u05dc\u05d1\u05d7\u05d5\u05e8 \u05d0\u05d7\u05d3 \u05de\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05d4\u05d4\u05d5\u05d3\u05e2\u05d5\u05ea:", "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify" }, "setup": { - "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:", + "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea ** Notify. {notify_service} **. \u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4:", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4" } }, diff --git a/homeassistant/components/auth/translations/hu.json b/homeassistant/components/auth/translations/hu.json index 5e7b183509304..99504c1b7a747 100644 --- a/homeassistant/components/auth/translations/hu.json +++ b/homeassistant/components/auth/translations/hu.json @@ -9,11 +9,11 @@ }, "step": { "init": { - "description": "K\u00e9rlek, v\u00e1lassz egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", + "description": "K\u00e9rem, v\u00e1lasszon egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", "title": "\u00c1ll\u00edtsa be az \u00e9rtes\u00edt\u00e9si \u00f6sszetev\u0151 \u00e1ltal megadott egyszeri jelsz\u00f3t" }, "setup": { - "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rlek, add meg al\u00e1bb:", + "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rem, adja meg al\u00e1bb:", "title": "Be\u00e1ll\u00edt\u00e1s ellen\u0151rz\u00e9se" } }, @@ -21,11 +21,11 @@ }, "totp": { "error": { - "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r." + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1lja \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy a Home Assistant rendszer\u00e9nek \u00f3r\u00e1ja pontosan j\u00e1r." }, "step": { "init": { - "description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", + "description": "Ahhoz, hogy haszn\u00e1lhassa a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkennelje be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3j\u00e1val. Ha m\u00e9g nincs ilyenje, akkor aj\u00e1nljuk figyelm\u00e9be a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n adja meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6zne a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edtson egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val" } }, diff --git a/homeassistant/components/auth/translations/it.json b/homeassistant/components/auth/translations/it.json index 34d404f0bc6eb..e8091faf75716 100644 --- a/homeassistant/components/auth/translations/it.json +++ b/homeassistant/components/auth/translations/it.json @@ -5,27 +5,27 @@ "no_available_service": "Nessun servizio di notifica disponibile." }, "error": { - "invalid_code": "Codice non valido, per favore riprovare." + "invalid_code": "Codice non valido, riprova." }, "step": { "init": { - "description": "Selezionare uno dei servizi di notifica:", + "description": "Seleziona uno dei servizi di notifica:", "title": "Imposta la password monouso fornita dal componente di notifica" }, "setup": { - "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:", + "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Inseriscila qui sotto:", "title": "Verifica l'installazione" } }, - "title": "Notifica la Password monouso" + "title": "Notifica la password monouso" }, "totp": { "error": { - "invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato." + "invalid_code": "Codice non valido, riprova. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato." }, "step": { "init": { - "description": "Per attivare l'autenticazione a due fattori utilizzando le password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator](https://support.google.com/accounts/answer/1066447) o [Authy](https://authy.com/). \n\n {qr_code} \n \nDopo la scansione, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con il codice **`{code}`**.", + "description": "Per attivare l'autenticazione a due fattori utilizzando le password monouso basate sul tempo, esegui la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator](https://support.google.com/accounts/answer/1066447) o [Authy](https://authy.com/). \n\n {qr_code} \n \nDopo la scansione, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con il codice **`{code}`**.", "title": "Imposta l'autenticazione a due fattori usando TOTP" } }, diff --git a/homeassistant/components/auth/translations/ja.json b/homeassistant/components/auth/translations/ja.json new file mode 100644 index 0000000000000..182e56114d656 --- /dev/null +++ b/homeassistant/components/auth/translations/ja.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u5229\u7528\u3067\u304d\u308b\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u304c\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_code": "\u7121\u52b9\u306a\u30b3\u30fc\u30c9\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "description": "\u3069\u308c\u304b1\u3064\u3001\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u901a\u77e5\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u306b\u3088\u3063\u3066\u914d\u4fe1\u3055\u308c\u308b\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u8a2d\u5b9a" + }, + "setup": { + "description": "\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u304c **notify.{notify_service}** \u3092\u4ecb\u3057\u3066\u9001\u4fe1\u3055\u308c\u307e\u3057\u305f\u3002\u4ee5\u4e0b\u306b\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306e\u78ba\u8a8d" + } + }, + "title": "\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u901a\u77e5" + }, + "totp": { + "error": { + "invalid_code": "\u7121\u52b9\u306a\u30b3\u30fc\u30c9\u3067\u3059\u3001\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u3053\u306e\u30a8\u30e9\u30fc\u304c\u5e38\u306b\u767a\u751f\u3059\u308b\u5834\u5408\u306f\u3001Home Assistant\u306e\u30b7\u30b9\u30c6\u30e0\u6642\u8a08\u304c\u6b63\u78ba\u3067\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "description": "\u30bf\u30a4\u30e0\u30d9\u30fc\u30b9\u306e\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u4f7f\u7528\u3057\u30662\u8981\u7d20\u8a8d\u8a3c\u3092\u6709\u52b9\u306b\u3059\u308b\u306b\u306f\u3001\u8a8d\u8a3c\u30a2\u30d7\u30ea\u3067QR\u30b3\u30fc\u30c9\u3092\u30b9\u30ad\u30e3\u30f3\u3057\u307e\u3059\u3002\u8a8d\u8a3c\u30a2\u30d7\u30ea\u3092\u304a\u6301\u3061\u306e\u5834\u5408\u306f\u3001[Google \u8a8d\u8a3c\u30b7\u30b9\u30c6\u30e0](https://support.google.com/accounts/answer/1066447)\u307e\u305f\u306f\u3001[Authy](https://authy.com/)\u306e\u3069\u3061\u3089\u304b\u3092\u63a8\u5968\u3057\u307e\u3059\u3002\n\n{qr_code}\n\n\u30b3\u30fc\u30c9\u3092\u30b9\u30ad\u30e3\u30f3\u3057\u305f\u5f8c\u3001\u30a2\u30d7\u30ea\u304b\u30896\u6841\u306e\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002QR\u30b3\u30fc\u30c9\u306e\u30b9\u30ad\u30e3\u30f3\u3067\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001\u30b3\u30fc\u30c9 **`{code}`** \u3092\u4f7f\u7528\u3057\u3066\u624b\u52d5\u3067\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u884c\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "TOTP\u3092\u4f7f\u7528\u3057\u30662\u8981\u7d20\u8a8d\u8a3c\u3092\u8a2d\u5b9a\u3059\u308b" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/translations/tr.json b/homeassistant/components/auth/translations/tr.json index 7d27321457468..1cab13d4dc6a0 100644 --- a/homeassistant/components/auth/translations/tr.json +++ b/homeassistant/components/auth/translations/tr.json @@ -1,22 +1,35 @@ { "mfa_setup": { "notify": { + "abort": { + "no_available_service": "Kullan\u0131labilir bildirim hizmeti yok." + }, + "error": { + "invalid_code": "Ge\u00e7ersiz kod, l\u00fctfen tekrar deneyiniz." + }, "step": { "init": { + "description": "L\u00fctfen bildirim hizmetlerinden birini se\u00e7in:", "title": "Bilgilendirme bile\u015feni taraf\u0131ndan verilen tek seferlik parolay\u0131 ayarlay\u0131n" }, "setup": { - "description": "**bildirim yoluyla tek seferlik bir parola g\u00f6nderildi. {notify_service}**. L\u00fctfen a\u015fa\u011f\u0131da girin:" + "description": "**bildirim yoluyla tek seferlik bir parola g\u00f6nderildi. {notify_service}**. L\u00fctfen a\u015fa\u011f\u0131da girin:", + "title": "Kurulumu do\u011frulay\u0131n" } }, "title": "Tek Seferlik Parolay\u0131 Bildir" }, "totp": { + "error": { + "invalid_code": "Ge\u00e7ersiz kod, l\u00fctfen tekrar deneyiniz. S\u00fcrekli olarak bu hatay\u0131 al\u0131yorsan\u0131z, l\u00fctfen Home Assistant sisteminizin saatinin do\u011fru oldu\u011fundan emin olun." + }, "step": { "init": { - "description": "Zamana dayal\u0131 tek seferlik parolalar\u0131 kullanarak iki fakt\u00f6rl\u00fc kimlik do\u011frulamay\u0131 etkinle\u015ftirmek i\u00e7in kimlik do\u011frulama uygulaman\u0131zla QR kodunu taray\u0131n. Hesab\u0131n\u0131z yoksa, [Google Authenticator] (https://support.google.com/accounts/answer/1066447) veya [Authy] (https://authy.com/) \u00f6neririz. \n\n {qr_code}\n\n Kodu tarad\u0131ktan sonra, kurulumu do\u011frulamak i\u00e7in uygulaman\u0131zdan alt\u0131 haneli kodu girin. QR kodunu taramayla ilgili sorun ya\u015f\u0131yorsan\u0131z, ** ` {code} ` manuel kurulum yap\u0131n." + "description": "Zamana dayal\u0131 tek seferlik parolalar\u0131 kullanarak iki fakt\u00f6rl\u00fc kimlik do\u011frulamay\u0131 etkinle\u015ftirmek i\u00e7in kimlik do\u011frulama uygulaman\u0131zla QR kodunu taray\u0131n. Hesab\u0131n\u0131z yoksa, [Google Authenticator] (https://support.google.com/accounts/answer/1066447) veya [Authy] (https://authy.com/) \u00f6neririz. \n\n {qr_code}\n\n Kodu tarad\u0131ktan sonra, kurulumu do\u011frulamak i\u00e7in uygulaman\u0131zdan alt\u0131 haneli kodu girin. QR kodunu taramayla ilgili sorun ya\u015f\u0131yorsan\u0131z, ** ` {code} ` manuel kurulum yap\u0131n.", + "title": "TOTP kullanarak iki fakt\u00f6rl\u00fc kimlik do\u011frulamay\u0131 ayarlay\u0131n" } - } + }, + "title": "TOTP" } } } \ No newline at end of file diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index a338f6cf161f5..64c6b335fbd82 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, Awaitable, Callable, Dict, cast +from typing import Any, Awaitable, Callable, Dict, TypedDict, cast import voluptuous as vol from voluptuous.humanize import humanize_error @@ -54,7 +54,10 @@ Script, ) from homeassistant.helpers.script_variables import ScriptVariables -from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.service import ( + ReloadServiceHelper, + async_register_admin_service, +) from homeassistant.helpers.trace import ( TraceElement, script_execution_set, @@ -68,9 +71,6 @@ from homeassistant.util.dt import parse_datetime from .config import AutomationConfig, async_validate_config_item - -# Not used except by packages to check config structure -from .config import PLATFORM_SCHEMA # noqa: F401 from .const import ( CONF_ACTION, CONF_INITIAL_STATE, @@ -106,6 +106,23 @@ AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] +class AutomationTriggerData(TypedDict): + """Automation trigger data.""" + + id: str + idx: str + + +class AutomationTriggerInfo(TypedDict): + """Information about automation trigger.""" + + domain: str + name: str + home_assistant_start: bool + variables: TemplateVarsType + trigger_data: AutomationTriggerData + + @bind_hass def is_on(hass, entity_id): """ @@ -139,9 +156,7 @@ def entities_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - automation_entity = component.get_entity(entity_id) - - if automation_entity is None: + if (automation_entity := component.get_entity(entity_id)) is None: return [] return list(automation_entity.referenced_entities) @@ -170,9 +185,7 @@ def devices_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - automation_entity = component.get_entity(entity_id) - - if automation_entity is None: + if (automation_entity := component.get_entity(entity_id)) is None: return [] return list(automation_entity.referenced_devices) @@ -201,9 +214,7 @@ def areas_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - automation_entity = component.get_entity(entity_id) - - if automation_entity is None: + if (automation_entity := component.get_entity(entity_id)) is None: return [] return list(automation_entity.referenced_areas) @@ -211,7 +222,6 @@ def areas_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: async def async_setup(hass, config): """Set up all automations.""" - # Local import to avoid circular import hass.data[DOMAIN] = component = EntityComponent(LOGGER, DOMAIN, hass) # To register the automation blueprints @@ -246,15 +256,20 @@ async def trigger_service_handler(entity, service_call): async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" - conf = await component.async_prepare_reload() - if conf is None: + if (conf := await component.async_prepare_reload()) is None: return async_get_blueprints(hass).async_reset_cache() await _async_process_config(hass, conf, component) hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context) + reload_helper = ReloadServiceHelper(reload_service_handler) + async_register_admin_service( - hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + hass, + DOMAIN, + SERVICE_RELOAD, + reload_helper.execute_service, + schema=vol.Schema({}), ) return True @@ -263,6 +278,8 @@ async def reload_service_handler(service_call): class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" + _attr_should_poll = False + def __init__( self, automation_id, @@ -278,8 +295,7 @@ def __init__( trace_config, ): """Initialize an automation entity.""" - self._id = automation_id - self._name = name + self._attr_name = name self._trigger_config = trigger_config self._async_detach_triggers = None self._cond_func = cond_func @@ -295,21 +311,7 @@ def __init__( self._raw_config = raw_config self._blueprint_inputs = blueprint_inputs self._trace_config = trace_config - - @property - def name(self): - """Name of the automation.""" - return self._name - - @property - def unique_id(self): - """Return unique ID.""" - return self._id - - @property - def should_poll(self): - """No polling needed for automation entities.""" - return False + self._attr_unique_id = automation_id @property def extra_state_attributes(self): @@ -321,8 +323,8 @@ def extra_state_attributes(self): } if self.action_script.supports_max: attrs[ATTR_MAX] = self.action_script.max_runs - if self._id is not None: - attrs[CONF_ID] = self._id + if self.unique_id is not None: + attrs[CONF_ID] = self.unique_id return attrs @property @@ -383,8 +385,7 @@ async def async_added_to_hass(self) -> None: ) self.action_script.update_logger(self._logger) - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): enable_automation = state.state == STATE_ON last_triggered = state.attributes.get("last_triggered") if last_triggered is not None: @@ -449,15 +450,18 @@ async def async_trigger(self, run_variables, context=None, skip_condition=False) trigger_context, self._trace_config, ) as automation_trace: + this = None + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + variables = {"this": this, **(run_variables or {})} if self._variables: try: - variables = self._variables.async_render(self.hass, run_variables) + variables = self._variables.async_render(self.hass, variables) except template.TemplateError as err: self._logger.error("Error rendering variables: %s", err) automation_trace.set_error(err) return - else: - variables = run_variables + # Prepare tracing the automation automation_trace.set_trace(trace_get()) @@ -466,8 +470,8 @@ async def async_trigger(self, run_variables, context=None, skip_condition=False) automation_trace.set_trigger_description(trigger_description) # Add initial variables as the trigger step - if "trigger" in variables and "id" in variables["trigger"]: - trigger_path = f"trigger/{variables['trigger']['id']}" + if "trigger" in variables and "idx" in variables["trigger"]: + trigger_path = f"trigger/{variables['trigger']['idx']}" else: trigger_path = "trigger" trace_element = TraceElement(variables, trigger_path) @@ -487,7 +491,7 @@ async def async_trigger(self, run_variables, context=None, skip_condition=False) self.async_set_context(trigger_context) event_data = { - ATTR_NAME: self._name, + ATTR_NAME: self.name, ATTR_ENTITY_ID: self.entity_id, } if "trigger" in variables and "description" in variables["trigger"]: @@ -571,13 +575,19 @@ async def _async_attach_triggers( """Set up the triggers.""" def log_cb(level, msg, **kwargs): - self._logger.log(level, "%s %s", msg, self._name, **kwargs) + self._logger.log(level, "%s %s", msg, self.name, **kwargs) - variables = None + this = None + self.async_write_ha_state() + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + variables = {"this": this} if self._trigger_variables: try: variables = self._trigger_variables.async_render( - self.hass, None, limited=True + self.hass, + variables, + limited=True, ) except template.TemplateError as err: self._logger.error("Error rendering trigger variables: %s", err) @@ -588,7 +598,7 @@ def log_cb(level, msg, **kwargs): self._trigger_config, self.async_trigger, DOMAIN, - self._name, + str(self.name), log_cb, home_assistant_start, variables, @@ -704,7 +714,7 @@ async def _async_process_if(hass, name, config, p_config): checks = [] for if_config in if_configs: try: - checks.append(await condition.async_from_config(hass, if_config, False)) + checks.append(await condition.async_from_config(hass, if_config)) except HomeAssistantError as ex: LOGGER.warning("Invalid condition: %s", ex) return None diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index e28fa5c477f68..228e78ac446bf 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -19,7 +19,7 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, config_validation as cv, script -from homeassistant.helpers.condition import async_validate_condition_config +from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.loader import IntegrationNotFound @@ -37,6 +37,8 @@ # mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any +PACKAGE_MERGE_HINT = "list" + _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.All( @@ -74,11 +76,8 @@ async def async_validate_config_item(hass, config, full_config=None): ) if CONF_CONDITION in config: - config[CONF_CONDITION] = await asyncio.gather( - *[ - async_validate_condition_config(hass, cond) - for cond in config[CONF_CONDITION] - ] + config[CONF_CONDITION] = await async_validate_conditions_config( + hass, config[CONF_CONDITION] ) config[CONF_ACTION] = await script.async_validate_actions_config( diff --git a/homeassistant/components/automation/reproduce_state.py b/homeassistant/components/automation/reproduce_state.py index dd2ba824f8aaf..4318cdafa39e1 100644 --- a/homeassistant/components/automation/reproduce_state.py +++ b/homeassistant/components/automation/reproduce_state.py @@ -30,9 +30,7 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - cur_state = hass.states.get(state.entity_id) - - if cur_state is None: + if (cur_state := hass.states.get(state.entity_id)) is None: _LOGGER.warning("Unable to find entity %s", state.entity_id) return diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index 5d399fb253efc..62d0988d7702e 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -3,17 +3,20 @@ turn_on: name: Turn on description: Enable an automation. target: + entity: + domain: automation turn_off: name: Turn off description: Disable an automation. target: + entity: + domain: automation fields: stop_actions: name: Stop actions description: Stop currently running actions. default: true - example: true selector: boolean: @@ -21,17 +24,20 @@ toggle: name: Toggle description: Toggle (enable / disable) an automation. target: + entity: + domain: automation trigger: name: Trigger description: Trigger the actions of an automation. target: + entity: + domain: automation fields: skip_condition: name: Skip conditions description: Whether or not the conditions will be skipped. default: true - example: true selector: boolean: diff --git a/homeassistant/components/automation/trace.py b/homeassistant/components/automation/trace.py index 102aeda5a651f..f76dd57e4ed5f 100644 --- a/homeassistant/components/automation/trace.py +++ b/homeassistant/components/automation/trace.py @@ -8,6 +8,8 @@ from homeassistant.components.trace.const import CONF_STORED_TRACES from homeassistant.core import Context +from .const import DOMAIN + # mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any @@ -15,16 +17,17 @@ class AutomationTrace(ActionTrace): """Container for automation trace.""" + _domain = DOMAIN + def __init__( self, item_id: str, config: dict[str, Any], blueprint_inputs: dict[str, Any], context: Context, - ): + ) -> None: """Container for automation trace.""" - key = ("automation", item_id) - super().__init__(key, config, blueprint_inputs, context) + super().__init__(item_id, config, blueprint_inputs, context) self._trigger_description: str | None = None def set_trigger_description(self, trigger: str) -> None: @@ -33,6 +36,9 @@ def set_trigger_description(self, trigger: str) -> None: def as_short_dict(self) -> dict[str, Any]: """Return a brief dictionary version of this AutomationTrace.""" + if self._short_dict: + return self._short_dict + result = super().as_short_dict() result["trigger"] = self._trigger_description return result diff --git a/homeassistant/components/automation/translations/he.json b/homeassistant/components/automation/translations/he.json index 6e4decfce9a04..0b94cedbebd42 100644 --- a/homeassistant/components/automation/translations/he.json +++ b/homeassistant/components/automation/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05d0\u05d5\u05d8\u05d5\u05de\u05e6\u05d9\u05d4" diff --git a/homeassistant/components/automation/translations/hu.json b/homeassistant/components/automation/translations/hu.json index 85640af23ba31..559523b1b12a1 100644 --- a/homeassistant/components/automation/translations/hu.json +++ b/homeassistant/components/automation/translations/hu.json @@ -5,5 +5,5 @@ "on": "Be" } }, - "title": "Automatiz\u00e1l\u00e1s" + "title": "Automatizmus" } \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ru.json b/homeassistant/components/automation/translations/ru.json index 79732bea38539..d98f55a898e0a 100644 --- a/homeassistant/components/automation/translations/ru.json +++ b/homeassistant/components/automation/translations/ru.json @@ -1,8 +1,8 @@ { "state": { "_": { - "off": "\u0412\u044b\u043a\u043b", - "on": "\u0412\u043a\u043b" + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" } }, "title": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u044f" diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index eca020f6cd033..ceb66ff39b688 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -30,32 +30,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AveaLight(LightEntity): """Representation of an Avea.""" + _attr_supported_features = SUPPORT_AVEA + def __init__(self, light): """Initialize an AveaLight.""" self._light = light - self._name = light.name - self._state = None - self._brightness = light.brightness - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_AVEA - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_name = light.name + self._attr_brightness = light.brightness def turn_on(self, **kwargs): """Instruct the light to turn on.""" @@ -78,10 +59,6 @@ def update(self): This is the only method that should fetch new data for Home Assistant. """ - brightness = self._light.get_brightness() - if brightness is not None: - if brightness == 0: - self._state = False - else: - self._state = True - self._brightness = round(255 * (brightness / 4095)) + if (brightness := self._light.get_brightness()) is not None: + self._attr_is_on = brightness != 0 + self._attr_brightness = round(255 * (brightness / 4095)) diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 0d242b952dd3f..e8f42e6a816e7 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -65,49 +65,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AvionLight(LightEntity): """Representation of an Avion light.""" + _attr_supported_features = SUPPORT_AVION_LED + _attr_should_poll = False + _attr_assumed_state = True + _attr_is_on = True + def __init__(self, device): """Initialize the light.""" - self._name = device.name - self._address = device.mac - self._brightness = 255 - self._state = False + self._attr_name = device.name + self._attr_unique_id = device.mac + self._attr_brightness = 255 self._switch = device - @property - def unique_id(self): - """Return the ID of this light.""" - return self._address - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_AVION_LED - - @property - def should_poll(self): - """Don't poll.""" - return False - - @property - def assumed_state(self): - """We can't read the actual state, so assume it matches.""" - return True - def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" avion = importlib.import_module("avion") @@ -127,15 +96,13 @@ def set_state(self, brightness): def turn_on(self, **kwargs): """Turn the specified or all lights on.""" - brightness = kwargs.get(ATTR_BRIGHTNESS) - - if brightness is not None: - self._brightness = brightness + if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: + self._attr_brightness = brightness self.set_state(self.brightness) - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs): """Turn the specified or all lights off.""" self.set_state(0) - self._state = False + self._attr_is_on = False diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 6af2850ea31d2..2cfaa88022dd0 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -8,14 +8,14 @@ from python_awair import Awair from python_awair.exceptions import AuthError -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL, AwairResult -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass, config_entry) -> bool: @@ -58,13 +58,13 @@ def __init__(self, hass, config_entry, session) -> None: async def _async_update_data(self) -> Any | None: """Update data via Awair client library.""" - with timeout(API_TIMEOUT): + async with timeout(API_TIMEOUT): try: LOGGER.debug("Fetching users and devices") user = await self._awair.user() devices = await user.devices() results = await gather( - *[self._fetch_air_data(device) for device in devices] + *(self._fetch_air_data(device) for device in devices) ) return {result.device.uuid: result for result in results} except AuthError as err: @@ -74,6 +74,7 @@ async def _async_update_data(self) -> Any | None: async def _fetch_air_data(self, device): """Fetch latest air quality data.""" + # pylint: disable=no-self-use LOGGER.debug("Fetching data for %s", device.uuid) air_data = await device.air_data_latest() LOGGER.debug(air_data) diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 3854909bc864d..4c4ccad8f5246 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -19,7 +19,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_import(self, conf: dict): """Import a configuration from config.yaml.""" - if self.hass.config_entries.async_entries(DOMAIN): + if self._async_current_entries(): return self.async_abort(reason="already_setup") user, error = await self._check_connection(conf[CONF_ACCESS_TOKEN]) @@ -69,6 +69,7 @@ async def async_step_reauth(self, user_input: dict | None = None): if error is None: entry = await self.async_set_unique_id(self.unique_id) + assert entry self.hass.config_entries.async_update_entry(entry, data=user_input) return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 2853ef9dd6c81..352237da3863b 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,23 +1,21 @@ """Constants for the Awair component.""" +from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging +from python_awair.air_data import AirData from python_awair.devices import AwairDevice +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, + SOUND_PRESSURE_WEIGHTED_DBA, TEMP_CELSIUS, ) @@ -35,10 +33,6 @@ ATTRIBUTION = "Awair air quality sensor" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" -ATTR_UNIQUE_ID = "unique_id" - DOMAIN = "awair" DUST_ALIASES = [API_PM25, API_PM10] @@ -47,71 +41,89 @@ UPDATE_INTERVAL = timedelta(minutes=5) -SENSOR_TYPES = { - API_SCORE: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Awair score", - ATTR_UNIQUE_ID: "score", # matches legacy format - }, - API_HUMID: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Humidity", - ATTR_UNIQUE_ID: "HUMID", # matches legacy format - }, - API_LUX: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, - ATTR_ICON: None, - ATTR_UNIT: LIGHT_LUX, - ATTR_LABEL: "Illuminance", - ATTR_UNIQUE_ID: "illuminance", - }, - API_SPL_A: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:ear-hearing", - ATTR_UNIT: "dBa", - ATTR_LABEL: "Sound level", - ATTR_UNIQUE_ID: "sound_level", - }, - API_VOC: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_LABEL: "Volatile organic compounds", - ATTR_UNIQUE_ID: "VOC", # matches legacy format - }, - API_TEMP: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_UNIT: TEMP_CELSIUS, - ATTR_LABEL: "Temperature", - ATTR_UNIQUE_ID: "TEMP", # matches legacy format - }, - API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM2.5", - ATTR_UNIQUE_ID: "PM25", # matches legacy format - }, - API_PM10: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM10", - ATTR_UNIQUE_ID: "PM10", # matches legacy format - }, - API_CO2: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO2, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_LABEL: "Carbon dioxide", - ATTR_UNIQUE_ID: "CO2", # matches legacy format - }, -} + +@dataclass +class AwairRequiredKeysMixin: + """Mixinf for required keys.""" + + unique_id_tag: str + + +@dataclass +class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): + """Describes Awair sensor entity.""" + + +SENSOR_TYPE_SCORE = AwairSensorEntityDescription( + key=API_SCORE, + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + name="Awair score", + unique_id_tag="score", # matches legacy format +) + +SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_HUMID, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + unique_id_tag="HUMID", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + unique_id_tag="illuminance", + ), + AwairSensorEntityDescription( + key=API_SPL_A, + icon="mdi:ear-hearing", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + name="Sound level", + unique_id_tag="sound_level", + ), + AwairSensorEntityDescription( + key=API_VOC, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="Volatile organic compounds", + unique_id_tag="VOC", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + unique_id_tag="TEMP", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_CO2, + device_class=SensorDeviceClass.CO2, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="Carbon dioxide", + unique_id_tag="CO2", # matches legacy format + ), +) + +SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_PM25, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM2.5", + unique_id_tag="PM25", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_PM10, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM10", + unique_id_tag="PM10", # matches legacy format + ), +) @dataclass @@ -119,4 +131,4 @@ class AwairResult: """Wrapper class to hold an awair device and set of air data.""" device: AwairDevice - air_data: dict + air_data: AirData diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 968587c3b10ac..b74c19330edfd 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,36 +1,40 @@ """Support for Awair sensors.""" from __future__ import annotations +from python_awair.air_data import AirData from python_awair.devices import AwairDevice import voluptuous as vol -from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_ACCESS_TOKEN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_CONNECTIONS, + ATTR_NAME, + CONF_ACCESS_TOKEN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AwairDataUpdateCoordinator, AwairResult from .const import ( API_DUST, API_PM25, API_SCORE, API_TEMP, API_VOC, - ATTR_ICON, - ATTR_LABEL, - ATTR_UNIQUE_ID, - ATTR_UNIT, ATTRIBUTION, DOMAIN, DUST_ALIASES, LOGGER, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, + AwairSensorEntityDescription, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -55,21 +59,25 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ): """Set up Awair sensor entity based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] + entities = [] data: list[AwairResult] = coordinator.data.values() for result in data: if result.air_data: - sensors.append(AwairSensor(API_SCORE, result.device, coordinator)) + entities.append(AwairSensor(result.device, coordinator, SENSOR_TYPE_SCORE)) device_sensors = result.air_data.sensors.keys() - for sensor in device_sensors: - if sensor in SENSOR_TYPES: - sensors.append(AwairSensor(sensor, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in (*SENSOR_TYPES, *SENSOR_TYPES_DUST) + if description.key in device_sensors + ] + ) # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only # present on first-gen devices in lieu of separate pm2.5/pm10 sensors. @@ -78,45 +86,54 @@ async def async_setup_entry( # that data - because we can't really tell what kind of particles the # "DUST" sensor actually detected. However, it's still useful data. if API_DUST in device_sensors: - for alias_kind in DUST_ALIASES: - sensors.append(AwairSensor(alias_kind, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in SENSOR_TYPES_DUST + ] + ) - async_add_entities(sensors) + async_add_entities(entities) class AwairSensor(CoordinatorEntity, SensorEntity): """Defines an Awair sensor entity.""" + entity_description: AwairSensorEntityDescription + def __init__( self, - kind: str, device: AwairDevice, coordinator: AwairDataUpdateCoordinator, + description: AwairSensorEntityDescription, ) -> None: """Set up an individual AwairSensor.""" super().__init__(coordinator) - self._kind = kind + self.entity_description = description self._device = device @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the sensor.""" - name = SENSOR_TYPES[self._kind][ATTR_LABEL] if self._device.name: - name = f"{self._device.name} {name}" + return f"{self._device.name} {self.entity_description.name}" - return name + return self.entity_description.name @property def unique_id(self) -> str: """Return the uuid as the unique_id.""" - unique_id_tag = SENSOR_TYPES[self._kind][ATTR_UNIQUE_ID] + unique_id_tag = self.entity_description.unique_id_tag # This integration used to create a sensor that was labelled as a "PM2.5" # sensor for first-gen Awair devices, but its unique_id reflected the truth: # under the hood, it was a "DUST" sensor. So we preserve that specific unique_id # for users with first-gen devices that are upgrading. - if self._kind == API_PM25 and API_DUST in self._air_data.sensors: + if ( + self.entity_description.key == API_PM25 + and self._air_data + and API_DUST in self._air_data.sensors + ): unique_id_tag = "DUST" return f"{self._device.uuid}_{unique_id_tag}" @@ -127,16 +144,17 @@ def available(self) -> bool: # If the last update was successful... if self.coordinator.last_update_success and self._air_data: # and the results included our sensor type... - if self._kind in self._air_data.sensors: + sensor_type = self.entity_description.key + if sensor_type in self._air_data.sensors: # then we are available. return True # or, we're a dust alias - if self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + if sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: return True # or we are API_SCORE - if self._kind == API_SCORE: + if sensor_type == API_SCORE: # then we are available. return True @@ -144,41 +162,30 @@ def available(self) -> bool: return False @property - def state(self) -> float: + def native_value(self) -> float | None: """Return the state, rounding off to reasonable values.""" + if not self._air_data: + return None + state: float + sensor_type = self.entity_description.key # Special-case for "SCORE", which we treat as the AQI - if self._kind == API_SCORE: + if sensor_type == API_SCORE: state = self._air_data.score - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: state = self._air_data.sensors.dust else: - state = self._air_data.sensors[self._kind] + state = self._air_data.sensors[sensor_type] - if self._kind == API_VOC or self._kind == API_SCORE: + if sensor_type in {API_VOC, API_SCORE}: return round(state) - if self._kind == API_TEMP: + if sensor_type == API_TEMP: return round(state, 1) return round(state, 2) - @property - def icon(self) -> str: - """Return the icon.""" - return SENSOR_TYPES[self._kind][ATTR_ICON] - - @property - def device_class(self) -> str: - """Return the device_class.""" - return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] - - @property - def unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self._kind][ATTR_UNIT] - @property def extra_state_attributes(self) -> dict: """Return the Awair Index alongside state attributes. @@ -201,10 +208,13 @@ def extra_state_attributes(self) -> dict: https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ + sensor_type = self.entity_description.key attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - if self._kind in self._air_data.indices: - attrs["awair_index"] = abs(self._air_data.indices[self._kind]) - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.indices: + if not self._air_data: + return attrs + if sensor_type in self._air_data.indices: + attrs["awair_index"] = abs(self._air_data.indices[sensor_type]) + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.indices: attrs["awair_index"] = abs(self._air_data.indices.dust) return attrs @@ -212,24 +222,24 @@ def extra_state_attributes(self) -> dict: @property def device_info(self) -> DeviceInfo: """Device information.""" - info = { - "identifiers": {(DOMAIN, self._device.uuid)}, - "manufacturer": "Awair", - "model": self._device.model, - } + info = DeviceInfo( + identifiers={(DOMAIN, self._device.uuid)}, + manufacturer="Awair", + model=self._device.model, + ) if self._device.name: - info["name"] = self._device.name + info[ATTR_NAME] = self._device.name if self._device.mac_address: - info["connections"] = { + info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, self._device.mac_address) } return info @property - def _air_data(self) -> AwairResult | None: + def _air_data(self) -> AirData | None: """Return the latest data for our device, or None.""" result: AwairResult | None = self.coordinator.data.get(self._device.uuid) if result: diff --git a/homeassistant/components/awair/translations/bg.json b/homeassistant/components/awair/translations/bg.json new file mode 100644 index 0000000000000..1d5233cabbfe6 --- /dev/null +++ b/homeassistant/components/awair/translations/bg.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "reauth": { + "data": { + "email": "Email" + } + }, + "user": { + "data": { + "email": "Email" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/ca.json b/homeassistant/components/awair/translations/ca.json index 2e75af9e7441d..12384b088bb3f 100644 --- a/homeassistant/components/awair/translations/ca.json +++ b/homeassistant/components/awair/translations/ca.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_devices_found": "No s'han trobat dispositius a la xarxa", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { - "invalid_access_token": "Token d'acc\u00e9s no v\u00e0lid", + "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "unknown": "Error inesperat" }, "step": { diff --git a/homeassistant/components/awair/translations/es-419.json b/homeassistant/components/awair/translations/es-419.json new file mode 100644 index 0000000000000..f487cd397c4e4 --- /dev/null +++ b/homeassistant/components/awair/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth": { + "description": "Vuelva a ingresar su token de acceso de desarrollador de Awair." + }, + "user": { + "description": "Debe registrarse para obtener un token de acceso de desarrollador de Awair en: https://developer.getawair.com/onboard/login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/fr.json b/homeassistant/components/awair/translations/fr.json index dd90f9409778c..65d550b52a6b6 100644 --- a/homeassistant/components/awair/translations/fr.json +++ b/homeassistant/components/awair/translations/fr.json @@ -3,11 +3,11 @@ "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_access_token": "Jeton d'acc\u00e8s non valide", - "unknown": "Erreur d'API Awair inconnue." + "unknown": "Erreur inattendue" }, "step": { "reauth": { diff --git a/homeassistant/components/awair/translations/he.json b/homeassistant/components/awair/translations/he.json new file mode 100644 index 0000000000000..55e8b21a52b01 --- /dev/null +++ b/homeassistant/components/awair/translations/he.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "invalid_access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth": { + "data": { + "access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4", + "email": "\u05d3\u05d5\u05d0\"\u05dc" + } + }, + "user": { + "data": { + "access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4", + "email": "\u05d3\u05d5\u05d0\"\u05dc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index 53827adf344e7..e3994430a8b50 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "invalid_access_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", @@ -15,13 +15,14 @@ "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "email": "E-mail" }, - "description": "Add meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." + "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "email": "E-mail" - } + }, + "description": "Regisztr\u00e1lnia kell az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokenj\u00e9hez a k\u00f6vetkez\u0151 c\u00edmen: https://developer.getawair.com/onboard/login" } } } diff --git a/homeassistant/components/awair/translations/it.json b/homeassistant/components/awair/translations/it.json index cad2b8555a8f0..c9480ecaaa006 100644 --- a/homeassistant/components/awair/translations/it.json +++ b/homeassistant/components/awair/translations/it.json @@ -13,14 +13,14 @@ "reauth": { "data": { "access_token": "Token di accesso", - "email": "E-mail" + "email": "Email" }, "description": "Inserisci nuovamente il tuo token di accesso per sviluppatori Awair." }, "user": { "data": { "access_token": "Token di accesso", - "email": "E-mail" + "email": "Email" }, "description": "\u00c8 necessario registrarsi per un token di accesso per sviluppatori Awair all'indirizzo: https://developer.getawair.com/onboard/login" } diff --git a/homeassistant/components/awair/translations/ja.json b/homeassistant/components/awair/translations/ja.json new file mode 100644 index 0000000000000..83121c9fe4240 --- /dev/null +++ b/homeassistant/components/awair/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "email": "E\u30e1\u30fc\u30eb" + }, + "description": "Awair developer access token\u3092\u518d\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "email": "E\u30e1\u30fc\u30eb" + }, + "description": "Awair developer access token\u306e\u767b\u9332\u306f\u4ee5\u4e0b\u306e\u30b5\u30a4\u30c8\u3067\u884c\u3046\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059: https://developer.getawair.com/onboard/login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/tr.json b/homeassistant/components/awair/translations/tr.json index 84da92b97d39f..e8fa8ca10276a 100644 --- a/homeassistant/components/awair/translations/tr.json +++ b/homeassistant/components/awair/translations/tr.json @@ -2,22 +2,24 @@ "config": { "abort": { "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci", + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "unknown": "Beklenmeyen hata" }, "step": { "reauth": { "data": { - "access_token": "Eri\u015fim Belirteci", + "access_token": "Eri\u015fim Anahtar\u0131", "email": "E-posta" - } + }, + "description": "L\u00fctfen Awair geli\u015ftirici eri\u015fim anahtar\u0131n\u0131 yeniden girin." }, "user": { "data": { - "access_token": "Eri\u015fim Belirteci", + "access_token": "Eri\u015fim Anahtar\u0131", "email": "E-posta" }, "description": "Awair geli\u015ftirici eri\u015fim belirteci i\u00e7in \u015fu adresten kaydolmal\u0131s\u0131n\u0131z: https://developer.getawair.com/onboard/login" diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py index da8c27d74455d..6dcbad748cc3b 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -85,8 +85,7 @@ async def async_setup(hass, config): """Set up AWS component.""" hass.data[DATA_HASS_CONFIG] = config - conf = config.get(DOMAIN) - if conf is None: + if (conf := config.get(DOMAIN)) is None: # create a default conf using default profile conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL}) @@ -159,9 +158,7 @@ async def _validate_aws_credentials(hass, credential): del aws_config[CONF_NAME] del aws_config[CONF_VALIDATE] - profile = aws_config.get(CONF_PROFILE_NAME) - - if profile is not None: + if (profile := aws_config.get(CONF_PROFILE_NAME)) is not None: session = aiobotocore.AioSession(profile=profile) del aws_config[CONF_PROFILE_NAME] if CONF_ACCESS_KEY_ID in aws_config: diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py index c9d6ca2faa725..b271a2a8786ef 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -82,8 +82,7 @@ async def async_get_service(hass, config, discovery_info=None): del aws_config[CONF_CREDENTIAL_NAME] if session is None: - profile = aws_config.get(CONF_PROFILE_NAME) - if profile is not None: + if (profile := aws_config.get(CONF_PROFILE_NAME)) is not None: session = aiobotocore.AioSession(profile=profile) del aws_config[CONF_PROFILE_NAME] else: diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py index 3e2b1a48eb79a..791764dc605a7 100644 --- a/homeassistant/components/axis/axis_base.py +++ b/homeassistant/components/axis/axis_base.py @@ -2,7 +2,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN as AXIS_DOMAIN @@ -14,6 +14,10 @@ def __init__(self, device): """Initialize the Axis event.""" self.device = device + self._attr_device_info = DeviceInfo( + identifiers={(AXIS_DOMAIN, device.unique_id)} + ) + async def async_added_to_hass(self): """Subscribe device events.""" self.async_on_remove( @@ -27,11 +31,6 @@ def available(self): """Return True if device is available.""" return self.device.available - @property - def device_info(self): - """Return a device description for device registry.""" - return {"identifiers": {(AXIS_DOMAIN, self.device.unique_id)}} - @callback def update_callback(self, no_delay=None): """Update the entities state.""" @@ -41,11 +40,18 @@ def update_callback(self, no_delay=None): class AxisEventBase(AxisEntityBase): """Base common to all Axis entities from event stream.""" + _attr_should_poll = False + def __init__(self, event, device): """Initialize the Axis event.""" super().__init__(device) self.event = event + self._attr_name = f"{device.name} {event.TYPE} {event.id}" + self._attr_unique_id = f"{device.unique_id}-{event.topic}-{event.id}" + + self._attr_device_class = event.CLASS + async def async_added_to_hass(self) -> None: """Subscribe sensors events.""" self.event.register_callback(self.update_callback) @@ -54,23 +60,3 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Disconnect device object when removed.""" self.event.remove_callback(self.update_callback) - - @property - def device_class(self): - """Return the class of the event.""" - return self.event.CLASS - - @property - def name(self): - """Return the name of the event.""" - return f"{self.device.name} {self.event.TYPE} {self.event.id}" - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return f"{self.device.unique_id}-{self.event.topic}-{self.event.id}" diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 222a356d4f98d..01cfb834f26f5 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -17,10 +17,7 @@ ) from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_LIGHT, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_SOUND, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.core import callback @@ -32,10 +29,10 @@ from .const import DOMAIN as AXIS_DOMAIN DEVICE_CLASS = { - CLASS_INPUT: DEVICE_CLASS_CONNECTIVITY, - CLASS_LIGHT: DEVICE_CLASS_LIGHT, - CLASS_MOTION: DEVICE_CLASS_MOTION, - CLASS_SOUND: DEVICE_CLASS_SOUND, + CLASS_INPUT: BinarySensorDeviceClass.CONNECTIVITY, + CLASS_LIGHT: BinarySensorDeviceClass.LIGHT, + CLASS_MOTION: BinarySensorDeviceClass.MOTION, + CLASS_SOUND: BinarySensorDeviceClass.SOUND, } @@ -66,6 +63,8 @@ def __init__(self, event, device): super().__init__(event, device) self.cancel_scheduled_update = None + self._attr_device_class = DEVICE_CLASS.get(self.event.CLASS) + @callback def update_callback(self, no_delay=False): """Update the sensor's state, if needed. @@ -126,9 +125,4 @@ def name(self): ): return f"{self.device.name} {self.event.TYPE} {event_data[self.event.id].name}" - return super().name - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS.get(self.event.CLASS) + return self._attr_name diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index cf2634b8f3a31..bd0cd46a1817f 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -51,6 +51,8 @@ def __init__(self, device): } MjpegCamera.__init__(self, config) + self._attr_unique_id = f"{device.unique_id}-camera" + async def async_added_to_hass(self): """Subscribe camera events.""" self.async_on_remove( @@ -71,11 +73,6 @@ def _new_address(self) -> None: self._mjpeg_url = self.mjpeg_source self._still_image_url = self.image_source - @property - def unique_id(self) -> str: - """Return a unique identifier for this device.""" - return f"{self.device.unique_id}-camera" - @property def image_source(self) -> str: """Return still image URL for device.""" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 8753114d86e1f..a47592fc87703 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ( CONF_HOST, @@ -17,6 +17,7 @@ CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from homeassistant.util.network import is_link_local @@ -151,37 +152,39 @@ async def async_step_reauth(self, device_config: dict): return await self.async_step_user() - async def async_step_dhcp(self, discovery_info: dict): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Prepare configuration for a DHCP discovered Axis device.""" return await self._process_discovered_device( { - CONF_HOST: discovery_info[IP_ADDRESS], - CONF_MAC: format_mac(discovery_info.get(MAC_ADDRESS, "")), - CONF_NAME: discovery_info.get(HOSTNAME), + CONF_HOST: discovery_info.ip, + CONF_MAC: format_mac(discovery_info.macaddress), + CONF_NAME: discovery_info.hostname, CONF_PORT: DEFAULT_PORT, } ) - async def async_step_ssdp(self, discovery_info: dict): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Prepare configuration for a SSDP discovered Axis device.""" - url = urlsplit(discovery_info["presentationURL"]) + url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL]) return await self._process_discovered_device( { CONF_HOST: url.hostname, - CONF_MAC: format_mac(discovery_info["serialNumber"]), - CONF_NAME: f"{discovery_info['friendlyName']}", + CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]), + CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}", CONF_PORT: url.port, } ) - async def async_step_zeroconf(self, discovery_info: dict): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a Zeroconf discovered Axis device.""" return await self._process_discovered_device( { - CONF_HOST: discovery_info[CONF_HOST], - CONF_MAC: format_mac(discovery_info["properties"]["macaddress"]), - CONF_NAME: discovery_info["name"].split(".", 1)[0], - CONF_PORT: discovery_info[CONF_PORT], + CONF_HOST: discovery_info.host, + CONF_MAC: format_mac(discovery_info.properties["macaddress"]), + CONF_NAME: discovery_info.name.split(".", 1)[0], + CONF_PORT: discovery_info.port, } ) @@ -243,8 +246,7 @@ async def async_step_configure_stream(self, user_input=None): # Stream profiles - if vapix.params.stream_profiles_max_groups > 0: - + if vapix.stream_profiles or vapix.params.stream_profiles_max_groups > 0: stream_profiles = [DEFAULT_STREAM_PROFILE] for profile in vapix.streaming_profiles: stream_profiles.append(profile.name) diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py index a1ce77f099b79..c9267568707d8 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -1,10 +1,7 @@ """Constants for the Axis component.""" import logging -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) @@ -22,4 +19,4 @@ DEFAULT_TRIGGER_TIME = 0 DEFAULT_VIDEO_SOURCE = "No video source" -PLATFORMS = [BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.LIGHT, Platform.SWITCH] diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index f1a57eec33c93..a2eceff6870f7 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -12,7 +12,7 @@ from homeassistant.components import mqtt from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.mqtt.models import Message +from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -23,6 +23,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.httpx_client import get_async_client @@ -168,9 +169,10 @@ async def async_new_address_callback(hass, entry): async def async_update_device_registry(self): """Update device registry.""" - device_registry = await self.hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, + configuration_url=self.api.config.url, connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, identifiers={(AXIS_DOMAIN, self.unique_id)}, manufacturer=ATTR_MANUFACTURER, @@ -195,7 +197,7 @@ async def use_mqtt(self, hass: HomeAssistant, component: str) -> None: ) @callback - def mqtt_message(self, message: Message) -> None: + def mqtt_message(self, message: ReceiveMessage) -> None: """Receive Axis MQTT message.""" self.disconnect_from_stream() @@ -226,12 +228,12 @@ async def async_setup(self): async def start_platforms(): await asyncio.gather( - *[ + *( self.hass.config_entries.async_forward_entry_setup( self.config_entry, platform ) for platform in PLATFORMS - ] + ) ) if self.option_events: self.api.stream.connection_status_callback.append( @@ -278,7 +280,7 @@ async def get_device(hass, host, port, username, password): ) try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): await device.vapix.initialize() return device diff --git a/homeassistant/components/axis/light.py b/homeassistant/components/axis/light.py index e627d6ccdbdba..ced795882e1ca 100644 --- a/homeassistant/components/axis/light.py +++ b/homeassistant/components/axis/light.py @@ -4,7 +4,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, - SUPPORT_BRIGHTNESS, + COLOR_MODE_BRIGHTNESS, LightEntity, ) from homeassistant.core import callback @@ -40,6 +40,8 @@ def async_add_sensor(event_id): class AxisLight(AxisEventBase, LightEntity): """Representation of a light Axis event.""" + _attr_should_poll = True + def __init__(self, event, device): """Initialize the Axis light.""" super().__init__(event, device) @@ -49,7 +51,11 @@ def __init__(self, event, device): self.current_intensity = 0 self.max_intensity = 0 - self._features = SUPPORT_BRIGHTNESS + light_type = device.api.vapix.light_control[self.light_id].light_type + self._attr_name = f"{device.name} {light_type} {event.TYPE} {event.id}" + + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + self._attr_color_mode = COLOR_MODE_BRIGHTNESS async def async_added_to_hass(self) -> None: """Subscribe lights events.""" @@ -67,17 +73,6 @@ async def async_added_to_hass(self) -> None: ) self.max_intensity = max_intensity["data"]["ranges"][0]["high"] - @property - def supported_features(self): - """Flag supported features.""" - return self._features - - @property - def name(self): - """Return the name of the light.""" - light_type = self.device.api.vapix.light_control[self.light_id].light_type - return f"{self.device.name} {light_type} {self.event.TYPE} {self.event.id}" - @property def is_on(self): """Return true if light is on.""" @@ -112,8 +107,3 @@ async def async_update(self): ) ) self.current_intensity = current_intensity["data"]["intensity"] - - @property - def should_poll(self): - """Brightness needs polling.""" - return True diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 52e0c99044b24..59e723411507d 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,15 +26,15 @@ "zeroconf": [ { "type": "_axis-video._tcp.local.", - "macaddress": "00408C*" + "properties": {"macaddress": "00408c*"} }, { "type": "_axis-video._tcp.local.", - "macaddress": "ACCC8E*" + "properties": {"macaddress": "accc8e*"} }, { "type": "_axis-video._tcp.local.", - "macaddress": "B8A44F*" + "properties": {"macaddress": "b8a44f*"} } ], "after_dependencies": ["mqtt"], diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py index e509716fc1ff0..3a23c3202df16 100644 --- a/homeassistant/components/axis/switch.py +++ b/homeassistant/components/axis/switch.py @@ -30,6 +30,13 @@ def async_add_switch(event_id): class AxisSwitch(AxisEventBase, SwitchEntity): """Representation of a Axis switch.""" + def __init__(self, event, device): + """Initialize the Axis switch.""" + super().__init__(event, device) + + if event.id and device.api.vapix.ports[event.id].name: + self._attr_name = f"{device.name} {device.api.vapix.ports[event.id].name}" + @property def is_on(self): """Return true if event is active.""" @@ -42,13 +49,3 @@ async def async_turn_on(self, **kwargs): async def async_turn_off(self, **kwargs): """Turn off switch.""" await self.device.api.vapix.ports[self.event.id].open() - - @property - def name(self): - """Return the name of the event.""" - if self.event.id and self.device.api.vapix.ports[self.event.id].name: - return ( - f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}" - ) - - return super().name diff --git a/homeassistant/components/axis/translations/de.json b/homeassistant/components/axis/translations/de.json index ed95dea6fc12b..607000b6eaa28 100644 --- a/homeassistant/components/axis/translations/de.json +++ b/homeassistant/components/axis/translations/de.json @@ -11,7 +11,7 @@ "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung" }, - "flow_title": "Achsenger\u00e4t: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/axis/translations/es-419.json b/homeassistant/components/axis/translations/es-419.json index 0e1c1e99b3603..39d216dd47593 100644 --- a/homeassistant/components/axis/translations/es-419.json +++ b/homeassistant/components/axis/translations/es-419.json @@ -21,5 +21,15 @@ "title": "Configurar dispositivo Axis" } } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "Seleccionar perfil de transmisi\u00f3n para usar" + }, + "title": "Opciones de transmisi\u00f3n de video del dispositivo Axis" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/axis/translations/fr.json b/homeassistant/components/axis/translations/fr.json index ed4113d02e2fc..ea3f93feb50e0 100644 --- a/homeassistant/components/axis/translations/fr.json +++ b/homeassistant/components/axis/translations/fr.json @@ -7,7 +7,7 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, @@ -15,7 +15,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "username": "Nom d'utilisateur" diff --git a/homeassistant/components/axis/translations/he.json b/homeassistant/components/axis/translations/he.json index 3007c0e968c1d..903656d41cf48 100644 --- a/homeassistant/components/axis/translations/he.json +++ b/homeassistant/components/axis/translations/he.json @@ -1,9 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } } } diff --git a/homeassistant/components/axis/translations/hu.json b/homeassistant/components/axis/translations/hu.json index 972690ede9724..cb2f9a17c93be 100644 --- a/homeassistant/components/axis/translations/hu.json +++ b/homeassistant/components/axis/translations/hu.json @@ -1,23 +1,26 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "link_local_address": "A linkek helyi c\u00edmei nem t\u00e1mogatottak", + "not_axis_device": "A felfedezett eszk\u00f6z nem Axis eszk\u00f6z" }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, - "flow_title": "Axis eszk\u00f6z: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "\u00c1ll\u00edtsa be az Axis eszk\u00f6zt" } } }, @@ -26,7 +29,8 @@ "configure_stream": { "data": { "stream_profile": "V\u00e1lassza ki a haszn\u00e1lni k\u00edv\u00e1nt adatfolyam-profilt" - } + }, + "title": "Axis eszk\u00f6z vide\u00f3 stream opci\u00f3k" } } } diff --git a/homeassistant/components/axis/translations/it.json b/homeassistant/components/axis/translations/it.json index 7e7aeb1d1b261..6bf0c73d403ea 100644 --- a/homeassistant/components/axis/translations/it.json +++ b/homeassistant/components/axis/translations/it.json @@ -28,7 +28,7 @@ "step": { "configure_stream": { "data": { - "stream_profile": "Selezionare il profilo di flusso da utilizzare" + "stream_profile": "Seleziona il profilo di flusso da utilizzare" }, "title": "Opzioni del flusso video del dispositivo Axis" } diff --git a/homeassistant/components/axis/translations/ja.json b/homeassistant/components/axis/translations/ja.json new file mode 100644 index 0000000000000..bb9fa910122e1 --- /dev/null +++ b/homeassistant/components/axis/translations/ja.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "link_local_address": "\u30ed\u30fc\u30ab\u30eb\u30a2\u30c9\u30ec\u30b9\u306e\u30ea\u30f3\u30af\u306b\u306f\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093", + "not_axis_device": "\u691c\u51fa\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306fAxis\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Axis\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "\u4f7f\u7528\u3059\u308b\u30b9\u30c8\u30ea\u30fc\u30e0\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u9078\u629e" + }, + "title": "Axis\u30c7\u30d0\u30a4\u30b9\u306e\u30d3\u30c7\u30aa\u30b9\u30c8\u30ea\u30fc\u30e0\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/translations/tr.json b/homeassistant/components/axis/translations/tr.json index b2d609747d142..77a4c59e08b55 100644 --- a/homeassistant/components/axis/translations/tr.json +++ b/homeassistant/components/axis/translations/tr.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "link_local_address": "Ba\u011flant\u0131 yerel adresleri desteklenmiyor", + "not_axis_device": "Bulunan cihaz bir Axis cihaz\u0131 de\u011fil" }, "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", @@ -9,14 +11,26 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Axis cihaz\u0131n\u0131 kurun" + } + } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "Kullan\u0131lacak ak\u0131\u015f profilini se\u00e7in" + }, + "title": "Axis cihaz\u0131 video ak\u0131\u015f\u0131 se\u00e7enekleri" } } } diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index ba9020e3e8812..213dc19ff9eee 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -1,48 +1,86 @@ """Support for Azure DevOps.""" from __future__ import annotations +from dataclasses import dataclass +from datetime import timedelta import logging +from typing import Final +from aioazuredevops.builds import DevOpsBuild from aioazuredevops.client import DevOpsClient +from aioazuredevops.core import DevOpsProject import aiohttp -from homeassistant.components.azure_devops.const import ( - CONF_ORG, - CONF_PAT, - CONF_PROJECT, - DATA_AZURE_DEVOPS_CLIENT, - DOMAIN, -) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] + +BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +@dataclass +class AzureDevOpsEntityDescription(EntityDescription): + """Class describing Azure DevOps entities.""" + + organization: str = "" + project: DevOpsProject = None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Azure DevOps from a config entry.""" client = DevOpsClient() - try: - if entry.data[CONF_PAT] is not None: - await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) - if not client.authorized: - raise ConfigEntryAuthFailed( - "Could not authorize with Azure DevOps. You may need to update your token" - ) - await client.get_project(entry.data[CONF_ORG], entry.data[CONF_PROJECT]) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - raise ConfigEntryNotReady from exception - - instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" - hass.data.setdefault(instance_key, {})[DATA_AZURE_DEVOPS_CLIENT] = client - - # Setup components + if entry.data.get(CONF_PAT) is not None: + await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) + if not client.authorized: + raise ConfigEntryAuthFailed( + "Could not authorize with Azure DevOps. You will need to update your token" + ) + + project = await client.get_project( + entry.data[CONF_ORG], + entry.data[CONF_PROJECT], + ) + + async def async_update_data() -> list[DevOpsBuild]: + """Fetch data from Azure DevOps.""" + + try: + return await client.get_builds( + entry.data[CONF_ORG], + entry.data[CONF_PROJECT], + BUILDS_QUERY, + ) + except (aiohttp.ClientError, aiohttp.ClientError) as exception: + raise UpdateFailed from exception + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{DOMAIN}_coordinator", + update_method=async_update_data, + update_interval=timedelta(seconds=300), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator, project + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -50,52 +88,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Azure DevOps config entry.""" - del hass.data[f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}"] + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - -class AzureDevOpsEntity(Entity): +class AzureDevOpsEntity(CoordinatorEntity): """Defines a base Azure DevOps entity.""" - def __init__(self, organization: str, project: str, name: str, icon: str) -> None: - """Initialize the Azure DevOps entity.""" - self._name = name - self._icon = icon - self._available = True - self.organization = organization - self.project = project - - @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 + coordinator: DataUpdateCoordinator[list[DevOpsBuild]] + entity_description: AzureDevOpsEntityDescription - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - async def async_update(self) -> None: - """Update Azure DevOps entity.""" - if await self._azure_devops_update(): - self._available = True - else: - if self._available: - _LOGGER.debug( - "An error occurred while updating Azure DevOps sensor", - exc_info=True, - ) - self._available = False - - async def _azure_devops_update(self) -> None: - """Update Azure DevOps entity.""" - raise NotImplementedError() + def __init__( + self, + coordinator: DataUpdateCoordinator[list[DevOpsBuild]], + entity_description: AzureDevOpsEntityDescription, + ) -> None: + """Initialize the Azure DevOps entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id: str = "_".join( + [entity_description.organization, entity_description.key] + ) + self._organization: str = entity_description.organization + self._project_name: str = entity_description.project.name class AzureDevOpsDeviceEntity(AzureDevOpsEntity): @@ -104,15 +121,9 @@ class AzureDevOpsDeviceEntity(AzureDevOpsEntity): @property def device_info(self) -> DeviceInfo: """Return device information about this Azure DevOps instance.""" - return { - "identifiers": { - ( - DOMAIN, - self.organization, - self.project, - ) - }, - "manufacturer": self.organization, - "name": self.project, - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._organization, self._project_name)}, # type: ignore + manufacturer=self._organization, + name=self._project_name, + ) diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py index 300730311955f..350bad5852a40 100644 --- a/homeassistant/components/azure_devops/config_flow.py +++ b/homeassistant/components/azure_devops/config_flow.py @@ -3,14 +3,10 @@ import aiohttp import voluptuous as vol -from homeassistant.components.azure_devops.const import ( - CONF_ORG, - CONF_PAT, - CONF_PROJECT, - DOMAIN, -) from homeassistant.config_entries import ConfigFlow +from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN + class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Azure DevOps config flow.""" diff --git a/homeassistant/components/azure_devops/const.py b/homeassistant/components/azure_devops/const.py index 40610ba7baa57..adaf5ebe76755 100644 --- a/homeassistant/components/azure_devops/const.py +++ b/homeassistant/components/azure_devops/const.py @@ -1,11 +1,6 @@ """Constants for the Azure DevOps integration.""" DOMAIN = "azure_devops" -DATA_AZURE_DEVOPS_CLIENT = "azure_devops_client" -DATA_ORG = "organization" -DATA_PROJECT = "project" -DATA_PAT = "personal_access_token" - CONF_ORG = "organization" CONF_PROJECT = "project" CONF_PAT = "personal_access_token" diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index ef6697dea5fa5..ac884f73d6820 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -1,150 +1,93 @@ """Support for Azure DevOps sensors.""" from __future__ import annotations -from datetime import timedelta -import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any from aioazuredevops.builds import DevOpsBuild -from aioazuredevops.client import DevOpsClient -import aiohttp - -from homeassistant.components.azure_devops import AzureDevOpsDeviceEntity -from homeassistant.components.azure_devops.const import ( - CONF_ORG, - CONF_PROJECT, - DATA_AZURE_DEVOPS_CLIENT, - DATA_ORG, - DATA_PROJECT, - DOMAIN, -) -from homeassistant.components.sensor import SensorEntity + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import AzureDevOpsDeviceEntity, AzureDevOpsEntityDescription +from .const import CONF_ORG, DOMAIN + -_LOGGER = logging.getLogger(__name__) +@dataclass +class AzureDevOpsSensorEntityDescriptionMixin: + """Mixin class for required Azure DevOps sensor description keys.""" -SCAN_INTERVAL = timedelta(seconds=300) -PARALLEL_UPDATES = 4 + build_key: int -BUILDS_QUERY = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + +@dataclass +class AzureDevOpsSensorEntityDescription( + AzureDevOpsEntityDescription, + SensorEntityDescription, + AzureDevOpsSensorEntityDescriptionMixin, +): + """Class describing Azure DevOps sensor entities.""" + + attrs: Callable[[DevOpsBuild], Any] = round + value: Callable[[DevOpsBuild], StateType] = round async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Azure DevOps sensor based on a config entry.""" - instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" - client = hass.data[instance_key][DATA_AZURE_DEVOPS_CLIENT] - organization = entry.data[DATA_ORG] - project = entry.data[DATA_PROJECT] - sensors = [] - - try: - builds: list[DevOpsBuild] = await client.get_builds( - organization, project, BUILDS_QUERY - ) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - raise PlatformNotReady from exception - - for build in builds: - sensors.append( - AzureDevOpsLatestBuildSensor(client, organization, project, build) + coordinator, project = hass.data[DOMAIN][entry.entry_id] + + sensors = [ + AzureDevOpsSensor( + coordinator, + AzureDevOpsSensorEntityDescription( + key=f"{build.project.id}_{build.definition.id}_latest_build", + name=f"{build.project.name} {build.definition.name} Latest Build", + icon="mdi:pipe", + attrs=lambda build: { + "definition_id": build.definition.id, + "definition_name": build.definition.name, + "id": build.id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + }, + build_key=key, + organization=entry.data[CONF_ORG], + project=project, + value=lambda build: build.build_number, + ), ) + for key, build in enumerate(coordinator.data) + ] async_add_entities(sensors, True) class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): - """Defines a Azure DevOps sensor.""" - - def __init__( - self, - client: DevOpsClient, - organization: str, - project: str, - key: str, - name: str, - icon: str, - measurement: str = "", - unit_of_measurement: str = "", - ) -> None: - """Initialize Azure DevOps sensor.""" - self._state = None - self._attributes = None - self._available = False - self._unit_of_measurement = unit_of_measurement - self.measurement = measurement - self.client = client - self.organization = organization - self.project = project - self.key = key - - super().__init__(organization, project, name, icon) - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return "_".join([self.organization, self.key]) + """Define a Azure DevOps sensor.""" - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self._state + entity_description: AzureDevOpsSensorEntityDescription @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - return self._attributes + def native_value(self) -> StateType: + """Return the state.""" + build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] + return self.entity_description.value(build) @property - def unit_of_measurement(self) -> str: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): - """Defines a Azure DevOps card count sensor.""" - - def __init__( - self, client: DevOpsClient, organization: str, project: str, build: DevOpsBuild - ): - """Initialize Azure DevOps sensor.""" - self.build: DevOpsBuild = build - super().__init__( - client, - organization, - project, - f"{build.project.id}_{build.definition.id}_latest_build", - f"{build.project.name} {build.definition.name} Latest Build", - "mdi:pipe", - ) - - async def _azure_devops_update(self) -> bool: - """Update Azure DevOps entity.""" - try: - build: DevOpsBuild = await self.client.get_build( - self.organization, self.project, self.build.id - ) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - self._available = False - return False - self._state = build.build_number - self._attributes = { - "definition_id": build.definition.id, - "definition_name": build.definition.name, - "id": build.id, - "reason": build.reason, - "result": build.result, - "source_branch": build.source_branch, - "source_version": build.source_version, - "status": build.status, - "url": build.links.web, - "queue_time": build.queue_time, - "start_time": build.start_time, - "finish_time": build.finish_time, - } - self._available = True - return True + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the entity.""" + build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] + return self.entity_description.attrs(build) diff --git a/homeassistant/components/azure_devops/translations/bg.json b/homeassistant/components/azure_devops/translations/bg.json new file mode 100644 index 0000000000000..60c6d07d01339 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "flow_title": "{project_url}", + "step": { + "user": { + "data": { + "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/ca.json b/homeassistant/components/azure_devops/translations/ca.json index 92dbd2e3e4019..e6811e54078b1 100644 --- a/homeassistant/components/azure_devops/translations/ca.json +++ b/homeassistant/components/azure_devops/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { @@ -9,7 +9,7 @@ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "project_error": "No s'ha pogut obtenir la informaci\u00f3 del projecte." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/de.json b/homeassistant/components/azure_devops/translations/de.json index 43a5776da2e94..eabc88625fb8e 100644 --- a/homeassistant/components/azure_devops/translations/de.json +++ b/homeassistant/components/azure_devops/translations/de.json @@ -9,7 +9,7 @@ "invalid_auth": "Ung\u00fcltige Authentifizierung", "project_error": "Konnte keine Projektinformationen erhalten." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/es-419.json b/homeassistant/components/azure_devops/translations/es-419.json new file mode 100644 index 0000000000000..7ac7d2a930d37 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/es-419.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "project_error": "No se pudo obtener la informaci\u00f3n del proyecto." + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token de acceso personal (PAT)" + }, + "description": "Error de autenticaci\u00f3n para {project_url}. Ingrese sus credenciales actuales.", + "title": "Reautenticaci\u00f3n" + }, + "user": { + "data": { + "organization": "Organizaci\u00f3n", + "personal_access_token": "Token de acceso personal (PAT)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/et.json b/homeassistant/components/azure_devops/translations/et.json index 63ec0276d895a..58931e3fd37d6 100644 --- a/homeassistant/components/azure_devops/translations/et.json +++ b/homeassistant/components/azure_devops/translations/et.json @@ -9,7 +9,7 @@ "invalid_auth": "Tuvastamise viga", "project_error": "Projekti teavet ei \u00f5nnestunud hankida." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/fr.json b/homeassistant/components/azure_devops/translations/fr.json index 5e62d54ec1d44..17bc61041127c 100644 --- a/homeassistant/components/azure_devops/translations/fr.json +++ b/homeassistant/components/azure_devops/translations/fr.json @@ -2,14 +2,14 @@ "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "project_error": "Impossible d'obtenir les informations sur le projet." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/he.json b/homeassistant/components/azure_devops/translations/he.json new file mode 100644 index 0000000000000..7dc658061623f --- /dev/null +++ b/homeassistant/components/azure_devops/translations/he.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{project_url}" + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/hu.json b/homeassistant/components/azure_devops/translations/hu.json index 460b613204851..2d8879b9d68e1 100644 --- a/homeassistant/components/azure_devops/translations/hu.json +++ b/homeassistant/components/azure_devops/translations/hu.json @@ -2,17 +2,29 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "project_error": "Nem siker\u00fclt lek\u00e9rni a projekt adatait." }, + "flow_title": "{project_url}", "step": { "reauth": { - "description": "A(z) {project_url} hiteles\u00edt\u00e9se nem siker\u00fclt. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait." + "data": { + "personal_access_token": "Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token (PAT)" + }, + "description": "{project_url} hiteles\u00edt\u00e9se nem siker\u00fclt. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait.", + "title": "\u00dajrahiteles\u00edt\u00e9s" }, "user": { + "data": { + "organization": "Szervezet", + "personal_access_token": "Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token (PAT)", + "project": "Projekt" + }, + "description": "\u00c1ll\u00edtson be egy Azure DevOps-p\u00e9ld\u00e1nyt a projekt el\u00e9r\u00e9s\u00e9hez. Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token csak mag\u00e1nprojekthez sz\u00fcks\u00e9ges.", "title": "Azure DevOps Project hozz\u00e1ad\u00e1sa" } } diff --git a/homeassistant/components/azure_devops/translations/id.json b/homeassistant/components/azure_devops/translations/id.json index 42292805b0882..bad7c022b934f 100644 --- a/homeassistant/components/azure_devops/translations/id.json +++ b/homeassistant/components/azure_devops/translations/id.json @@ -9,7 +9,7 @@ "invalid_auth": "Autentikasi tidak valid", "project_error": "Tidak bisa mendapatkan info proyek." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/it.json b/homeassistant/components/azure_devops/translations/it.json index 4b2f5e0efae8d..232264d1026b0 100644 --- a/homeassistant/components/azure_devops/translations/it.json +++ b/homeassistant/components/azure_devops/translations/it.json @@ -9,14 +9,14 @@ "invalid_auth": "Autenticazione non valida", "project_error": "Non \u00e8 stato possibile ottenere informazioni sul progetto." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { - "personal_access_token": "Token di Accesso Personale (PAT)" + "personal_access_token": "Token di accesso personale (PAT)" }, - "description": "Autenticazione non riuscita per {project_url}. Si prega di inserire le proprie credenziali attuali.", - "title": "Riautenticazione" + "description": "Autenticazione non riuscita per {project_url}. Digita le tue credenziali attuali.", + "title": "Nuova autenticazione" }, "user": { "data": { @@ -24,7 +24,7 @@ "personal_access_token": "Token di Accesso Personale (PAT)", "project": "Progetto" }, - "description": "Configurare un'istanza di DevOps di Azure per accedere al progetto. Un Token di Accesso Personale (PAT) \u00e8 richiesto solo per un progetto privato.", + "description": "Configura un'istanza di DevOps di Azure per accedere al progetto. Un Token di Accesso Personale (PAT) \u00e8 richiesto solo per un progetto privato.", "title": "Aggiungere un progetto Azure DevOps" } } diff --git a/homeassistant/components/azure_devops/translations/ja.json b/homeassistant/components/azure_devops/translations/ja.json new file mode 100644 index 0000000000000..659caaa931777 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "project_error": "\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u60c5\u5831\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u30d1\u30fc\u30bd\u30ca\u30eb \u30a2\u30af\u30bb\u30b9 \u30c8\u30fc\u30af\u30f3(PAT)" + }, + "description": "{project_url} \u306e\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u73fe\u5728\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "organization": "\u7d44\u7e54", + "personal_access_token": "\u30d1\u30fc\u30bd\u30ca\u30eb \u30a2\u30af\u30bb\u30b9 \u30c8\u30fc\u30af\u30f3(PAT)", + "project": "\u30d7\u30ed\u30b8\u30a7\u30af\u30c8" + }, + "description": "\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u305f\u3081\u306bAzureDevOps\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u30d1\u30fc\u30bd\u30ca\u30eb\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u306f\u3001\u30d7\u30e9\u30a4\u30d9\u30fc\u30c8\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u5834\u5408\u306e\u307f\u5fc5\u8981\u3067\u3059\u3002", + "title": "Azure DevOps\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u8ffd\u52a0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/nl.json b/homeassistant/components/azure_devops/translations/nl.json index 971af5b8d588b..a57dd85c495ce 100644 --- a/homeassistant/components/azure_devops/translations/nl.json +++ b/homeassistant/components/azure_devops/translations/nl.json @@ -9,7 +9,7 @@ "invalid_auth": "Ongeldige authenticatie", "project_error": "Kon geen projectinformatie ophalen." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/no.json b/homeassistant/components/azure_devops/translations/no.json index 50ee7a7a2a935..ba4ff946595c2 100644 --- a/homeassistant/components/azure_devops/translations/no.json +++ b/homeassistant/components/azure_devops/translations/no.json @@ -9,7 +9,7 @@ "invalid_auth": "Ugyldig godkjenning", "project_error": "Kunne ikke f\u00e5 prosjektinformasjon." }, - "flow_title": "", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/pl.json b/homeassistant/components/azure_devops/translations/pl.json index 18c4080a69660..e285af979bdb3 100644 --- a/homeassistant/components/azure_devops/translations/pl.json +++ b/homeassistant/components/azure_devops/translations/pl.json @@ -9,7 +9,7 @@ "invalid_auth": "Niepoprawne uwierzytelnienie", "project_error": "Nie mo\u017cna uzyska\u0107 informacji o projekcie" }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_devops/translations/ru.json b/homeassistant/components/azure_devops/translations/ru.json index 4e59af2dd11a7..5fe2aa58b5cf1 100644 --- a/homeassistant/components/azure_devops/translations/ru.json +++ b/homeassistant/components/azure_devops/translations/ru.json @@ -9,7 +9,7 @@ "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "project_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0435." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { @@ -24,7 +24,7 @@ "personal_access_token": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (PAT)", "project": "\u041f\u0440\u043e\u0435\u043a\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c Azure DevOps. \u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u0432.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c Azure DevOps. \u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u0432.", "title": "Azure DevOps" } } diff --git a/homeassistant/components/azure_devops/translations/tr.json b/homeassistant/components/azure_devops/translations/tr.json index 11a15956f635b..407d0aafdbbd5 100644 --- a/homeassistant/components/azure_devops/translations/tr.json +++ b/homeassistant/components/azure_devops/translations/tr.json @@ -9,9 +9,13 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "project_error": "Proje bilgileri al\u0131namad\u0131." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { + "data": { + "personal_access_token": "Ki\u015fisel Eri\u015fim Anahtar\u0131 (PAT)" + }, + "description": "{project_url} i\u00e7in kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu. L\u00fctfen mevcut kimlik bilgilerinizi girin.", "title": "Yeniden kimlik do\u011frulama" }, "user": { diff --git a/homeassistant/components/azure_devops/translations/zh-Hans.json b/homeassistant/components/azure_devops/translations/zh-Hans.json index b0c629646e289..d6a6e62e27c28 100644 --- a/homeassistant/components/azure_devops/translations/zh-Hans.json +++ b/homeassistant/components/azure_devops/translations/zh-Hans.json @@ -1,8 +1,32 @@ { "config": { + "abort": { + "already_configured": "\u8d26\u6237\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", - "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" + "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548", + "project_error": "\u65e0\u6cd5\u83b7\u53d6\u9879\u76ee\u4fe1\u606f\u3002" + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c (PAT)" + }, + "description": "{project_url} \u8eab\u4efd\u9a8c\u8bc1\u5931\u8d25\u3002\u8bf7\u8f93\u5165\u60a8\u5f53\u524d\u7684\u51ed\u636e\u3002", + "title": "\u91cd\u9a8c\u8bc1" + }, + "user": { + "data": { + "organization": "\u7ec4\u7ec7", + "personal_access_token": "\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c (PAT)", + "project": "\u9879\u76ee" + }, + "description": "\u8bbe\u7f6e Azure DevOps \u5b9e\u4f8b\u4ee5\u8bbf\u95ee\u60a8\u7684\u9879\u76ee\u3002\u79c1\u4eba\u9879\u76ee\u624d\u9700\u8981\u63d0\u4f9b\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c\u3002", + "title": "\u6dfb\u52a0 Azure DevOps \u9879\u76ee" + } } } } \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/zh-Hant.json b/homeassistant/components/azure_devops/translations/zh-Hant.json index b77ce8c54a762..58ac777f34a82 100644 --- a/homeassistant/components/azure_devops/translations/zh-Hant.json +++ b/homeassistant/components/azure_devops/translations/zh-Hant.json @@ -9,11 +9,11 @@ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "project_error": "\u7121\u6cd5\u53d6\u5f97\u5c08\u6848\u8cc7\u8a0a\u3002" }, - "flow_title": "Azure DevOps\uff1a{project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { - "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff08PAT\uff09" + "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u91d1\u9470\uff08PAT\uff09" }, "description": "{project_url}\u8a8d\u8b49\u5931\u6557\u3002\u8acb\u8f38\u5165\u76ee\u524d\u8b49\u66f8\u3002", "title": "\u91cd\u65b0\u8a8d\u8b49" @@ -21,10 +21,10 @@ "user": { "data": { "organization": "\u7d44\u7e54", - "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff08PAT\uff09", + "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u91d1\u9470\uff08PAT\uff09", "project": "\u5c08\u6848" }, - "description": "\u8a2d\u5b9a Azure DevOps \u4ee5\u5b58\u53d6\u5c08\u6848\u3002\u79c1\u4eba\u5c08\u6848\u5247\u9700\u8981\u8f38\u5165\u300c\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff09\u3002", + "description": "\u8a2d\u5b9a Azure DevOps \u4ee5\u5b58\u53d6\u5c08\u6848\u3002\u79c1\u4eba\u5c08\u6848\u5247\u9700\u8981\u8f38\u5165\u300c\u500b\u4eba\u5b58\u53d6\u91d1\u9470\uff09\u3002", "title": "\u65b0\u589e Azure DevOps \u5c08\u6848" } } diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 0473c4ff5a755..71d4d7c2cc4e5 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -2,30 +2,29 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from datetime import datetime import json import logging -import time from typing import Any -from azure.eventhub import EventData -from azure.eventhub.aio import EventHubProducerClient, EventHubSharedKeyCredential +from azure.eventhub import EventData, EventDataBatch +from azure.eventhub.aio import EventHubProducerClient from azure.eventhub.exceptions import EventHubError import voluptuous as vol -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, - MATCH_ALL, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady +from homeassistant.const import MATCH_ALL +from homeassistant.core import Event, HomeAssistant, State import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.event import async_call_later from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import utcnow +from .client import AzureEventHubClient from .const import ( - ADDITIONAL_ARGS, CONF_EVENT_HUB_CON_STRING, CONF_EVENT_HUB_INSTANCE_NAME, CONF_EVENT_HUB_NAMESPACE, @@ -34,7 +33,11 @@ CONF_FILTER, CONF_MAX_DELAY, CONF_SEND_INTERVAL, + DATA_FILTER, + DATA_HUB, + DEFAULT_MAX_DELAY, DOMAIN, + FILTER_STATES, ) _LOGGER = logging.getLogger(__name__) @@ -43,51 +46,76 @@ { DOMAIN: vol.Schema( { - vol.Exclusive(CONF_EVENT_HUB_CON_STRING, "setup_methods"): cv.string, - vol.Exclusive(CONF_EVENT_HUB_NAMESPACE, "setup_methods"): cv.string, vol.Optional(CONF_EVENT_HUB_INSTANCE_NAME): cv.string, + vol.Optional(CONF_EVENT_HUB_CON_STRING): cv.string, + vol.Optional(CONF_EVENT_HUB_NAMESPACE): cv.string, vol.Optional(CONF_EVENT_HUB_SAS_POLICY): cv.string, vol.Optional(CONF_EVENT_HUB_SAS_KEY): cv.string, - vol.Optional(CONF_SEND_INTERVAL, default=5): cv.positive_int, - vol.Optional(CONF_MAX_DELAY, default=30): cv.positive_int, + vol.Optional(CONF_SEND_INTERVAL): cv.positive_int, + vol.Optional(CONF_MAX_DELAY): cv.positive_int, vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, }, - cv.has_at_least_one_key( - CONF_EVENT_HUB_CON_STRING, CONF_EVENT_HUB_NAMESPACE - ), ) }, extra=vol.ALLOW_EXTRA, ) -async def async_setup(hass, yaml_config): - """Activate Azure EH component.""" - config = yaml_config[DOMAIN] - if config.get(CONF_EVENT_HUB_CON_STRING): - client_args = {"conn_str": config[CONF_EVENT_HUB_CON_STRING]} - conn_str_client = True - else: - client_args = { - "fully_qualified_namespace": f"{config[CONF_EVENT_HUB_NAMESPACE]}.servicebus.windows.net", - "credential": EventHubSharedKeyCredential( - policy=config[CONF_EVENT_HUB_SAS_POLICY], - key=config[CONF_EVENT_HUB_SAS_KEY], - ), - "eventhub_name": config[CONF_EVENT_HUB_INSTANCE_NAME], - } - conn_str_client = False - - instance = hass.data[DOMAIN] = AzureEventHub( +async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: + """Activate Azure EH component from yaml. + + Adds an empty filter to hass data. + Tries to get a filter from yaml, if present set to hass data. + If config is empty after getting the filter, return, otherwise emit + deprecated warning and pass the rest to the config flow. + """ + hass.data.setdefault(DOMAIN, {DATA_FILTER: FILTER_SCHEMA({})}) + if DOMAIN not in yaml_config: + return True + hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN].pop(CONF_FILTER) + + if not yaml_config[DOMAIN]: + return True + _LOGGER.warning( + "Loading Azure Event Hub completely via yaml config is deprecated; Only the \ + Filter can be set in yaml, the rest is done through a config flow and has \ + been imported, all other keys but filter can be deleted from configuration.yaml" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=yaml_config[DOMAIN] + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Do the setup based on the config entry and the filter from yaml.""" + hass.data.setdefault(DOMAIN, {DATA_FILTER: FILTER_SCHEMA({})}) + hub = AzureEventHub( hass, - client_args, - conn_str_client, - config[CONF_FILTER], - config[CONF_SEND_INTERVAL], - config[CONF_MAX_DELAY], + entry, + hass.data[DOMAIN][DATA_FILTER], ) + try: + await hub.async_test_connection() + except EventHubError as err: + raise ConfigEntryNotReady("Could not connect to Azure Event Hub") from err + hass.data[DOMAIN][DATA_HUB] = hub + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + await hub.async_start() + return True + - hass.async_create_task(instance.async_start()) +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener for options.""" + hass.data[DOMAIN][DATA_HUB].update_options(entry.options) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hub = hass.data[DOMAIN].pop(DATA_HUB) + await hub.async_stop() return True @@ -97,129 +125,128 @@ class AzureEventHub: def __init__( self, hass: HomeAssistant, - client_args: dict[str, Any], - conn_str_client: bool, + entry: ConfigEntry, entities_filter: vol.Schema, - send_interval: int, - max_delay: int, - ): + ) -> None: """Initialize the listener.""" self.hass = hass - self.queue = asyncio.PriorityQueue() - self._client_args = client_args - self._conn_str_client = conn_str_client + self._entry = entry self._entities_filter = entities_filter - self._send_interval = send_interval - self._max_delay = max_delay + send_interval - self._listener_remover = None - self._next_send_remover = None - self.shutdown = False - - async def async_start(self): - """Start the recorder, suppress logging and register the callbacks and do the first send after five seconds, to capture the startup events.""" - # suppress the INFO and below logging on the underlying packages, they are very verbose, even at INFO + + self._client = AzureEventHubClient.from_input(**self._entry.data) + self._send_interval = self._entry.options[CONF_SEND_INTERVAL] + self._max_delay = self._entry.options.get(CONF_MAX_DELAY, DEFAULT_MAX_DELAY) + + self._shutdown = False + self._queue: asyncio.PriorityQueue[ # pylint: disable=unsubscriptable-object + tuple[int, tuple[datetime, State | None]] + ] = asyncio.PriorityQueue() + self._listener_remover: Callable[[], None] | None = None + self._next_send_remover: Callable[[], None] | None = None + + async def async_start(self) -> None: + """Start the hub. + + This suppresses logging and register the listener and + schedules the first send. + + Suppress the INFO and below logging on the underlying packages, + they are very verbose, even at INFO. + """ logging.getLogger("uamqp").setLevel(logging.WARNING) logging.getLogger("azure.eventhub").setLevel(logging.WARNING) - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_shutdown) self._listener_remover = self.hass.bus.async_listen( MATCH_ALL, self.async_listen ) - # schedule the first send after 10 seconds to capture startup events, after that each send will schedule the next after the interval. - self._next_send_remover = async_call_later(self.hass, 10, self.async_send) + self._schedule_next_send() - async def async_shutdown(self, _: Event): - """Shut down the AEH by queueing None and calling send.""" + async def async_stop(self) -> None: + """Shut down the AEH by queueing None, calling send, join queue.""" if self._next_send_remover: self._next_send_remover() if self._listener_remover: self._listener_remover() - await self.queue.put((3, (time.monotonic(), None))) + await self._queue.put((3, (utcnow(), None))) await self.async_send(None) + await self._queue.join() + + def update_options(self, new_options: dict[str, Any]) -> None: + """Update options.""" + self._send_interval = new_options[CONF_SEND_INTERVAL] + + async def async_test_connection(self) -> None: + """Test the connection to the event hub.""" + await self._client.test_connection() - async def async_listen(self, event: Event): + def _schedule_next_send(self) -> None: + """Schedule the next send.""" + if not self._shutdown: + self._next_send_remover = async_call_later( + self.hass, self._send_interval, self.async_send + ) + + async def async_listen(self, event: Event) -> None: """Listen for new messages on the bus and queue them for AEH.""" - await self.queue.put((2, (time.monotonic(), event))) + if state := event.data.get("new_state"): + await self._queue.put((2, (event.time_fired, state))) - async def async_send(self, _): + async def async_send(self, _) -> None: """Write preprocessed events to eventhub, with retry.""" - client = self._get_client() - async with client: - while not self.queue.empty(): - data_batch, dequeue_count = await self.fill_batch(client) - _LOGGER.debug( - "Sending %d event(s), out of %d events in the queue", - len(data_batch), - dequeue_count, - ) - if data_batch: + async with self._client.client as client: + while not self._queue.empty(): + if event_batch := await self.fill_batch(client): + _LOGGER.debug("Sending %d event(s)", len(event_batch)) try: - await client.send_batch(data_batch) + await client.send_batch(event_batch) except EventHubError as exc: _LOGGER.error("Error in sending events to Event Hub: %s", exc) - finally: - for _ in range(dequeue_count): - self.queue.task_done() - await client.close() + self._schedule_next_send() - if not self.shutdown: - self._next_send_remover = async_call_later( - self.hass, self._send_interval, self.async_send - ) - - async def fill_batch(self, client): - """Return a batch of events formatted for writing. + async def fill_batch(self, client: EventHubProducerClient) -> EventDataBatch: + """Return a batch of events formatted for sending to Event Hub. - Uses get_nowait instead of await get, because the functions batches and doesn't wait for each single event, the send function is called. + Uses get_nowait instead of await get, because the functions batches and + doesn't wait for each single event. - Throws ValueError on add to batch when the EventDataBatch object reaches max_size. Put the item back in the queue and the next batch will include it. + Throws ValueError on add to batch when the EventDataBatch object reaches + max_size. Put the item back in the queue and the next batch will include + it. """ event_batch = await client.create_batch() - dequeue_count = 0 dropped = 0 - while not self.shutdown: + while not self._shutdown: try: - _, (timestamp, event) = self.queue.get_nowait() + _, event = self._queue.get_nowait() except asyncio.QueueEmpty: break - dequeue_count += 1 - if not event: - self.shutdown = True - break - event_data = self._event_to_filtered_event_data(event) + event_data, dropped = self._parse_event(*event, dropped) if not event_data: continue - if time.monotonic() - timestamp <= self._max_delay: - try: - event_batch.add(event_data) - except ValueError: - self.queue.put_nowait((1, (timestamp, event))) - break - else: - dropped += 1 + try: + event_batch.add(event_data) + except ValueError: + self._queue.put_nowait((1, event)) + break if dropped: _LOGGER.warning( - "Dropped %d old events, consider increasing the max_delay", dropped - ) - - return event_batch, dequeue_count - - def _event_to_filtered_event_data(self, event: Event): - """Filter event states and create EventData object.""" - state = event.data.get("new_state") - if ( - state is None - or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) - or not self._entities_filter(state.entity_id) - ): - return None - return EventData(json.dumps(obj=state, cls=JSONEncoder).encode("utf-8")) - - def _get_client(self): - """Get a Event Producer Client.""" - if self._conn_str_client: - return EventHubProducerClient.from_connection_string( - **self._client_args, **ADDITIONAL_ARGS + "Dropped %d old events, consider filtering messages", dropped ) - return EventHubProducerClient(**self._client_args, **ADDITIONAL_ARGS) + return event_batch + + def _parse_event( + self, time_fired: datetime, state: State | None, dropped: int + ) -> tuple[EventData | None, int]: + """Parse event by checking if it needs to be sent, and format it.""" + self._queue.task_done() + if not state: + self._shutdown = True + return None, dropped + if state.state in FILTER_STATES or not self._entities_filter(state.entity_id): + return None, dropped + if (utcnow() - time_fired).seconds > self._max_delay + self._send_interval: + return None, dropped + 1 + return ( + EventData(json.dumps(obj=state, cls=JSONEncoder).encode("utf-8")), + dropped, + ) diff --git a/homeassistant/components/azure_event_hub/client.py b/homeassistant/components/azure_event_hub/client.py new file mode 100644 index 0000000000000..1a5aa330cc8a2 --- /dev/null +++ b/homeassistant/components/azure_event_hub/client.py @@ -0,0 +1,71 @@ +"""File for Azure Event Hub models.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from azure.eventhub.aio import EventHubProducerClient, EventHubSharedKeyCredential + +from .const import ADDITIONAL_ARGS, CONF_EVENT_HUB_CON_STRING + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AzureEventHubClient: + """Class for the Azure Event Hub client. Use from_input to initialize.""" + + event_hub_instance_name: str + + @property + def client(self) -> EventHubProducerClient: + """Return the client.""" + + async def test_connection(self) -> None: + """Test connection, will throw EventHubError when it cannot connect.""" + async with self.client as client: + await client.get_eventhub_properties() + + @classmethod + def from_input(cls, **kwargs) -> AzureEventHubClient: + """Create the right class.""" + if CONF_EVENT_HUB_CON_STRING in kwargs: + return AzureEventHubClientConnectionString(**kwargs) + return AzureEventHubClientSAS(**kwargs) + + +@dataclass +class AzureEventHubClientConnectionString(AzureEventHubClient): + """Class for Connection String based Azure Event Hub Client.""" + + event_hub_connection_string: str + + @property + def client(self) -> EventHubProducerClient: + """Return the client.""" + return EventHubProducerClient.from_connection_string( + conn_str=self.event_hub_connection_string, + eventhub_name=self.event_hub_instance_name, + **ADDITIONAL_ARGS, + ) + + +@dataclass +class AzureEventHubClientSAS(AzureEventHubClient): + """Class for SAS based Azure Event Hub Client.""" + + event_hub_namespace: str + event_hub_sas_policy: str + event_hub_sas_key: str + + @property + def client(self) -> EventHubProducerClient: + """Get a Event Producer Client.""" + return EventHubProducerClient( + fully_qualified_namespace=f"{self.event_hub_namespace}.servicebus.windows.net", + eventhub_name=self.event_hub_instance_name, + credential=EventHubSharedKeyCredential( # type: ignore + policy=self.event_hub_sas_policy, key=self.event_hub_sas_key + ), + **ADDITIONAL_ARGS, + ) diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py new file mode 100644 index 0000000000000..a0dded5f48779 --- /dev/null +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -0,0 +1,196 @@ +"""Config flow for azure_event_hub integration.""" +from __future__ import annotations + +from copy import deepcopy +import logging +from typing import Any + +from azure.eventhub.exceptions import EventHubError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .client import AzureEventHubClient +from .const import ( + CONF_EVENT_HUB_CON_STRING, + CONF_EVENT_HUB_INSTANCE_NAME, + CONF_EVENT_HUB_NAMESPACE, + CONF_EVENT_HUB_SAS_KEY, + CONF_EVENT_HUB_SAS_POLICY, + CONF_MAX_DELAY, + CONF_SEND_INTERVAL, + CONF_USE_CONN_STRING, + DEFAULT_OPTIONS, + DOMAIN, + STEP_CONN_STRING, + STEP_SAS, + STEP_USER, +) + +_LOGGER = logging.getLogger(__name__) + +BASE_SCHEMA = vol.Schema( + { + vol.Required(CONF_EVENT_HUB_INSTANCE_NAME): str, + vol.Optional(CONF_USE_CONN_STRING, default=False): bool, + } +) + +CONN_STRING_SCHEMA = vol.Schema( + { + vol.Required(CONF_EVENT_HUB_CON_STRING): str, + } +) + +SAS_SCHEMA = vol.Schema( + { + vol.Required(CONF_EVENT_HUB_NAMESPACE): str, + vol.Required(CONF_EVENT_HUB_SAS_POLICY): str, + vol.Required(CONF_EVENT_HUB_SAS_KEY): str, + } +) + + +async def validate_data(data: dict[str, Any]) -> dict[str, str] | None: + """Validate the input.""" + client = AzureEventHubClient.from_input(**data) + try: + await client.test_connection() + except EventHubError: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + return {"base": "unknown"} + return None + + +class AEHConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for azure event hub.""" + + VERSION: int = 1 + + def __init__(self): + """Initialize the config flow.""" + self._data: dict[str, Any] = {} + self._options: dict[str, Any] = deepcopy(DEFAULT_OPTIONS) + self._conn_string: bool | None = None + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return AEHOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial user step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if user_input is None: + return self.async_show_form(step_id=STEP_USER, data_schema=BASE_SCHEMA) + + self._conn_string = user_input.pop(CONF_USE_CONN_STRING) + self._data = user_input + + if self._conn_string: + return await self.async_step_conn_string() + return await self.async_step_sas() + + async def async_step_conn_string( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the connection string steps.""" + errors = await self.async_update_and_validate_data(user_input) + if user_input is None or errors is not None: + return self.async_show_form( + step_id=STEP_CONN_STRING, + data_schema=CONN_STRING_SCHEMA, + errors=errors, + description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + last_step=True, + ) + + return self.async_create_entry( + title=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + data=self._data, + options=self._options, + ) + + async def async_step_sas( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the sas steps.""" + errors = await self.async_update_and_validate_data(user_input) + if user_input is None or errors is not None: + return self.async_show_form( + step_id=STEP_SAS, + data_schema=SAS_SCHEMA, + errors=errors, + description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + last_step=True, + ) + + return self.async_create_entry( + title=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + data=self._data, + options=self._options, + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import config from configuration.yaml.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if CONF_SEND_INTERVAL in import_config: + self._options[CONF_SEND_INTERVAL] = import_config.pop(CONF_SEND_INTERVAL) + if CONF_MAX_DELAY in import_config: + self._options[CONF_MAX_DELAY] = import_config.pop(CONF_MAX_DELAY) + self._data = import_config + errors = await validate_data(self._data) + if errors: + return self.async_abort(reason=errors["base"]) + return self.async_create_entry( + title=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + data=self._data, + options=self._options, + ) + + async def async_update_and_validate_data( + self, user_input: dict[str, Any] | None + ) -> dict[str, str] | None: + """Validate the input.""" + if user_input is None: + return None + self._data.update(user_input) + return await validate_data(self._data) + + +class AEHOptionsFlowHandler(config_entries.OptionsFlow): + """Handle azure event hub options.""" + + def __init__(self, config_entry): + """Initialize AEH options flow.""" + self.config_entry = config_entry + self.options = deepcopy(dict(config_entry.options)) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the AEH options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SEND_INTERVAL, + default=self.options.get(CONF_SEND_INTERVAL), + ): int + } + ), + last_step=True, + ) diff --git a/homeassistant/components/azure_event_hub/const.py b/homeassistant/components/azure_event_hub/const.py index 1786bb5cbf299..8c90b5daaa0ad 100644 --- a/homeassistant/components/azure_event_hub/const.py +++ b/homeassistant/components/azure_event_hub/const.py @@ -1,6 +1,13 @@ """Constants and shared schema for the Azure Event Hub integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN + DOMAIN = "azure_event_hub" +CONF_USE_CONN_STRING = "use_connection_string" CONF_EVENT_HUB_NAMESPACE = "event_hub_namespace" CONF_EVENT_HUB_INSTANCE_NAME = "event_hub_instance_name" CONF_EVENT_HUB_SAS_POLICY = "event_hub_sas_policy" @@ -8,6 +15,18 @@ CONF_EVENT_HUB_CON_STRING = "event_hub_connection_string" CONF_SEND_INTERVAL = "send_interval" CONF_MAX_DELAY = "max_delay" -CONF_FILTER = "filter" +CONF_FILTER = DATA_FILTER = "filter" +DATA_HUB = "hub" + +STEP_USER = "user" +STEP_SAS = "sas" +STEP_CONN_STRING = "conn_string" + +DEFAULT_SEND_INTERVAL: int = 5 +DEFAULT_MAX_DELAY: int = 30 +DEFAULT_OPTIONS: dict[str, Any] = { + CONF_SEND_INTERVAL: DEFAULT_SEND_INTERVAL, +} -ADDITIONAL_ARGS = {"logging_enable": False} +ADDITIONAL_ARGS: dict[str, Any] = {"logging_enable": False} +FILTER_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE, "") diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json index b570f11e28f87..52125b5a79c53 100644 --- a/homeassistant/components/azure_event_hub/manifest.json +++ b/homeassistant/components/azure_event_hub/manifest.json @@ -2,7 +2,8 @@ "domain": "azure_event_hub", "name": "Azure Event Hub", "documentation": "https://www.home-assistant.io/integrations/azure_event_hub", - "requirements": ["azure-eventhub==5.1.0"], + "requirements": ["azure-eventhub==5.5.0"], "codeowners": ["@eavanvalkenburg"], - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "config_flow": true } diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json new file mode 100644 index 0000000000000..a7e714d8442b8 --- /dev/null +++ b/homeassistant/components/azure_event_hub/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your Azure Event Hub integration", + "data": { + "event_hub_instance_name": "Event Hub Instance Name", + "use_connection_string": "Use Connection String" + } + }, + "conn_string": { + "title": "Connection String method", + "description": "Please enter the connection string for: {event_hub_instance_name}", + "data": { + "event_hub_connection_string": "Event Hub Connection String" + } + }, + "sas": { + "title": "SAS Credentials method", + "description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}", + "data": { + "event_hub_namespace": "Event Hub Namespace", + "event_hub_sas_policy": "Event Hub SAS Policy", + "event_hub_sas_key": "Event Hub SAS Key" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "cannot_connect": "Connecting with the credentails from the configuration.yaml failed, please remove from yaml and use the config flow.", + "unknown": "Connecting with the credentails from the configuration.yaml failed with an unknown error, please remove from yaml and use the config flow." + } + }, + "options": { + "step": { + "options": { + "title": "Options for the Azure Event Hub.", + "data": { + "send_interval": "Interval between sending batches to the hub." + } + } + } + } + } \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/bg.json b/homeassistant/components/azure_event_hub/translations/bg.json new file mode 100644 index 0000000000000..d361944ea4e49 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/bg.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430", + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/ca.json b/homeassistant/components/azure_event_hub/translations/ca.json new file mode 100644 index 0000000000000..b3afd0bdb9255 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/ca.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "El servei ja est\u00e0 configurat", + "cannot_connect": "No s'ha pogut connectar amb les credencials de configuration.yaml, elimina el yaml i utilitza el flux de configuraci\u00f3.", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", + "unknown": "S'ha produ\u00eft un error desconegut i no s'ha pogut connectar amb les credencials de configuration.yaml, elimina el yaml i utilitza el flux de configuraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Cadena de connexi\u00f3 d'Event Hub" + }, + "description": "Introdueix la cadena de connexi\u00f3 de: {event_hub_instance_name}", + "title": "M\u00e8tode cadena de connexi\u00f3" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub Namespace", + "event_hub_sas_key": "Clau SAS de l'Event Hub", + "event_hub_sas_policy": "Pol\u00edtica SAS de l'Event Hub" + }, + "description": "Introdueix les credencials SAS (signatura d'acc\u00e9s compartit) de: {event_hub_instance_name}", + "title": "M\u00e8tode de credencials SAS" + }, + "user": { + "data": { + "event_hub_instance_name": "Nom de la inst\u00e0ncia Event Hub", + "use_connection_string": "Utilitza una cadena de connexi\u00f3" + }, + "title": "Configuraci\u00f3 de la integraci\u00f3 Azure Event Hub" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Interval entre enviaments de lots al Hub." + }, + "title": "Opcions d'Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/de.json b/homeassistant/components/azure_event_hub/translations/de.json new file mode 100644 index 0000000000000..4b86c6f902147 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/de.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Der Dienst ist bereits konfiguriert", + "cannot_connect": "Die Verbindung mit den Anmeldeinformationen aus der configuration.yaml ist fehlgeschlagen. Bitte entferne sie aus der yaml und verwende den Konfigurationsablauf.", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", + "unknown": "Die Verbindung mit den Anmeldeinformationen aus der configuration.yaml ist mit einem unbekannten Fehler fehlgeschlagen. Bitte entferne sie aus der yaml und verwende den Konfigurationsablauf." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hub-Verbindungszeichenfolge" + }, + "description": "Bitte gib die Verbindungszeichenfolge ein f\u00fcr: {event_hub_instance_name}", + "title": "Verbindungszeichenfolgenmethode" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub-Namespace", + "event_hub_sas_key": "Event Hub SAS-Schl\u00fcssel", + "event_hub_sas_policy": "Event Hub SAS-Richtlinie" + }, + "description": "Bitte gib die SAS-Anmeldeinformationen (Shared Access Signature) ein f\u00fcr: {event_hub_instance_name}", + "title": "SAS-Anmeldeinformationen-Methode" + }, + "user": { + "data": { + "event_hub_instance_name": "Name der Event Hub-Instanz", + "use_connection_string": "Verbindungszeichenfolge verwenden" + }, + "title": "Einrichten deiner Azure Event Hub-Integration" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Intervall zwischen dem Senden von Batches an den Hub." + }, + "title": "Optionen f\u00fcr den Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/en.json b/homeassistant/components/azure_event_hub/translations/en.json new file mode 100644 index 0000000000000..60f9ea8f36c32 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/en.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "cannot_connect": "Connecting with the credentails from the configuration.yaml failed, please remove from yaml and use the config flow.", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown": "Connecting with the credentails from the configuration.yaml failed with an unknown error, please remove from yaml and use the config flow." + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hub Connection String" + }, + "description": "Please enter the connection string for: {event_hub_instance_name}", + "title": "Connection String method" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub Namespace", + "event_hub_sas_key": "Event Hub SAS Key", + "event_hub_sas_policy": "Event Hub SAS Policy" + }, + "description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}", + "title": "SAS Credentials method" + }, + "user": { + "data": { + "event_hub_instance_name": "Event Hub Instance Name", + "use_connection_string": "Use Connection String" + }, + "title": "Setup your Azure Event Hub integration" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Interval between sending batches to the hub." + }, + "title": "Options for the Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/et.json b/homeassistant/components/azure_event_hub/translations/et.json new file mode 100644 index 0000000000000..3137da288a6aa --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/et.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Teenus on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchenduse loomine faili configuration.yaml mandaadiandmetega eba\u00f5nnestus, eemalda yamlist ja kasuta konfiguratsioonivoogu.", + "single_instance_allowed": "Lubatud on ainult \u00fcks sidumine", + "unknown": "\u00dchenduse loomine faili configuration.yaml mandaadiandmetega nurjus tundmatu vea t\u00f5ttu. Eemalda yamlist ja kasuta konfiguratsioonivoogu." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hubi \u00fchendusstring" + }, + "description": "Sisesta \u00fchenduse string: {event_hub_instance_name}", + "title": "\u00dchendusstringi meetod" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hubi nimeruum", + "event_hub_sas_key": "Event Hub SAS v\u00f5ti", + "event_hub_sas_policy": "Event Hub SAS-i eeskirjad" + }, + "description": "Sisesta SAS-i (jagatud juurdep\u00e4\u00e4su allkiri) mandaat: {event_hub_instance_name}", + "title": "SAS mandaatide meetod" + }, + "user": { + "data": { + "event_hub_instance_name": "Event Hubi \u00fcksuse nimi", + "use_connection_string": "Kasuta \u00fchendusstringi" + }, + "title": "Seadista oma Azure Event Hubi sidumine" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Intervall partiide jaoturisse saatmise vahel." + }, + "title": "Azure Event Hubi suvandid." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/fr.json b/homeassistant/components/azure_event_hub/translations/fr.json new file mode 100644 index 0000000000000..317479bdf95fc --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/fr.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "Impossible de se connecter", + "unknown": "Erreur inattendue" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/he.json b/homeassistant/components/azure_event_hub/translations/he.json new file mode 100644 index 0000000000000..cf4b00f2264c5 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/he.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/hu.json b/homeassistant/components/azure_event_hub/translations/hu.json new file mode 100644 index 0000000000000..21b6a4ba63fc4 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/hu.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "A csatlakoz\u00e1s a configuration.yaml-ben szerepl\u0151 hiteles\u00edt\u0151 adatokkal nem siker\u00fclt, k\u00e9rj\u00fck, t\u00e1vol\u00edtsa el ezeket, \u00e9s haszn\u00e1lja a kezel\u0151 fel\u00fcletet a konfigur\u00e1l\u00e1shoz.", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", + "unknown": "A csatlakoz\u00e1s a configuration.yaml-ben szerepl\u0151 hiteles\u00edt\u0151 adatokkal nem siker\u00fclt, k\u00e9rj\u00fck, t\u00e1vol\u00edtsa el ezeket, \u00e9s haszn\u00e1lja a kezel\u0151 fel\u00fcletet a konfigur\u00e1l\u00e1shoz." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hub csatlakoz\u00e1si karaktersor" + }, + "description": "K\u00e9rj\u00fck, adja meg a csatlakoz\u00e1si karaktersort ehhez: {event_hub_instance_name}", + "title": "Csatlakoz\u00e1si karaktersor t\u00edpus" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub n\u00e9vt\u00e9r", + "event_hub_sas_key": "Event Hub SAS kulcs", + "event_hub_sas_policy": "Event Hub SAS h\u00e1zirend" + }, + "description": "K\u00e9rj\u00fck, adja meg a SAS hiteles\u00edt\u0151 adatait ehhez: {event_hub_instance_name}", + "title": "SAS hiteles\u00edt\u00e9s t\u00edpus" + }, + "user": { + "data": { + "event_hub_instance_name": "Event Hub p\u00e9ld\u00e1ny neve", + "use_connection_string": "Csatlakoz\u00e1si karaktersor haszn\u00e1lata" + }, + "title": "Azure Event Hub integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "A csom\u00f3pontnak (hubnak) k\u00fcld\u00f6tt t\u00e9telek k\u00f6z\u00f6tti id\u0151k\u00f6z." + }, + "title": "Azure Event Hub be\u00e1ll\u00edt\u00e1sai." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/id.json b/homeassistant/components/azure_event_hub/translations/id.json new file mode 100644 index 0000000000000..d762032b48544 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/id.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi", + "cannot_connect": "Menghubungkan dengan kredensial dari configuration.yaml gagal, hapus dari yaml dan gunakan proses konfigurasi.", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan.", + "unknown": "Menghubungkan dengan kredensial dari configuration.yaml gagal disertai kesalahan yang tidak dikenal, hapus dari yaml dan gunakan proses konfigurasi." + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "String Koneksi Event Hub" + }, + "description": "Masukkan string koneksi untuk: {event_hub_instance_name}", + "title": "Metode String Koneksi" + }, + "sas": { + "data": { + "event_hub_namespace": "Namespace Event Hub", + "event_hub_sas_key": "Kunci SAS Event Hub", + "event_hub_sas_policy": "Kebijakan SAS Event Hub" + }, + "description": "Masukkan kredensial SAS (shared access signature) untuk: {event_hub_instance_name}", + "title": "Metode Kredensial SAS" + }, + "user": { + "data": { + "event_hub_instance_name": "Nama Instans Event Hub", + "use_connection_string": "Gunakan String Koneksi" + }, + "title": "Siapkan integrasi Azure Event Hub Anda" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Interval antara pengiriman batch ke hub." + }, + "title": "Opsi untuk Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/it.json b/homeassistant/components/azure_event_hub/translations/it.json new file mode 100644 index 0000000000000..d0459ffadd202 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/it.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Il servizio \u00e8 gi\u00e0 configurato", + "cannot_connect": "Connessione con le credenziali da configuration.yaml non riuscita, rimuovi da yaml e utilizza il flusso di configurazione.", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", + "unknown": "La connessione con le credenziali da configuration.yaml non \u00e8 riuscita con un errore sconosciuto, rimuovi da yaml e utilizza il flusso di configurazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Stringa di connessione dell'Event Hub" + }, + "description": "Inserisci la stringa di connessione per: {event_hub_instance_name}", + "title": "Metodo della stringa di connessione" + }, + "sas": { + "data": { + "event_hub_namespace": "Spazio dei nomi dell'Event Hub", + "event_hub_sas_key": "Chiave SAS dell'Event Hub", + "event_hub_sas_policy": "Criteri SAS dell'Event Hub" + }, + "description": "Inserisci le credenziali SAS (firma di accesso condiviso) per: {event_hub_instance_name}", + "title": "Metodo delle credenziali SAS" + }, + "user": { + "data": { + "event_hub_instance_name": "Nome dell'istanza dell'Event Hub", + "use_connection_string": "Usa stringa di connessione" + }, + "title": "Configura l'integrazione di Azure Event Hub" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Intervallo tra l'invio di batch all'hub." + }, + "title": "Opzioni per Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/ja.json b/homeassistant/components/azure_event_hub/translations/ja.json new file mode 100644 index 0000000000000..d8c0407fbc503 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/ja.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "configuration.yaml\u3092\u4f7f\u7528\u3057\u305f\u8a8d\u8a3c\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002yaml\u3092\u524a\u9664\u3057\u3066\u69cb\u6210\u30d5\u30ed\u30fc(config flow)\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown": "configuration.yaml\u3092\u4f7f\u7528\u3057\u305f\u8a8d\u8a3c\u63a5\u7d9a\u304c\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u3067\u5931\u6557\u3057\u307e\u3057\u305f\u3002yaml\u3092\u524a\u9664\u3057\u3066\u69cb\u6210\u30d5\u30ed\u30fc(config flow)\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "\u30a4\u30d9\u30f3\u30c8\u30cf\u30d6\u306e\u63a5\u7d9a\u6587\u5b57\u5217" + }, + "description": "\u6b21\u306e\u63a5\u7d9a\u6587\u5b57\u5217\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044: {event_hub_instance_name}", + "title": "\u63a5\u7d9a\u6587\u5b57\u5217\u30e1\u30bd\u30c3\u30c9" + }, + "sas": { + "data": { + "event_hub_namespace": "\u30a4\u30d9\u30f3\u30c8\u30cf\u30d6\u306e\u540d\u524d\u7a7a\u9593", + "event_hub_sas_key": "\u30a4\u30d9\u30f3\u30c8\u30cf\u30d6SAS\u30ad\u30fc", + "event_hub_sas_policy": "\u30a4\u30d9\u30f3\u30c8\u30cf\u30d6SAS\u30dd\u30ea\u30b7\u30fc" + }, + "description": "\u6b21\u306eSAS(\u5171\u6709\u30a2\u30af\u30bb\u30b9\u7f72\u540d)\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044: {event_hub_instance_name}", + "title": "SAS\u306e\u8a8d\u8a3c\u60c5\u5831\u65b9\u5f0f" + }, + "user": { + "data": { + "event_hub_instance_name": "\u30a4\u30d9\u30f3\u30c8\u30cf\u30d6\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u540d", + "use_connection_string": "\u63a5\u7d9a\u6587\u5b57\u5217\u3092\u4f7f\u7528\u3059\u308b" + }, + "title": "Azure Event Hub\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "\u30d0\u30c3\u30c1\u3092\u30cf\u30d6\u306b\u9001\u4fe1\u3059\u308b\u9593\u9694\u3002" + }, + "title": "Azure Event Hub\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/nl.json b/homeassistant/components/azure_event_hub/translations/nl.json new file mode 100644 index 0000000000000..92b3eee0a8ded --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/nl.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Service is al geconfigureerd", + "cannot_connect": "Verbinding maken met de credentails uit de configuration.yaml is mislukt, verwijder deze uit de yaml en gebruik de config flow.", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk.", + "unknown": "Verbinding maken met de credentails uit de configuration.yaml is mislukt met een onbekende fout, verwijder a.u.b. de yaml en gebruik de config flow." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hub Connection String" + }, + "description": "Voer de connection string in voor: {event_hub_instance_name}", + "title": "Connection String methode" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub Namespace", + "event_hub_sas_key": "Event Hub SAS-sleutel", + "event_hub_sas_policy": "Event Hub SAS Policy" + }, + "description": "Voer de SAS-gegevens (shared access signature) in voor: {event_hub_instance_name}", + "title": "SAS Credentials methode" + }, + "user": { + "data": { + "event_hub_instance_name": "Event Hub Instance Naam", + "use_connection_string": "Gebruik Connection String" + }, + "title": "Stel uw Azure Event Hub integratie in" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Interval tussen het verzenden van batches naar de hub." + }, + "title": "Opties voor de Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/no.json b/homeassistant/components/azure_event_hub/translations/no.json new file mode 100644 index 0000000000000..4c767a4607656 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/no.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Tjenesten er allerede konfigurert", + "cannot_connect": "Tilkobling med p\u00e5loggingsinformasjonen fra configuration.yaml mislyktes, vennligst fjern fra yaml og bruk konfigurasjonsflyten.", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", + "unknown": "Tilkobling med p\u00e5loggingsinformasjonen fra configuration.yaml mislyktes med en ukjent feil, vennligst fjern fra yaml og bruk konfigurasjonsflyten." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hub Connection String" + }, + "description": "Vennligst skriv inn tilkoblingsstrengen for: {event_hub_instance_name}", + "title": "Metode for tilkoblingsstreng" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub-navneomr\u00e5de", + "event_hub_sas_key": "Event Hub SAS-n\u00f8kkel", + "event_hub_sas_policy": "Event Hub SAS-policy" + }, + "description": "Vennligst skriv inn SAS-legitimasjonen (delt tilgangssignatur) for: {event_hub_instance_name}", + "title": "SAS Credentials-metoden" + }, + "user": { + "data": { + "event_hub_instance_name": "Event Hub-forekomstnavn", + "use_connection_string": "Bruk tilkoblingsstreng" + }, + "title": "Konfigurer din Azure Event Hub-integrasjon" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Intervall mellom sending av batcher til huben." + }, + "title": "Alternativer for Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/pl.json b/homeassistant/components/azure_event_hub/translations/pl.json new file mode 100644 index 0000000000000..14a08f96184cc --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/pl.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", + "cannot_connect": "\u0141\u0105czenie si\u0119 z danymi uwierzytelniaj\u0105cymi z pliku configuration.yaml nie powiod\u0142o si\u0119. Usu\u0144 wpis z pliku yaml i u\u017cyj tego konfiguratora.", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja.", + "unknown": "\u0141\u0105czenie z danymi uwierzytelniaj\u0105cymi z pliku configuration.yaml nie powiod\u0142o si\u0119 z powodu nieznanego b\u0142\u0119du, usu\u0144 go z pliku yaml i u\u017cyj konfiguratora." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Parametry po\u0142\u0105czenia z Event Hub" + }, + "description": "Wprowad\u017a parametry po\u0142\u0105czenia dla: {event_hub_instance_name}", + "title": "Metoda parametru po\u0142\u0105czenia" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub", + "event_hub_sas_key": "Klucz SAS do Event Hub", + "event_hub_sas_policy": "Zasady SAS dotycz\u0105ce Event Hub" + }, + "description": "Wprowad\u017a po\u015bwiadczenia SAS (sygnatura dost\u0119pu wsp\u00f3\u0142dzielonego) dla: {event_hub_instance_name}", + "title": "Metoda po\u015bwiadcze\u0144 SAS" + }, + "user": { + "data": { + "event_hub_instance_name": "Nazwa instancji centrum zdarze\u0144", + "use_connection_string": "U\u017cyj parametru po\u0142\u0105czenia" + }, + "title": "Konfiguracja integracji us\u0142ugi Azure Event Hub" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Odst\u0119p czasu pomi\u0119dzy wysy\u0142aniem partii do huba." + }, + "title": "Opcje dla Azure Event Hub." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/ru.json b/homeassistant/components/azure_event_hub/translations/ru.json new file mode 100644 index 0000000000000..df58f5e8c34b8 --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/ru.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u0441 \u0443\u0447\u0435\u0442\u043d\u044b\u043c\u0438 \u0434\u0430\u043d\u043d\u044b\u043c\u0438 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 configuration.yaml, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u0445 \u0438\u0437 yaml \u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043c\u0430\u0441\u0442\u0435\u0440 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", + "unknown": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u0441 \u0443\u0447\u0435\u0442\u043d\u044b\u043c\u0438 \u0434\u0430\u043d\u043d\u044b\u043c\u0438 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 configuration.yaml, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u0445 \u0438\u0437 yaml \u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043c\u0430\u0441\u0442\u0435\u0440 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "\u0421\u0442\u0440\u043e\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0442\u0440\u043e\u043a\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0434\u043b\u044f: {event_hub_instance_name}", + "title": "\u041c\u0435\u0442\u043e\u0434 \u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f" + }, + "sas": { + "data": { + "event_hub_namespace": "\u041f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u043e \u0438\u043c\u0435\u043d \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439", + "event_hub_sas_key": "\u041a\u043b\u044e\u0447 SAS \u0434\u043b\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439", + "event_hub_sas_policy": "\u041f\u043e\u043b\u0438\u0442\u0438\u043a\u0430 SAS \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 SAS (\u043e\u0431\u0449\u0430\u044f \u043f\u043e\u0434\u043f\u0438\u0441\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0430) \u0434\u043b\u044f: {event_hub_instance_name}", + "title": "\u041c\u0435\u0442\u043e\u0434 \u0443\u0447\u0435\u0442\u043d\u044b\u0445 \u0434\u0430\u043d\u043d\u044b\u0445 SAS" + }, + "user": { + "data": { + "event_hub_instance_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439", + "use_connection_string": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0441\u0442\u0440\u043e\u043a\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f" + }, + "title": "Azure Event Hub" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0435\u0436\u0434\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u043e\u0439 \u043f\u0430\u043a\u0435\u0442\u043e\u0432 \u043d\u0430 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0442\u043e\u0440." + }, + "title": "Azure Event Hub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/tr.json b/homeassistant/components/azure_event_hub/translations/tr.json new file mode 100644 index 0000000000000..a2905eae6e9dc --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/tr.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "configuration.yaml'deki kimlik bilgileriyle ba\u011flant\u0131 kurulamad\u0131, l\u00fctfen yaml'den \u00e7\u0131kar\u0131n ve yap\u0131land\u0131rma ak\u0131\u015f\u0131n\u0131 kullan\u0131n.", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", + "unknown": "configuration.yaml'deki kimlik bilgileriyle ba\u011flant\u0131, bilinmeyen bir hatayla ba\u015far\u0131s\u0131z oldu, l\u00fctfen yaml'den kald\u0131r\u0131n ve yap\u0131land\u0131rma ak\u0131\u015f\u0131n\u0131 kullan\u0131n." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "Event Hub Ba\u011flant\u0131 Dizesi" + }, + "description": "L\u00fctfen \u015fu ba\u011flant\u0131 dizesini girin: {event_hub_instance_name}", + "title": "Ba\u011flant\u0131 Dizisi y\u00f6ntemi" + }, + "sas": { + "data": { + "event_hub_namespace": "Event Hub Ad Alan\u0131", + "event_hub_sas_key": "Event Hub SAS Anahtar\u0131", + "event_hub_sas_policy": "Event Hub SAS \u0130lkesi" + }, + "description": "{event_hub_instance_name} i\u00e7in SAS (payla\u015f\u0131lan eri\u015fim anahtar\u0131) kimlik bilgilerini girin", + "title": "SAS Kimlik Bilgileri y\u00f6ntemi" + }, + "user": { + "data": { + "event_hub_instance_name": "Event Hub \u00d6rnek Ad\u0131", + "use_connection_string": "Ba\u011flant\u0131 Dizesini Kullan" + }, + "title": "Azure Event Hub entegrasyonu kurun" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "Hub'a toplu g\u00f6nderme aras\u0131ndaki aral\u0131k." + }, + "title": "Azure Event Hub i\u00e7in se\u00e7enekler." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/translations/zh-Hant.json b/homeassistant/components/azure_event_hub/translations/zh-Hant.json new file mode 100644 index 0000000000000..64f713f5bd84b --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/zh-Hant.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u4f7f\u7528 configuration.yaml \u6240\u5305\u542b\u6191\u8b49\u9023\u7dda\u5931\u6557\uff0c\u8acb\u81ea Yaml \u79fb\u9664\u8a72\u6191\u8b49\u4e26\u4f7f\u7528\u8a2d\u5b9a\u6d41\u7a0b\u65b9\u5f0f\u3002", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002", + "unknown": "\u4f7f\u7528 configuration.yaml \u6240\u5305\u542b\u6191\u8b49\u9023\u7dda\u767c\u751f\u672a\u77e5\u932f\u8aa4\uff0c\u8acb\u81ea Yaml \u79fb\u9664\u8a72\u6191\u8b49\u4e26\u4f7f\u7528\u8a2d\u5b9a\u6d41\u7a0b\u65b9\u5f0f\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "conn_string": { + "data": { + "event_hub_connection_string": "\u4e8b\u4ef6\u4e2d\u6a1e\u9023\u7dda\u5b57\u4e32" + }, + "description": "\u8acb\u8f38\u5165\u9023\u7dda\u5b57\u4e32\uff1a{event_hub_instance_name}", + "title": "\u9023\u63a5\u5b57\u4e32\u6a21\u5f0f" + }, + "sas": { + "data": { + "event_hub_namespace": "\u4e8b\u4ef6\u4e2d\u6a1e Namespace", + "event_hub_sas_key": "\u4e8b\u4ef6\u4e2d\u6a1e SAS \u91d1\u9470", + "event_hub_sas_policy": "\u4e8b\u4ef6\u4e2d\u6a1e SAS \u96b1\u79c1\u653f\u7b56" + }, + "description": "\u8acb\u8f38\u5165 SAS \uff08\u5171\u7528\u5b58\u53d6\u7c3d\u7ae0\uff09\u6191\u8b49\uff1a{event_hub_instance_name}", + "title": "SAS \u6191\u8b49\u6a21\u5f0f" + }, + "user": { + "data": { + "event_hub_instance_name": "\u4e8b\u4ef6\u4e2d\u6a1e\u5be6\u4f8b\u540d\u7a31", + "use_connection_string": "\u4f7f\u7528\u9023\u63a5\u5b57\u4e32" + }, + "title": "\u8a2d\u5b9a Azure \u4e8b\u4ef6\u4e2d\u6a1e\u6574\u5408" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "send_interval": "\u4e2d\u6a1e\u50b3\u9001\u6279\u6b21\u9593\u9694\u6642\u9593" + }, + "title": "Azure \u4e8b\u4ef6\u4e2d\u6a1e\u9078\u9805\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index e7c85adede8f2..0d48ff6b2d6ab 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -90,8 +90,7 @@ async def async_send_message(self, message, **kwargs): if ATTR_TARGET in kwargs: dto[ATTR_ASB_TARGET] = kwargs[ATTR_TARGET] - data = kwargs.get(ATTR_DATA) - if data: + if data := kwargs.get(ATTR_DATA): dto.update(data) queue_message = Message(json.dumps(dto)) diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py new file mode 100644 index 0000000000000..731f7b2c2d1dd --- /dev/null +++ b/homeassistant/components/balboa/__init__.py @@ -0,0 +1,102 @@ +"""The Balboa Spa Client integration.""" +import asyncio +from datetime import datetime, timedelta +import time + +from pybalboa import BalboaSpaWifi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util + +from .const import ( + _LOGGER, + CONF_SYNC_TIME, + DEFAULT_SYNC_TIME, + DOMAIN, + PLATFORMS, + SIGNAL_UPDATE, +) + +SYNC_TIME_INTERVAL = timedelta(days=1) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Balboa Spa from a config entry.""" + host = entry.data[CONF_HOST] + + _LOGGER.debug("Attempting to connect to %s", host) + spa = BalboaSpaWifi(host) + + connected = await spa.connect() + if not connected: + _LOGGER.error("Failed to connect to spa at %s", host) + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = spa + + # send config requests, and then listen until we are configured. + await spa.send_mod_ident_req() + await spa.send_panel_req(0, 1) + + async def _async_balboa_update_cb(): + """Primary update callback called from pybalboa.""" + _LOGGER.debug("Primary update callback triggered") + async_dispatcher_send(hass, SIGNAL_UPDATE.format(entry.entry_id)) + + # set the callback so we know we have new data + spa.new_data_cb = _async_balboa_update_cb + + _LOGGER.debug("Starting listener and monitor tasks") + asyncio.create_task(spa.listen()) + await spa.spa_configured() + asyncio.create_task(spa.check_connection_status()) + + # At this point we have a configured spa. + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + # call update_listener on startup and for options change as well. + await async_setup_time_sync(hass, entry) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + _LOGGER.debug("Disconnecting from spa") + spa = hass.data[DOMAIN][entry.entry_id] + await spa.disconnect() + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup_time_sync(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Set up the time sync.""" + if not entry.options.get(CONF_SYNC_TIME, DEFAULT_SYNC_TIME): + return + + _LOGGER.debug("Setting up daily time sync") + spa = hass.data[DOMAIN][entry.entry_id] + + async def sync_time(now: datetime): + _LOGGER.debug("Syncing time with Home Assistant") + await spa.set_time(time.strptime(str(dt_util.now()), "%Y-%m-%d %H:%M:%S.%f%z")) + + await sync_time(dt_util.utcnow()) + entry.async_on_unload( + async_track_time_interval(hass, sync_time, SYNC_TIME_INTERVAL) + ) diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py new file mode 100644 index 0000000000000..b73872b664710 --- /dev/null +++ b/homeassistant/components/balboa/binary_sensor.py @@ -0,0 +1,59 @@ +"""Support for Balboa Spa binary sensors.""" +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) + +from .const import CIRC_PUMP, DOMAIN, FILTER +from .entity import BalboaEntity + +FILTER_STATES = [ + [False, False], # self.FILTER_OFF + [True, False], # self.FILTER_1 + [False, True], # self.FILTER_2 + [True, True], # self.FILTER_1_2 +] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the spa's binary sensors.""" + spa = hass.data[DOMAIN][entry.entry_id] + entities = [BalboaSpaFilter(entry, spa, FILTER, index) for index in range(1, 3)] + if spa.have_circ_pump(): + entities.append(BalboaSpaCircPump(entry, spa, CIRC_PUMP)) + + async_add_entities(entities) + + +class BalboaSpaBinarySensor(BalboaEntity, BinarySensorEntity): + """Representation of a Balboa Spa binary sensor entity.""" + + _attr_device_class = BinarySensorDeviceClass.MOVING + + +class BalboaSpaCircPump(BalboaSpaBinarySensor): + """Representation of a Balboa Spa circulation pump.""" + + @property + def is_on(self) -> bool: + """Return true if the filter is on.""" + return self._client.get_circ_pump() + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:water-pump" if self.is_on else "mdi:water-pump-off" + + +class BalboaSpaFilter(BalboaSpaBinarySensor): + """Representation of a Balboa Spa Filter.""" + + @property + def is_on(self) -> bool: + """Return true if the filter is on.""" + return FILTER_STATES[self._client.get_filtermode()][self._num - 1] + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:sync" if self.is_on else "mdi:sync-off" diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py new file mode 100644 index 0000000000000..edd44b03f17c1 --- /dev/null +++ b/homeassistant/components/balboa/climate.py @@ -0,0 +1,161 @@ +"""Support for Balboa Spa Wifi adaptor.""" +from __future__ import annotations + +import asyncio + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from .const import CLIMATE, CLIMATE_SUPPORTED_FANSTATES, CLIMATE_SUPPORTED_MODES, DOMAIN +from .entity import BalboaEntity + +SET_TEMPERATURE_WAIT = 1 + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the spa climate device.""" + async_add_entities( + [ + BalboaSpaClimate( + entry, + hass.data[DOMAIN][entry.entry_id], + CLIMATE, + ) + ], + ) + + +class BalboaSpaClimate(BalboaEntity, ClimateEntity): + """Representation of a Balboa Spa Climate device.""" + + _attr_icon = "mdi:hot-tub" + _attr_fan_modes = CLIMATE_SUPPORTED_FANSTATES + _attr_hvac_modes = CLIMATE_SUPPORTED_MODES + + def __init__(self, entry, client, devtype, num=None): + """Initialize the climate entity.""" + super().__init__(entry, client, devtype, num) + self._balboa_to_ha_blower_map = { + self._client.BLOWER_OFF: FAN_OFF, + self._client.BLOWER_LOW: FAN_LOW, + self._client.BLOWER_MEDIUM: FAN_MEDIUM, + self._client.BLOWER_HIGH: FAN_HIGH, + } + self._ha_to_balboa_blower_map = { + value: key for key, value in self._balboa_to_ha_blower_map.items() + } + self._balboa_to_ha_heatmode_map = { + self._client.HEATMODE_READY: HVAC_MODE_HEAT, + self._client.HEATMODE_RNR: HVAC_MODE_AUTO, + self._client.HEATMODE_REST: HVAC_MODE_OFF, + } + self._ha_heatmode_to_balboa_map = { + value: key for key, value in self._balboa_to_ha_heatmode_map.items() + } + scale = self._client.get_tempscale() + self._attr_preset_modes = self._client.get_heatmode_stringlist() + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + if self._client.have_blower(): + self._attr_supported_features |= SUPPORT_FAN_MODE + self._attr_min_temp = self._client.tmin[self._client.TEMPRANGE_LOW][scale] + self._attr_max_temp = self._client.tmax[self._client.TEMPRANGE_HIGH][scale] + self._attr_temperature_unit = TEMP_FAHRENHEIT + self._attr_precision = PRECISION_WHOLE + if self._client.get_tempscale() == self._client.TSCALE_C: + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_precision = PRECISION_HALVES + + @property + def hvac_mode(self) -> str: + """Return the current HVAC mode.""" + mode = self._client.get_heatmode() + return self._balboa_to_ha_heatmode_map[mode] + + @property + def hvac_action(self) -> str: + """Return the current operation mode.""" + state = self._client.get_heatstate() + if state >= self._client.ON: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + + @property + def fan_mode(self) -> str: + """Return the current fan mode.""" + fanmode = self._client.get_blower() + return self._balboa_to_ha_blower_map.get(fanmode, FAN_OFF) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._client.get_curtemp() + + @property + def target_temperature(self): + """Return the target temperature we try to reach.""" + return self._client.get_settemp() + + @property + def preset_mode(self): + """Return current preset mode.""" + return self._client.get_heatmode(True) + + async def async_set_temperature(self, **kwargs): + """Set a new target temperature.""" + scale = self._client.get_tempscale() + newtemp = kwargs[ATTR_TEMPERATURE] + if newtemp > self._client.tmax[self._client.TEMPRANGE_LOW][scale]: + await self._client.change_temprange(self._client.TEMPRANGE_HIGH) + await asyncio.sleep(SET_TEMPERATURE_WAIT) + if newtemp < self._client.tmin[self._client.TEMPRANGE_HIGH][scale]: + await self._client.change_temprange(self._client.TEMPRANGE_LOW) + await asyncio.sleep(SET_TEMPERATURE_WAIT) + await self._client.send_temp_change(newtemp) + + async def async_set_preset_mode(self, preset_mode) -> None: + """Set new preset mode.""" + modelist = self._client.get_heatmode_stringlist() + self._async_validate_mode_or_raise(preset_mode) + if preset_mode not in modelist: + raise ValueError(f"{preset_mode} is not a valid preset mode") + await self._client.change_heatmode(modelist.index(preset_mode)) + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + await self._client.change_blower(self._ha_to_balboa_blower_map[fan_mode]) + + def _async_validate_mode_or_raise(self, mode): + """Check that the mode can be set.""" + if mode == self._client.HEATMODE_RNR: + raise ValueError(f"{mode} can only be reported but not set") + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode. + + OFF = Rest + AUTO = Ready in Rest (can't be set, only reported) + HEAT = Ready + """ + mode = self._ha_heatmode_to_balboa_map[hvac_mode] + self._async_validate_mode_or_raise(mode) + await self._client.change_heatmode(self._ha_heatmode_to_balboa_map[hvac_mode]) diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py new file mode 100644 index 0000000000000..42895e5ccd64a --- /dev/null +++ b/homeassistant/components/balboa/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for Balboa Spa Client integration.""" +import asyncio + +from pybalboa import BalboaSpaWifi +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac + +from .const import _LOGGER, CONF_SYNC_TIME, DOMAIN + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + + _LOGGER.debug("Attempting to connect to %s", data[CONF_HOST]) + spa = BalboaSpaWifi(data[CONF_HOST]) + connected = await spa.connect() + _LOGGER.debug("Got connected = %d", connected) + if not connected: + raise CannotConnect + + # send config requests, and then listen until we are configured. + await spa.send_mod_ident_req() + await spa.send_panel_req(0, 1) + + asyncio.create_task(spa.listen()) + + await spa.spa_configured() + + mac_addr = format_mac(spa.get_macaddr()) + model = spa.get_model_name() + await spa.disconnect() + + return {"title": model, "formatted_mac": mac_addr} + + +class BalboaSpaClientFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Balboa Spa Client config flow.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return BalboaSpaClientOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["formatted_mac"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class BalboaSpaClientOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Balboa Spa Client options.""" + + def __init__(self, config_entry): + """Initialize Balboa Spa Client options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage Balboa Spa Client options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SYNC_TIME, + default=self.config_entry.options.get(CONF_SYNC_TIME, False), + ): bool, + } + ), + ) diff --git a/homeassistant/components/balboa/const.py b/homeassistant/components/balboa/const.py new file mode 100644 index 0000000000000..f5b288049525c --- /dev/null +++ b/homeassistant/components/balboa/const.py @@ -0,0 +1,36 @@ +"""Constants for the Balboa Spa Client integration.""" +import logging + +from homeassistant.components.climate.const import ( + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, +) +from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_OFF +from homeassistant.const import Platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "balboa" + +CLIMATE_SUPPORTED_FANSTATES = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] +CLIMATE_SUPPORTED_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] +CONF_SYNC_TIME = "sync_time" +DEFAULT_SYNC_TIME = False +FAN_SUPPORTED_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_HIGH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] + +AUX = "Aux" +CIRC_PUMP = "Circ Pump" +CLIMATE = "Climate" +FILTER = "Filter" +LIGHT = "Light" +MISTER = "Mister" +PUMP = "Pump" +TEMP_RANGE = "Temp Range" + +SIGNAL_UPDATE = "balboa_update_{}" diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py new file mode 100644 index 0000000000000..44f0635024337 --- /dev/null +++ b/homeassistant/components/balboa/entity.py @@ -0,0 +1,57 @@ +"""Base class for Balboa Spa Client integration.""" +import time + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import SIGNAL_UPDATE + + +class BalboaEntity(Entity): + """Abstract class for all Balboa platforms. + + Once you connect to the spa's port, it continuously sends data (at a rate + of about 5 per second!). The API updates the internal states of things + from this stream, and all we have to do is read the values out of the + accessors. + """ + + _attr_should_poll = False + + def __init__(self, entry, client, devtype, num=None): + """Initialize the spa entity.""" + self._client = client + self._device_name = self._client.get_model_name() + self._type = devtype + self._num = num + self._entry = entry + self._attr_unique_id = f'{self._device_name}-{self._type}{self._num or ""}-{self._client.get_macaddr().replace(":","")[-6:]}' + self._attr_name = f'{self._device_name}: {self._type}{self._num or ""}' + self._attr_device_info = DeviceInfo( + name=self._device_name, + manufacturer="Balboa Water Group", + model=self._client.get_model_name(), + sw_version=self._client.get_ssid(), + connections={(CONNECTION_NETWORK_MAC, self._client.get_macaddr())}, + ) + + async def async_added_to_hass(self) -> None: + """Set up a listener for the entity.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE.format(self._entry.entry_id), + self.async_write_ha_state, + ) + ) + + @property + def assumed_state(self) -> bool: + """Return whether the state is based on actual reading from device.""" + return (self._client.lastupd + 5 * 60) < time.time() + + @property + def available(self) -> bool: + """Return whether the entity is available or not.""" + return self._client.connected diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json new file mode 100644 index 0000000000000..aa52bee230d7f --- /dev/null +++ b/homeassistant/components/balboa/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "balboa", + "name": "Balboa Spa Client", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/balboa", + "requirements": [ + "pybalboa==0.13" + ], + "codeowners": [ + "@garbled1" + ], + "iot_class": "local_push" +} diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json new file mode 100644 index 0000000000000..68bd4ddef7b58 --- /dev/null +++ b/homeassistant/components/balboa/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the Balboa Wi-Fi device", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant" + } + } + } + } +} diff --git a/homeassistant/components/balboa/translations/bg.json b/homeassistant/components/balboa/translations/bg.json new file mode 100644 index 0000000000000..cbf1e2ae7c9b3 --- /dev/null +++ b/homeassistant/components/balboa/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/ca.json b/homeassistant/components/balboa/translations/ca.json new file mode 100644 index 0000000000000..139f706c878ef --- /dev/null +++ b/homeassistant/components/balboa/translations/ca.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Connexi\u00f3 amb dispositiu Wi-Fi Balboa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Mantingues l'hora del client Balboa Spa sincronitzada amb Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/de.json b/homeassistant/components/balboa/translations/de.json new file mode 100644 index 0000000000000..7b5961040e797 --- /dev/null +++ b/homeassistant/components/balboa/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Verbinde dich mit dem Balboa Wi-Fi Ger\u00e4t" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Synchronisiere die Zeit deines Balboa Spa-Clients mit Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/en.json b/homeassistant/components/balboa/translations/en.json new file mode 100644 index 0000000000000..bad5167fc5ec7 --- /dev/null +++ b/homeassistant/components/balboa/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Connect to the Balboa Wi-Fi device" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/et.json b/homeassistant/components/balboa/translations/et.json new file mode 100644 index 0000000000000..264855023f9fa --- /dev/null +++ b/homeassistant/components/balboa/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "\u00dchendu Balboa Wi-Fi seadmega" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Hoia oma Balboa Spa kliendi aeg Home Assistantiga s\u00fcnkroonis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/fr.json b/homeassistant/components/balboa/translations/fr.json new file mode 100644 index 0000000000000..c9c24b8dee872 --- /dev/null +++ b/homeassistant/components/balboa/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + }, + "title": "Connectez-vous \u00e0 l'appareil Wi-Fi Balboa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Synchronisez l'heure de votre client Balboa Spa avec Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/he.json b/homeassistant/components/balboa/translations/he.json new file mode 100644 index 0000000000000..1699e0f8e1983 --- /dev/null +++ b/homeassistant/components/balboa/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/hu.json b/homeassistant/components/balboa/translations/hu.json new file mode 100644 index 0000000000000..d9ae0e9c403f1 --- /dev/null +++ b/homeassistant/components/balboa/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm" + }, + "title": "Csatlakoz\u00e1s a Balboa Wi-Fi eszk\u00f6zh\u00f6z" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Balboa Spa kliens \u00f3r\u00e1j\u00e1nak szinkroniz\u00e1l\u00e1sa Home Assistanthoz" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/id.json b/homeassistant/components/balboa/translations/id.json new file mode 100644 index 0000000000000..8f8cb97780ab4 --- /dev/null +++ b/homeassistant/components/balboa/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Hubungkan ke perangkat Wi-Fi Balboa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Selalu sinkronkan waktu Balboa Spa Client Anda dengan Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/it.json b/homeassistant/components/balboa/translations/it.json new file mode 100644 index 0000000000000..f9be4a441870d --- /dev/null +++ b/homeassistant/components/balboa/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Connettiti al dispositivo Wi-Fi Balboa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Mantieni sincronizzato l'orario del tuo client Balboa Spa con Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/ja.json b/homeassistant/components/balboa/translations/ja.json new file mode 100644 index 0000000000000..f6e799cd6af3f --- /dev/null +++ b/homeassistant/components/balboa/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "title": "Balboa Wi-Fi device\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Balboa Spa Client's\u306e\u6642\u9593\u3092\u3001Home Assistant\u3068\u540c\u671f\u3055\u305b\u307e\u3059" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/lt.json b/homeassistant/components/balboa/translations/lt.json new file mode 100644 index 0000000000000..5d5f99e097100 --- /dev/null +++ b/homeassistant/components/balboa/translations/lt.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u012erenginys paruo\u0161tas naudojimui" + }, + "error": { + "cannot_connect": "Nepavyko prisijungti" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/nl.json b/homeassistant/components/balboa/translations/nl.json new file mode 100644 index 0000000000000..04dc0decd0d34 --- /dev/null +++ b/homeassistant/components/balboa/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Maak verbinding met het Balboa Wi-Fi-apparaat" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Houd de tijd van uw Balboa Spa Client gesynchroniseerd met Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/no.json b/homeassistant/components/balboa/translations/no.json new file mode 100644 index 0000000000000..46f2c9284b868 --- /dev/null +++ b/homeassistant/components/balboa/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "title": "Koble til Balboa Wi-Fi-enheten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Hold Balboa Spa-klientens tid synkronisert med Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/pl.json b/homeassistant/components/balboa/translations/pl.json new file mode 100644 index 0000000000000..a0524761e5770 --- /dev/null +++ b/homeassistant/components/balboa/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "title": "Po\u0142\u0105czenie z urz\u0105dzeniem Balboa Wi-Fi" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Synchronizacja czasu klienta Balboa Spa z Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/ru.json b/homeassistant/components/balboa/translations/ru.json new file mode 100644 index 0000000000000..07732f3796e8f --- /dev/null +++ b/homeassistant/components/balboa/translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0432\u0440\u0435\u043c\u044f Balboa Spa \u0441 Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/sl.json b/homeassistant/components/balboa/translations/sl.json new file mode 100644 index 0000000000000..0eec93b817d33 --- /dev/null +++ b/homeassistant/components/balboa/translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/tr.json b/homeassistant/components/balboa/translations/tr.json new file mode 100644 index 0000000000000..8be6c335bd64a --- /dev/null +++ b/homeassistant/components/balboa/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Sunucu" + }, + "title": "Balboa Wi-Fi cihaz\u0131na ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Balboa Spa \u0130stemcisi'nin zaman\u0131n\u0131 Home Assistant ile senkronize tutun" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/zh-Hant.json b/homeassistant/components/balboa/translations/zh-Hant.json new file mode 100644 index 0000000000000..78aa2e7c0b95f --- /dev/null +++ b/homeassistant/components/balboa/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u9023\u7dda\u81f3 Balboa Wi-Fi \u88dd\u7f6e" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "\u5c07 Balboa Spa \u5ba2\u6236\u7aef\u6642\u9593\u8207 Home Assistant \u4fdd\u6301\u540c\u6b65" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6879e278bab03..41654973ffe1a 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -129,13 +129,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BayesianBinarySensor(BinarySensorEntity): """Representation of a Bayesian sensor.""" + _attr_should_poll = False + def __init__(self, name, prior, observations, probability_threshold, device_class): """Initialize the Bayesian sensor.""" - self._name = name + self._attr_name = name self._observations = observations self._probability_threshold = probability_threshold - self._device_class = device_class - self._deviation = False + self._attr_device_class = device_class + self._attr_is_on = False self._callbacks = [] self.prior = prior @@ -238,12 +240,12 @@ def _async_template_result_changed(event, updates): self.current_observations.update(self._initialize_current_observations()) self.probability = self._calculate_new_probability() - self._deviation = bool(self.probability >= self._probability_threshold) + self._attr_is_on = bool(self.probability >= self._probability_threshold) @callback def _recalculate_and_write_state(self): self.probability = self._calculate_new_probability() - self._deviation = bool(self.probability >= self._probability_threshold) + self._attr_is_on = bool(self.probability >= self._probability_threshold) self.async_write_ha_state() def _initialize_current_observations(self): @@ -363,30 +365,9 @@ def _process_state(self, entity_observation): except ConditionError: return False - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._deviation - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" - attr_observations_list = [ obs.copy() for obs in self.current_observations.values() if obs is not None ] diff --git a/homeassistant/components/bayesian/services.yaml b/homeassistant/components/bayesian/services.yaml index 2fe3a4f7c9b26..c1dc891805a3b 100644 --- a/homeassistant/components/bayesian/services.yaml +++ b/homeassistant/components/bayesian/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all bayesian entities diff --git a/homeassistant/components/bbb_gpio/__init__.py b/homeassistant/components/bbb_gpio/__init__.py index f7d146e073edc..9cc94de091017 100644 --- a/homeassistant/components/bbb_gpio/__init__.py +++ b/homeassistant/components/bbb_gpio/__init__.py @@ -1,13 +1,23 @@ """Support for controlling GPIO pins of a Beaglebone Black.""" +import logging + from Adafruit_BBIO import GPIO # pylint: disable=import-error from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP DOMAIN = "bbb_gpio" +_LOGGER = logging.getLogger(__name__) + def setup(hass, config): """Set up the BeagleBone Black GPIO component.""" + _LOGGER.warning( + "The BeagleBone Black GPIO integration is deprecated and will be removed " + "in Home Assistant Core 2022.4; this integration is removed under " + "Architectural Decision Record 0019, more information can be found here: " + "https://github.com/home-assistant/architecture/blob/master/adr/0019-GPIO.md" + ) def cleanup_gpio(event): """Stuff to do before stopping.""" diff --git a/homeassistant/components/bbb_gpio/binary_sensor.py b/homeassistant/components/bbb_gpio/binary_sensor.py index c772cf86f00b4..5fcaccd674ef6 100644 --- a/homeassistant/components/bbb_gpio/binary_sensor.py +++ b/homeassistant/components/bbb_gpio/binary_sensor.py @@ -43,10 +43,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BBBGPIOBinarySensor(BinarySensorEntity): """Representation of a binary sensor that uses Beaglebone Black GPIO.""" + _attr_should_poll = False + def __init__(self, pin, params): """Initialize the Beaglebone Black binary sensor.""" self._pin = pin - self._name = params[CONF_NAME] or DEVICE_DEFAULT_NAME + self._attr_name = params[CONF_NAME] or DEVICE_DEFAULT_NAME self._bouncetime = params[CONF_BOUNCETIME] self._pull_mode = params[CONF_PULL_MODE] self._invert_logic = params[CONF_INVERT_LOGIC] @@ -62,16 +64,6 @@ def read_gpio(pin): bbb_gpio.edge_detect(self._pin, read_gpio, self._bouncetime) @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the entity.""" return self._state != self._invert_logic diff --git a/homeassistant/components/bbb_gpio/switch.py b/homeassistant/components/bbb_gpio/switch.py index 03a9065a15b57..3bed1d7db1302 100644 --- a/homeassistant/components/bbb_gpio/switch.py +++ b/homeassistant/components/bbb_gpio/switch.py @@ -37,10 +37,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BBBGPIOSwitch(ToggleEntity): """Representation of a BeagleBone Black GPIO.""" + _attr_should_poll = False + def __init__(self, pin, params): """Initialize the pin.""" self._pin = pin - self._name = params[CONF_NAME] or DEVICE_DEFAULT_NAME + self._attr_name = params[CONF_NAME] or DEVICE_DEFAULT_NAME self._state = params[CONF_INITIAL] self._invert_logic = params[CONF_INVERT_LOGIC] @@ -52,17 +54,7 @@ def __init__(self, pin, params): bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1) @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._state diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 9dac635dd2f74..68f24d03ed0eb 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST @@ -24,7 +24,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string} ) diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 5256c2a61a078..5ab897de797c8 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -1,4 +1,6 @@ """Support for Bbox Bouygues Modem Router.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,13 +8,17 @@ import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_VARIABLES, CONF_NAME, DATA_RATE_MEGABITS_PER_SECOND, - DEVICE_CLASS_TIMESTAMP, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -26,36 +32,54 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -# Sensor types are defined like so: Name, unit, icon -SENSOR_TYPES = { - "down_max_bandwidth": [ - "Maximum Download Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:download", - ], - "up_max_bandwidth": [ - "Maximum Upload Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:upload", - ], - "current_down_bandwidth": [ - "Currently Used Download Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:download", - ], - "current_up_bandwidth": [ - "Currently Used Upload Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:upload", - ], - "uptime": ["Uptime", None, "mdi:clock"], - "number_of_reboots": ["Number of reboot", None, "mdi:restart"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="down_max_bandwidth", + name="Maximum Download Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:download", + ), + SensorEntityDescription( + key="up_max_bandwidth", + name="Maximum Upload Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="current_down_bandwidth", + name="Currently Used Download Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:download", + ), + SensorEntityDescription( + key="current_up_bandwidth", + name="Currently Used Upload Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:upload", + ), + SensorEntityDescription( + key="number_of_reboots", + name="Number of reboot", + icon="mdi:restart", + ), +) + +SENSOR_TYPES_UPTIME: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="uptime", + name="Uptime", + icon="mdi:clock", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in (*SENSOR_TYPES, *SENSOR_TYPES_UPTIME)] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } @@ -75,114 +99,78 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - if variable == "uptime": - sensors.append(BboxUptimeSensor(bbox_data, variable, name)) - else: - sensors.append(BboxSensor(bbox_data, variable, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities: list[BboxSensor | BboxUptimeSensor] = [ + BboxSensor(bbox_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] + entities.extend( + [ + BboxUptimeSensor(bbox_data, name, description) + for description in SENSOR_TYPES_UPTIME + if description.key in monitored_variables + ] + ) - add_entities(sensors, True) + add_entities(entities, True) class BboxUptimeSensor(SensorEntity): """Bbox uptime sensor.""" - def __init__(self, bbox_data, sensor_type, name): + _attr_attribution = ATTRIBUTION + _attr_device_class = SensorDeviceClass.TIMESTAMP + + def __init__(self, bbox_data, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bbox_data = bbox_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICE_CLASS_TIMESTAMP def update(self): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() - uptime = utcnow() - timedelta( + self._attr_native_value = utcnow() - timedelta( seconds=self.bbox_data.router_infos["device"]["uptime"] ) - self._state = uptime.replace(microsecond=0).isoformat() class BboxSensor(SensorEntity): """Implementation of a Bbox sensor.""" - def __init__(self, bbox_data, sensor_type, name): + _attr_attribution = ATTRIBUTION + + def __init__(self, bbox_data, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bbox_data = bbox_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() - if self.type == "down_max_bandwidth": - self._state = round(self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2) - elif self.type == "up_max_bandwidth": - self._state = round(self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2) - elif self.type == "current_down_bandwidth": - self._state = round(self.bbox_data.data["rx"]["bandwidth"] / 1000, 2) - elif self.type == "current_up_bandwidth": - self._state = round(self.bbox_data.data["tx"]["bandwidth"] / 1000, 2) - elif self.type == "number_of_reboots": - self._state = self.bbox_data.router_infos["device"]["numberofboots"] + sensor_type = self.entity_description.key + if sensor_type == "down_max_bandwidth": + self._attr_native_value = round( + self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2 + ) + elif sensor_type == "up_max_bandwidth": + self._attr_native_value = round( + self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2 + ) + elif sensor_type == "current_down_bandwidth": + self._attr_native_value = round( + self.bbox_data.data["rx"]["bandwidth"] / 1000, 2 + ) + elif sensor_type == "current_up_bandwidth": + self._attr_native_value = round( + self.bbox_data.data["tx"]["bandwidth"] / 1000, 2 + ) + elif sensor_type == "number_of_reboots": + self._attr_native_value = self.bbox_data.router_infos["device"][ + "numberofboots" + ] class BboxData: diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 9bf935f3c4f5c..afb51734a9e07 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -2,16 +2,12 @@ from beewi_smartclim import BeewiSmartClimPoller # pylint: disable=import-error import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - CONF_MAC, - CONF_NAME, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_CELSIUS, +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, ) +from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv # Default values @@ -19,9 +15,9 @@ # Sensor config SENSOR_TYPES = [ - [DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS], - [DEVICE_CLASS_HUMIDITY, "Humidity", PERCENTAGE], - [DEVICE_CLASS_BATTERY, "Battery", PERCENTAGE], + [SensorDeviceClass.TEMPERATURE, "Temperature", TEMP_CELSIUS], + [SensorDeviceClass.HUMIDITY, "Humidity", PERCENTAGE], + [SensorDeviceClass.BATTERY, "Battery", PERCENTAGE], ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -61,44 +57,19 @@ class BeewiSmartclimSensor(SensorEntity): def __init__(self, poller, name, mac, device, unit): """Initialize the sensor.""" self._poller = poller - self._name = name - self._mac = mac + self._attr_name = name self._device = device - self._unit = unit - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor. State is returned in Celsius.""" - return self._state - - @property - def device_class(self): - """Device class of this entity.""" - return self._device - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._mac}_{self._device}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + self._attr_native_unit_of_measurement = unit + self._attr_device_class = self._device + self._attr_unique_id = f"{mac}_{device}" def update(self): """Fetch new state data from the poller.""" self._poller.update_sensor() - self._state = None - if self._device == DEVICE_CLASS_TEMPERATURE: - self._state = self._poller.get_temperature() - if self._device == DEVICE_CLASS_HUMIDITY: - self._state = self._poller.get_humidity() - if self._device == DEVICE_CLASS_BATTERY: - self._state = self._poller.get_battery() + self._attr_native_value = None + if self._device == SensorDeviceClass.TEMPERATURE: + self._attr_native_value = self._poller.get_temperature() + if self._device == SensorDeviceClass.HUMIDITY: + self._attr_native_value = self._poller.get_humidity() + if self._device == SensorDeviceClass.BATTERY: + self._attr_native_value = self._poller.get_battery() diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py index 5b708ae263074..a25a645d47feb 100644 --- a/homeassistant/components/bh1750/sensor.py +++ b/homeassistant/components/bh1750/sensor.py @@ -6,8 +6,12 @@ import smbus import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_NAME, DEVICE_CLASS_ILLUMINANCE, LIGHT_LUX +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) +from homeassistant.const import CONF_NAME, LIGHT_LUX import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -60,6 +64,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BH1750 sensor.""" + _LOGGER.warning( + "The BH1750 integration is deprecated and will be removed " + "in Home Assistant Core 2022.4; this integration is removed under " + "Architectural Decision Record 0019, more information can be found here: " + "https://github.com/home-assistant/architecture/blob/master/adr/0019-GPIO.md" + ) name = config[CONF_NAME] bus_number = config[CONF_I2C_BUS] @@ -96,42 +106,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BH1750Sensor(SensorEntity): """Implementation of the BH1750 sensor.""" + _attr_device_class = SensorDeviceClass.ILLUMINANCE + def __init__(self, bh1750_sensor, name, unit, multiplier=1.0): """Initialize the sensor.""" - self._name = name - self._unit_of_measurement = unit + self._attr_name = name + self._attr_native_unit_of_measurement = unit self._multiplier = multiplier self.bh1750_sensor = bh1750_sensor - if self.bh1750_sensor.light_level >= 0: - self._state = int(round(self.bh1750_sensor.light_level)) - else: - self._state = None - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> int: - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_ILLUMINANCE async def async_update(self): """Get the latest data from the BH1750 and update the states.""" await self.hass.async_add_executor_job(self.bh1750_sensor.update) if self.bh1750_sensor.sample_ok and self.bh1750_sensor.light_level >= 0: - self._state = int(round(self.bh1750_sensor.light_level * self._multiplier)) + self._attr_native_value = int( + round(self.bh1750_sensor.light_level * self._multiplier) + ) else: _LOGGER.warning( "Bad Update of sensor.%s: %s", self.name, self.bh1750_sensor.light_level diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index f022509f9dea5..dc2d302012f3c 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,19 +1,24 @@ """Component to interface with binary sensors.""" +from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging +from typing import Literal, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent - -# mypy: allow-untyped-defs, no-check-untyped-defs +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -22,109 +27,127 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" -# On means low, Off means normal -DEVICE_CLASS_BATTERY = "battery" -# On means charging, Off means not charging -DEVICE_CLASS_BATTERY_CHARGING = "battery_charging" +class BinarySensorDeviceClass(StrEnum): + """Device class for binary sensors.""" + + # On means low, Off means normal + BATTERY = "battery" + + # On means charging, Off means not charging + BATTERY_CHARGING = "battery_charging" + + # On means cold, Off means normal + COLD = "cold" + + # On means connected, Off means disconnected + CONNECTIVITY = "connectivity" -# On means cold, Off means normal -DEVICE_CLASS_COLD = "cold" + # On means open, Off means closed + DOOR = "door" -# On means connected, Off means disconnected -DEVICE_CLASS_CONNECTIVITY = "connectivity" + # On means open, Off means closed + GARAGE_DOOR = "garage_door" -# On means open, Off means closed -DEVICE_CLASS_DOOR = "door" + # On means gas detected, Off means no gas (clear) + GAS = "gas" -# On means open, Off means closed -DEVICE_CLASS_GARAGE_DOOR = "garage_door" + # On means hot, Off means normal + HEAT = "heat" -# On means gas detected, Off means no gas (clear) -DEVICE_CLASS_GAS = "gas" + # On means light detected, Off means no light + LIGHT = "light" -# On means hot, Off means normal -DEVICE_CLASS_HEAT = "heat" + # On means open (unlocked), Off means closed (locked) + LOCK = "lock" -# On means light detected, Off means no light -DEVICE_CLASS_LIGHT = "light" + # On means wet, Off means dry + MOISTURE = "moisture" -# On means open (unlocked), Off means closed (locked) -DEVICE_CLASS_LOCK = "lock" + # On means motion detected, Off means no motion (clear) + MOTION = "motion" -# On means wet, Off means dry -DEVICE_CLASS_MOISTURE = "moisture" + # On means moving, Off means not moving (stopped) + MOVING = "moving" -# On means motion detected, Off means no motion (clear) -DEVICE_CLASS_MOTION = "motion" + # On means occupied, Off means not occupied (clear) + OCCUPANCY = "occupancy" -# On means moving, Off means not moving (stopped) -DEVICE_CLASS_MOVING = "moving" + # On means open, Off means closed + OPENING = "opening" -# On means occupied, Off means not occupied (clear) -DEVICE_CLASS_OCCUPANCY = "occupancy" + # On means plugged in, Off means unplugged + PLUG = "plug" -# On means open, Off means closed -DEVICE_CLASS_OPENING = "opening" + # On means power detected, Off means no power + POWER = "power" -# On means plugged in, Off means unplugged -DEVICE_CLASS_PLUG = "plug" + # On means home, Off means away + PRESENCE = "presence" -# On means power detected, Off means no power -DEVICE_CLASS_POWER = "power" + # On means problem detected, Off means no problem (OK) + PROBLEM = "problem" -# On means home, Off means away -DEVICE_CLASS_PRESENCE = "presence" + # On means running, Off means not running + RUNNING = "running" -# On means problem detected, Off means no problem (OK) -DEVICE_CLASS_PROBLEM = "problem" + # On means unsafe, Off means safe + SAFETY = "safety" -# On means unsafe, Off means safe -DEVICE_CLASS_SAFETY = "safety" + # On means smoke detected, Off means no smoke (clear) + SMOKE = "smoke" -# On means smoke detected, Off means no smoke (clear) -DEVICE_CLASS_SMOKE = "smoke" + # On means sound detected, Off means no sound (clear) + SOUND = "sound" -# On means sound detected, Off means no sound (clear) -DEVICE_CLASS_SOUND = "sound" + # On means tampering detected, Off means no tampering (clear) + TAMPER = "tamper" -# On means vibration detected, Off means no vibration -DEVICE_CLASS_VIBRATION = "vibration" + # On means update available, Off means up-to-date + UPDATE = "update" -# On means open, Off means closed -DEVICE_CLASS_WINDOW = "window" + # On means vibration detected, Off means no vibration + VIBRATION = "vibration" -DEVICE_CLASSES = [ - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_COLD, - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GARAGE_DOOR, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HEAT, - DEVICE_CLASS_LIGHT, - DEVICE_CLASS_LOCK, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_MOVING, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_PLUG, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESENCE, - DEVICE_CLASS_PROBLEM, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_SOUND, - DEVICE_CLASS_VIBRATION, - DEVICE_CLASS_WINDOW, -] + # On means open, Off means closed + WINDOW = "window" -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)) -async def async_setup(hass, config): +# DEVICE_CLASS* below are deprecated as of 2021.12 +# use the BinarySensorDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] +DEVICE_CLASS_BATTERY = BinarySensorDeviceClass.BATTERY.value +DEVICE_CLASS_BATTERY_CHARGING = BinarySensorDeviceClass.BATTERY_CHARGING.value +DEVICE_CLASS_COLD = BinarySensorDeviceClass.COLD.value +DEVICE_CLASS_CONNECTIVITY = BinarySensorDeviceClass.CONNECTIVITY.value +DEVICE_CLASS_DOOR = BinarySensorDeviceClass.DOOR.value +DEVICE_CLASS_GARAGE_DOOR = BinarySensorDeviceClass.GARAGE_DOOR.value +DEVICE_CLASS_GAS = BinarySensorDeviceClass.GAS.value +DEVICE_CLASS_HEAT = BinarySensorDeviceClass.HEAT.value +DEVICE_CLASS_LIGHT = BinarySensorDeviceClass.LIGHT.value +DEVICE_CLASS_LOCK = BinarySensorDeviceClass.LOCK.value +DEVICE_CLASS_MOISTURE = BinarySensorDeviceClass.MOISTURE.value +DEVICE_CLASS_MOTION = BinarySensorDeviceClass.MOTION.value +DEVICE_CLASS_MOVING = BinarySensorDeviceClass.MOVING.value +DEVICE_CLASS_OCCUPANCY = BinarySensorDeviceClass.OCCUPANCY.value +DEVICE_CLASS_OPENING = BinarySensorDeviceClass.OPENING.value +DEVICE_CLASS_PLUG = BinarySensorDeviceClass.PLUG.value +DEVICE_CLASS_POWER = BinarySensorDeviceClass.POWER.value +DEVICE_CLASS_PRESENCE = BinarySensorDeviceClass.PRESENCE.value +DEVICE_CLASS_PROBLEM = BinarySensorDeviceClass.PROBLEM.value +DEVICE_CLASS_RUNNING = BinarySensorDeviceClass.RUNNING.value +DEVICE_CLASS_SAFETY = BinarySensorDeviceClass.SAFETY.value +DEVICE_CLASS_SMOKE = BinarySensorDeviceClass.SMOKE.value +DEVICE_CLASS_SOUND = BinarySensorDeviceClass.SOUND.value +DEVICE_CLASS_TAMPER = BinarySensorDeviceClass.TAMPER.value +DEVICE_CLASS_UPDATE = BinarySensorDeviceClass.UPDATE.value +DEVICE_CLASS_VIBRATION = BinarySensorDeviceClass.VIBRATION.value +DEVICE_CLASS_WINDOW = BinarySensorDeviceClass.WINDOW.value + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for binary sensors.""" component = hass.data[DOMAIN] = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL @@ -134,42 +157,51 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class BinarySensorEntityDescription(EntityDescription): + """A class that describes binary sensor entities.""" + + device_class: BinarySensorDeviceClass | str | None = None class BinarySensorEntity(Entity): """Represent a binary sensor.""" + entity_description: BinarySensorEntityDescription + _attr_device_class: BinarySensorDeviceClass | str | None + _attr_is_on: bool | None = None + _attr_state: None = None + @property - def is_on(self): - """Return true if the binary sensor is on.""" + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class return None @property - def state(self): - """Return the state of the binary sensor.""" - return STATE_ON if self.is_on else STATE_OFF + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._attr_is_on + @final @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return None - - -class BinarySensorDevice(BinarySensorEntity): - """Represent a binary sensor (for backwards compatibility).""" - - def __init_subclass__(cls, **kwargs): - """Print deprecation warning.""" - super().__init_subclass__(**kwargs) - _LOGGER.warning( - "BinarySensorDevice is deprecated, modify %s to extend BinarySensorEntity", - cls.__name__, - ) + def state(self) -> Literal["on", "off"] | None: + """Return the state of the binary sensor.""" + if (is_on := self.is_on) is None: + return None + return STATE_ON if is_on else STATE_OFF diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 8c5066342005a..6f1a0ba4f5f50 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -4,9 +4,10 @@ import voluptuous as vol from homeassistant.components.device_automation.const import CONF_IS_OFF, CONF_IS_ON -from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE +from homeassistant.const import CONF_ENTITY_ID, CONF_FOR, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.entity_registry import ( async_entries_for_device, async_get_registry, @@ -33,14 +34,19 @@ DEVICE_CLASS_POWER, DEVICE_CLASS_PRESENCE, DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_RUNNING, DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_TAMPER, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, DOMAIN, ) +# mypy: disallow-any-generics + DEVICE_CLASS_NONE = "none" CONF_IS_BAT_LOW = "is_bat_low" @@ -75,12 +81,18 @@ CONF_IS_NOT_PRESENT = "is_not_present" CONF_IS_PROBLEM = "is_problem" CONF_IS_NO_PROBLEM = "is_no_problem" +CONF_IS_RUNNING = "is_running" +CONF_IS_NOT_RUNNING = "is_not_running" CONF_IS_UNSAFE = "is_unsafe" CONF_IS_NOT_UNSAFE = "is_not_unsafe" CONF_IS_SMOKE = "is_smoke" CONF_IS_NO_SMOKE = "is_no_smoke" CONF_IS_SOUND = "is_sound" CONF_IS_NO_SOUND = "is_no_sound" +CONF_IS_TAMPERED = "is_tampered" +CONF_IS_NOT_TAMPERED = "is_not_tampered" +CONF_IS_UPDATE = "is_update" +CONF_IS_NO_UPDATE = "is_no_update" CONF_IS_VIBRATION = "is_vibration" CONF_IS_NO_VIBRATION = "is_no_vibration" CONF_IS_OPEN = "is_open" @@ -104,8 +116,11 @@ CONF_IS_POWERED, CONF_IS_PRESENT, CONF_IS_PROBLEM, + CONF_IS_RUNNING, CONF_IS_SMOKE, CONF_IS_SOUND, + CONF_IS_TAMPERED, + CONF_IS_UPDATE, CONF_IS_UNSAFE, CONF_IS_VIBRATION, CONF_IS_ON, @@ -125,13 +140,16 @@ CONF_IS_NOT_PLUGGED_IN, CONF_IS_NOT_POWERED, CONF_IS_NOT_PRESENT, + CONF_IS_NOT_TAMPERED, CONF_IS_NOT_UNSAFE, CONF_IS_NO_GAS, CONF_IS_NO_LIGHT, CONF_IS_NO_MOTION, CONF_IS_NO_PROBLEM, + CONF_IS_NOT_RUNNING, CONF_IS_NO_SMOKE, CONF_IS_NO_SOUND, + CONF_IS_NO_UPDATE, CONF_IS_NO_VIBRATION, CONF_IS_OFF, ] @@ -183,9 +201,18 @@ {CONF_TYPE: CONF_IS_PROBLEM}, {CONF_TYPE: CONF_IS_NO_PROBLEM}, ], + DEVICE_CLASS_RUNNING: [ + {CONF_TYPE: CONF_IS_RUNNING}, + {CONF_TYPE: CONF_IS_NOT_RUNNING}, + ], DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_IS_UNSAFE}, {CONF_TYPE: CONF_IS_NOT_UNSAFE}], DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_IS_SMOKE}, {CONF_TYPE: CONF_IS_NO_SMOKE}], DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_IS_SOUND}, {CONF_TYPE: CONF_IS_NO_SOUND}], + DEVICE_CLASS_TAMPER: [ + {CONF_TYPE: CONF_IS_TAMPERED}, + {CONF_TYPE: CONF_IS_NOT_TAMPERED}, + ], + DEVICE_CLASS_UPDATE: [{CONF_TYPE: CONF_IS_UPDATE}, {CONF_TYPE: CONF_IS_NO_UPDATE}], DEVICE_CLASS_VIBRATION: [ {CONF_TYPE: CONF_IS_VIBRATION}, {CONF_TYPE: CONF_IS_NO_VIBRATION}, @@ -216,10 +243,7 @@ async def async_get_conditions( ] for entry in entries: - device_class = DEVICE_CLASS_NONE - state = hass.states.get(entry.entity_id) - if state and ATTR_DEVICE_CLASS in state.attributes: - device_class = state.attributes[ATTR_DEVICE_CLASS] + device_class = get_device_class(hass, entry.entity_id) or DEVICE_CLASS_NONE templates = ENTITY_CONDITIONS.get( device_class, ENTITY_CONDITIONS[DEVICE_CLASS_NONE] @@ -241,11 +265,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" - if config_validation: - config = CONDITION_SCHEMA(config) condition_type = config[CONF_TYPE] if condition_type in IS_ON: stat = "on" @@ -258,11 +280,15 @@ def async_condition_from_config( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] + state_config = cv.STATE_CONDITION_SCHEMA(state_config) + state_config = condition.state_validate_config(hass, state_config) return condition.state_from_config(state_config) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index b87a761a7a113..0f2c7a836a2de 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -1,14 +1,15 @@ """Provides device triggers for binary sensors.""" import voluptuous as vol -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.const import ( CONF_TURNED_OFF, CONF_TURNED_ON, ) from homeassistant.components.homeassistant.triggers import state as state_trigger -from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE +from homeassistant.const import CONF_ENTITY_ID, CONF_FOR, CONF_TYPE from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.entity_registry import async_entries_for_device from . import ( @@ -31,9 +32,12 @@ DEVICE_CLASS_POWER, DEVICE_CLASS_PRESENCE, DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_RUNNING, DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_TAMPER, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, DOMAIN, @@ -75,12 +79,18 @@ CONF_NOT_PRESENT = "not_present" CONF_PROBLEM = "problem" CONF_NO_PROBLEM = "no_problem" +CONF_RUNNING = "running" +CONF_NOT_RUNNING = "not_running" CONF_UNSAFE = "unsafe" CONF_NOT_UNSAFE = "not_unsafe" CONF_SMOKE = "smoke" CONF_NO_SMOKE = "no_smoke" CONF_SOUND = "sound" CONF_NO_SOUND = "no_sound" +CONF_TAMPERED = "tampered" +CONF_NOT_TAMPERED = "not_tampered" +CONF_UPDATE = "update" +CONF_NO_UPDATE = "no_update" CONF_VIBRATION = "vibration" CONF_NO_VIBRATION = "no_vibration" CONF_OPENED = "opened" @@ -104,10 +114,13 @@ CONF_POWERED, CONF_PRESENT, CONF_PROBLEM, + CONF_RUNNING, CONF_SMOKE, CONF_SOUND, CONF_UNSAFE, + CONF_UPDATE, CONF_VIBRATION, + CONF_TAMPERED, CONF_TURNED_ON, ] @@ -124,11 +137,13 @@ CONF_NOT_PLUGGED_IN, CONF_NOT_POWERED, CONF_NOT_PRESENT, + CONF_NOT_TAMPERED, CONF_NOT_UNSAFE, CONF_NO_GAS, CONF_NO_LIGHT, CONF_NO_MOTION, CONF_NO_PROBLEM, + CONF_NOT_RUNNING, CONF_NO_SMOKE, CONF_NO_SOUND, CONF_NO_VIBRATION, @@ -165,9 +180,12 @@ DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWERED}, {CONF_TYPE: CONF_NOT_POWERED}], DEVICE_CLASS_PRESENCE: [{CONF_TYPE: CONF_PRESENT}, {CONF_TYPE: CONF_NOT_PRESENT}], DEVICE_CLASS_PROBLEM: [{CONF_TYPE: CONF_PROBLEM}, {CONF_TYPE: CONF_NO_PROBLEM}], + DEVICE_CLASS_RUNNING: [{CONF_TYPE: CONF_RUNNING}, {CONF_TYPE: CONF_NOT_RUNNING}], DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_UNSAFE}, {CONF_TYPE: CONF_NOT_UNSAFE}], DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_SMOKE}, {CONF_TYPE: CONF_NO_SMOKE}], DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_SOUND}, {CONF_TYPE: CONF_NO_SOUND}], + DEVICE_CLASS_UPDATE: [{CONF_TYPE: CONF_UPDATE}, {CONF_TYPE: CONF_NO_UPDATE}], + DEVICE_CLASS_TAMPER: [{CONF_TYPE: CONF_TAMPERED}, {CONF_TYPE: CONF_NOT_TAMPERED}], DEVICE_CLASS_VIBRATION: [ {CONF_TYPE: CONF_VIBRATION}, {CONF_TYPE: CONF_NO_VIBRATION}, @@ -177,7 +195,7 @@ } -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON), @@ -202,7 +220,7 @@ async def async_attach_trigger(hass, config, action, automation_info): if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) @@ -220,10 +238,7 @@ async def async_get_triggers(hass, device_id): ] for entry in entries: - device_class = DEVICE_CLASS_NONE - state = hass.states.get(entry.entity_id) - if state: - device_class = state.attributes.get(ATTR_DEVICE_CLASS) + device_class = get_device_class(hass, entry.entity_id) or DEVICE_CLASS_NONE templates = ENTITY_TRIGGERS.get( device_class, ENTITY_TRIGGERS[DEVICE_CLASS_NONE] diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 7380d1be576bb..e2167c24f8ab0 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -32,12 +32,18 @@ "is_not_present": "{entity_name} is not present", "is_problem": "{entity_name} is detecting problem", "is_no_problem": "{entity_name} is not detecting problem", + "is_running": "{entity_name} is running", + "is_not_running": "{entity_name} is not running", "is_unsafe": "{entity_name} is unsafe", "is_not_unsafe": "{entity_name} is safe", "is_smoke": "{entity_name} is detecting smoke", "is_no_smoke": "{entity_name} is not detecting smoke", "is_sound": "{entity_name} is detecting sound", "is_no_sound": "{entity_name} is not detecting sound", + "is_tampered": "{entity_name} is detecting tampering", + "is_not_tampered": "{entity_name} is not detecting tampering", + "is_update": "{entity_name} has an update available", + "is_no_update": "{entity_name} is up-to-date", "is_vibration": "{entity_name} is detecting vibration", "is_no_vibration": "{entity_name} is not detecting vibration", "is_open": "{entity_name} is open", @@ -76,12 +82,18 @@ "not_present": "{entity_name} not present", "problem": "{entity_name} started detecting problem", "no_problem": "{entity_name} stopped detecting problem", + "running": "{entity_name} started running", + "not_running": "{entity_name} is no longer running", "unsafe": "{entity_name} became unsafe", "not_unsafe": "{entity_name} became safe", "smoke": "{entity_name} started detecting smoke", "no_smoke": "{entity_name} stopped detecting smoke", "sound": "{entity_name} started detecting sound", "no_sound": "{entity_name} stopped detecting sound", + "tampered": "{entity_name} started detecting tampering", + "not_tampered": "{entity_name} stopped detecting tampering", + "update": "{entity_name} got an update available", + "no_update": "{entity_name} became up-to-date", "vibration": "{entity_name} started detecting vibration", "no_vibration": "{entity_name} stopped detecting vibration", "opened": "{entity_name} opened", @@ -163,6 +175,10 @@ "off": "OK", "on": "Problem" }, + "running": { + "off": "Not running", + "on": "Running" + }, "safety": { "off": "Safe", "on": "Unsafe" @@ -175,6 +191,10 @@ "off": "[%key:component::binary_sensor::state::gas::off%]", "on": "[%key:component::binary_sensor::state::gas::on%]" }, + "update": { + "off": "Up-to-date", + "on": "Update available" + }, "vibration": { "off": "[%key:component::binary_sensor::state::gas::off%]", "on": "[%key:component::binary_sensor::state::gas::on%]" @@ -187,5 +207,18 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "device_class": { + "cold": "cold", + "gas": "gas", + "heat": "heat", + "moisture": "moisture", + "motion": "motion", + "occupancy": "occupancy", + "power": "power", + "problem": "problem", + "smoke": "smoke", + "sound": "sound", + "vibration": "vibration" } -} +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/af.json b/homeassistant/components/binary_sensor/translations/af.json index c0988c3aa6894..37dab20a22ebb 100644 --- a/homeassistant/components/binary_sensor/translations/af.json +++ b/homeassistant/components/binary_sensor/translations/af.json @@ -1,4 +1,15 @@ { + "device_class": { + "cold": "hideg", + "gas": "g\u00e1z", + "heat": "h\u0151", + "moisture": "p\u00e1ratartalom", + "motion": "mozg\u00e1s", + "problem": "probl\u00e9ma", + "smoke": "f\u00fcst", + "sound": "hang", + "vibration": "rezg\u00e9s" + }, "state": { "_": { "off": "Af", diff --git a/homeassistant/components/binary_sensor/translations/ar.json b/homeassistant/components/binary_sensor/translations/ar.json index 7782421ef1cd1..0d835aea3f39f 100644 --- a/homeassistant/components/binary_sensor/translations/ar.json +++ b/homeassistant/components/binary_sensor/translations/ar.json @@ -32,6 +32,9 @@ "off": "\u0637\u0628\u064a\u0639\u064a", "on": "\u062d\u0627\u0631" }, + "light": { + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641 \u0639\u0646 \u0627\u0644\u0636\u0648\u0621" + }, "lock": { "off": "\u0645\u0642\u0641\u0644", "on": "\u063a\u064a\u0631 \u0645\u0642\u0641\u0644" diff --git a/homeassistant/components/binary_sensor/translations/bg.json b/homeassistant/components/binary_sensor/translations/bg.json index 2d969af731e2d..621625cb457ad 100644 --- a/homeassistant/components/binary_sensor/translations/bg.json +++ b/homeassistant/components/binary_sensor/translations/bg.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", "is_no_sound": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0437\u0432\u0443\u043a", + "is_no_update": "{entity_name} \u0435 \u0430\u043a\u0442\u0443\u0430\u043b\u0435\u043d", "is_no_vibration": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438", "is_not_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u0435 \u0437\u0430\u0440\u0435\u0434\u0435\u043d\u0430", "is_not_cold": "{entity_name} \u043d\u0435 \u0435 \u0441\u0442\u0443\u0434\u0435\u043d", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", "is_sound": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0437\u0432\u0443\u043a", "is_unsafe": "{entity_name} \u043d\u0435 \u0435 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "is_update": "{entity_name} \u0438\u043c\u0430 \u043d\u0430\u043b\u0438\u0447\u043d\u0430 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f", "is_vibration": "{entity_name} \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", "no_smoke": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", "no_sound": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0437\u0432\u0443\u043a", + "no_update": "{entity_name} \u0435 \u0430\u043a\u0442\u0443\u0430\u043b\u0435\u043d", "no_vibration": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438", "not_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u043d\u0435 \u0435 \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430", "not_cold": "{entity_name} \u0441\u0435 \u0441\u0442\u043e\u043f\u043b\u0438", @@ -86,9 +89,21 @@ "turned_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", "turned_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", "unsafe": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u043e\u043f\u0430\u0441\u0435\u043d", + "update": "{entity_name} \u0438\u043c\u0430 \u043d\u0430\u043b\u0438\u0447\u043d\u0430 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f", "vibration": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" } }, + "device_class": { + "cold": "\u0441\u0442\u0443\u0434", + "gas": "\u0433\u0430\u0437", + "heat": "\u0442\u043e\u043f\u043b\u0438\u043d\u0430", + "moisture": "\u0432\u043b\u0430\u0433\u0430", + "motion": "\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "problem": "\u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "smoke": "\u0434\u0438\u043c", + "sound": "\u0437\u0432\u0443\u043a", + "vibration": "\u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044f" + }, "state": { "_": { "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", @@ -162,6 +177,10 @@ "off": "\u0427\u0438\u0441\u0442\u043e", "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d" }, + "update": { + "off": "\u0410\u043a\u0442\u0443\u0430\u043b\u0435\u043d", + "on": "\u041d\u0430\u043b\u0438\u0447\u043d\u0430 \u0435 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f" + }, "vibration": { "off": "\u0427\u0438\u0441\u0442\u043e", "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u0430" diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json index 9c92a50246a93..a806bc3908ef6 100644 --- a/homeassistant/components/binary_sensor/translations/ca.json +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} no est\u00e0 detectant cap problema", "is_no_smoke": "{entity_name} no detecta fum", "is_no_sound": "{entity_name} no detecta so", + "is_no_update": "{entity_name} est\u00e0 actualitzat/da", "is_no_vibration": "{entity_name} no detecta vibraci\u00f3", "is_not_bat_low": "Bateria de {entity_name} normal", "is_not_cold": "{entity_name} no est\u00e0 fred", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} est\u00e0 desendollat", "is_not_powered": "{entity_name} no est\u00e0 alimentat", "is_not_present": "{entity_name} no est\u00e0 present", + "is_not_running": "{entity_name} no est\u00e0 funcionant", + "is_not_tampered": "{entity_name} no detecta manipulaci\u00f3", "is_not_unsafe": "{entity_name} \u00e9s segur", "is_occupied": "{entity_name} est\u00e0 ocupat", "is_off": "{entity_name} est\u00e0 apagat", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} est\u00e0 alimentat", "is_present": "{entity_name} est\u00e0 present", "is_problem": "{entity_name} est\u00e0 detectant un problema", + "is_running": "{entity_name} est\u00e0 funcionant", "is_smoke": "{entity_name} est\u00e0 detectant fum", "is_sound": "{entity_name} est\u00e0 detectant so", + "is_tampered": "{entity_name} detecta manipulaci\u00f3", "is_unsafe": "{entity_name} \u00e9s insegur", + "is_update": "{entity_name} t\u00e9 una actualitzaci\u00f3 disponible", "is_vibration": "{entity_name} est\u00e0 detectant vibraci\u00f3" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} est\u00e0 connectat", "gas": "{entity_name} ha comen\u00e7at a detectar gas", "hot": "{entity_name} es torna calent", + "is_not_tampered": "{entity_name} ha deixat de detectar manipulaci\u00f3", + "is_tampered": "{entity_name} ha comen\u00e7at a detectar manipulaci\u00f3", "light": "{entity_name} ha comen\u00e7at a detectar llum", "locked": "{entity_name} est\u00e0 bloquejat", "moist": "{entity_name} es torna humit", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} ha deixat de detectar un problema", "no_smoke": "{entity_name} ha deixat de detectar fum", "no_sound": "{entity_name} ha deixat de detectar so", + "no_update": "{entity_name} s'ha actualitzat", "no_vibration": "{entity_name} ha deixat de detectar vibraci\u00f3", "not_bat_low": "Bateria de {entity_name} normal", "not_cold": "{entity_name} es torna no-fred", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} desendollat", "not_powered": "{entity_name} no est\u00e0 alimentat", "not_present": "{entity_name} no est\u00e0 present", + "not_running": "{entity_name} para de funcionar", + "not_tampered": "{entity_name} deixa de detectar manipulaci\u00f3", "not_unsafe": "{entity_name} es torna segur", "occupied": "{entity_name} s'ocupa", "opened": "{entity_name} s'ha obert", @@ -81,14 +92,30 @@ "powered": "{entity_name} alimentat", "present": "{entity_name} present", "problem": "{entity_name} ha comen\u00e7at a detectar un problema", + "running": "{entity_name} comen\u00e7a a funcionar", "smoke": "{entity_name} ha comen\u00e7at a detectar fum", "sound": "{entity_name} ha comen\u00e7at a detectar so", + "tampered": "{entity_name} comen\u00e7a a detectar manipulaci\u00f3", "turned_off": "{entity_name} apagat", "turned_on": "{entity_name} enc\u00e8s", "unsafe": "{entity_name} es torna insegur", + "update": "{entity_name} obt\u00e9 una nova actualitzaci\u00f3 disponible", "vibration": "{entity_name} ha comen\u00e7at a detectar vibraci\u00f3" } }, + "device_class": { + "cold": "fred", + "gas": "gas", + "heat": "calor", + "moisture": "humitat", + "motion": "moviment", + "occupancy": "ocupaci\u00f3", + "power": "pot\u00e8ncia", + "problem": "problema", + "smoke": "fum", + "sound": "so", + "vibration": "vibraci\u00f3" + }, "state": { "_": { "off": "OFF", @@ -166,6 +193,10 @@ "off": "OK", "on": "Problema" }, + "running": { + "off": "No funcionant", + "on": "En funcionament" + }, "safety": { "off": "Segur", "on": "No segur" @@ -178,6 +209,10 @@ "off": "Lliure", "on": "Detectat" }, + "update": { + "off": "Actualitzat/da", + "on": "Actualitzaci\u00f3 disponible" + }, "vibration": { "off": "Lliure", "on": "Detectat" diff --git a/homeassistant/components/binary_sensor/translations/cs.json b/homeassistant/components/binary_sensor/translations/cs.json index 90f25332bdb16..25b82e54de72b 100644 --- a/homeassistant/components/binary_sensor/translations/cs.json +++ b/homeassistant/components/binary_sensor/translations/cs.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} nehl\u00e1s\u00ed probl\u00e9m", "is_no_smoke": "{entity_name} nedetekuje kou\u0159", "is_no_sound": "{entity_name} nedetekuje zvuk", + "is_no_update": "{entity_name} je aktu\u00e1ln\u00ed", "is_no_vibration": "{entity_name} nedetekuje vibrace", "is_not_bat_low": "{entity_name} baterie v norm\u00e1lu", "is_not_cold": "{entity_name} nen\u00ed studen\u00fd", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} detekuje kou\u0159", "is_sound": "{entity_name} detekuje zvuk", "is_unsafe": "{entity_name} nen\u00ed bezpe\u010dno", + "is_update": "{entity_name} m\u00e1 k dispozici aktualizaci", "is_vibration": "{entity_name} detekuje vibrace" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} p\u0159estalo detekovat probl\u00e9m", "no_smoke": "{entity_name} p\u0159estalo detekovat kou\u0159", "no_sound": "{entity_name} p\u0159estalo detekovat zvuk", + "no_update": "{entity_name} se stalo aktu\u00e1ln\u00ed", "no_vibration": "{entity_name} p\u0159estalo detekovat vibrace", "not_bat_low": "{entity_name} baterie v norm\u00e1lu", "not_cold": "{entity_name} p\u0159estal b\u00fdt studen\u00fd", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} vypnuto", "turned_on": "{entity_name} zapnuto", "unsafe": "{entity_name} hl\u00e1s\u00ed ohro\u017een\u00ed", + "update": "{entity_name} m\u00e1 k dispozici aktualizaci", "vibration": "{entity_name} za\u010dalo detekovat vibrace" } }, @@ -178,6 +182,10 @@ "off": "Ticho", "on": "Zachycen zvuk" }, + "update": { + "off": "Aktu\u00e1ln\u00ed", + "on": "Aktualizace k dispozici" + }, "vibration": { "off": "Klid", "on": "Zji\u0161t\u011bny vibrace" diff --git a/homeassistant/components/binary_sensor/translations/de.json b/homeassistant/components/binary_sensor/translations/de.json index a78befb7965e4..c84a91e32a53b 100644 --- a/homeassistant/components/binary_sensor/translations/de.json +++ b/homeassistant/components/binary_sensor/translations/de.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} erkennt kein Problem", "is_no_smoke": "{entity_name} erkennt keinen Rauch", "is_no_sound": "{entity_name} erkennt keine Ger\u00e4usche", + "is_no_update": "{entity_name} ist aktuell", "is_no_vibration": "{entity_name} erkennt keine Vibrationen", "is_not_bat_low": "{entity_name} Batterie ist normal", "is_not_cold": "{entity_name} ist nicht kalt", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} ist nicht angeschlossen", "is_not_powered": "{entity_name} wird nicht mit Strom versorgt", "is_not_present": "{entity_name} ist nicht vorhanden", + "is_not_running": "{entity_name} wird nicht ausgef\u00fchrt", + "is_not_tampered": "{entity_name} erkennt keine Manipulationen", "is_not_unsafe": "{entity_name} ist sicher", "is_occupied": "{entity_name} ist besch\u00e4ftigt / besetzt", "is_off": "{entity_name} ist ausgeschaltet", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} wird mit Strom versorgt", "is_present": "{entity_name} ist vorhanden", "is_problem": "{entity_name} hat ein Problem festgestellt", + "is_running": "{entity_name} wird ausgef\u00fchrt", "is_smoke": "{entity_name} hat Rauch detektiert", "is_sound": "{entity_name} hat Ger\u00e4usche detektiert", + "is_tampered": "{entity_name} erkennt Manipulationen", "is_unsafe": "{entity_name} ist unsicher", + "is_update": "{entity_name} hat ein Update verf\u00fcgbar", "is_vibration": "{entity_name} erkennt Vibrationen." }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} verbunden", "gas": "{entity_name} hat Gas detektiert", "hot": "{entity_name} wurde hei\u00df", + "is_not_tampered": "{entity_name} hat aufgeh\u00f6rt, Manipulationen zu erkennen", + "is_tampered": "{entity_name} hat begonnen, Manipulationen zu erkennen", "light": "{entity_name} hat Licht detektiert", "locked": "{entity_name} gesperrt", "moist": "{entity_name} wurde feucht", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} hat kein Problem mehr erkannt", "no_smoke": "{entity_name} hat keinen Rauch mehr erkannt", "no_sound": "{entity_name} hat keine Ger\u00e4usche mehr erkannt", + "no_update": "{entity_name} wurde auf den neuesten Stand gebracht", "no_vibration": "{entity_name}hat keine Vibrationen mehr erkannt", "not_bat_low": "{entity_name} Batterie normal", "not_cold": "{entity_name} w\u00e4rmte auf", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} ist nicht angeschlossen", "not_powered": "{entity_name} nicht mit Strom versorgt", "not_present": "{entity_name} nicht anwesend", + "not_running": "{entity_name} wird nicht mehr ausgef\u00fchrt", + "not_tampered": "{entity_name} hat aufgeh\u00f6rt, Manipulationen zu erkennen", "not_unsafe": "{entity_name} wurde sicher", "occupied": "{entity_name} wurde besch\u00e4ftigt / besetzt", "opened": "{entity_name} ge\u00f6ffnet", @@ -81,14 +92,30 @@ "powered": "{entity_name} wird mit Strom versorgt", "present": "{entity_name} anwesend", "problem": "{entity_name} hat ein Problem festgestellt", + "running": "{entity_name} ausgef\u00fchrt", "smoke": "{entity_name} detektiert Rauch", "sound": "{entity_name} detektiert Ger\u00e4usche", + "tampered": "{entity_name} hat begonnen, Manipulationen zu erkennen", "turned_off": "{entity_name} ausgeschaltet", "turned_on": "{entity_name} eingeschaltet", "unsafe": "{entity_name} ist unsicher", + "update": "{entity_name} hat ein Update verf\u00fcgbar", "vibration": "{entity_name} detektiert Vibrationen" } }, + "device_class": { + "cold": "K\u00e4lte", + "gas": "Gas", + "heat": "W\u00e4rme", + "moisture": "Feuchtigkeit", + "motion": "Bewegung", + "occupancy": "Belegung", + "power": "Energie", + "problem": "Problem", + "smoke": "Rauch", + "sound": "Ton", + "vibration": "Vibration" + }, "state": { "_": { "off": "Aus", @@ -139,8 +166,8 @@ "on": "Nass" }, "motion": { - "off": "Ruhig", - "on": "Bewegung erkannt" + "off": "Normal", + "on": "Erkannt" }, "moving": { "off": "Bewegt sich nicht", @@ -166,21 +193,29 @@ "off": "OK", "on": "Problem" }, + "running": { + "off": "Nicht ausgef\u00fchrt", + "on": "L\u00e4uft" + }, "safety": { "off": "Sicher", "on": "Unsicher" }, "smoke": { - "off": "OK", - "on": "Rauch erkannt" + "off": "Normal", + "on": "Erkannt" }, "sound": { - "off": "Stille", - "on": "Ger\u00e4usch erkannt" + "off": "Normal", + "on": "Erkannt" + }, + "update": { + "off": "Aktuell", + "on": "Update verf\u00fcgbar" }, "vibration": { "off": "Normal", - "on": "Vibration" + "on": "Erkannt" }, "window": { "off": "Geschlossen", diff --git a/homeassistant/components/binary_sensor/translations/el.json b/homeassistant/components/binary_sensor/translations/el.json index f4ed1d55bc240..a3887149bee6d 100644 --- a/homeassistant/components/binary_sensor/translations/el.json +++ b/homeassistant/components/binary_sensor/translations/el.json @@ -1,4 +1,14 @@ { + "device_automation": { + "condition_type": { + "is_no_update": "{entity_name} \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03c9\u03bc\u03ad\u03bd\u03bf", + "is_update": "{entity_name} \u03ad\u03c7\u03b5\u03b9 \u03bc\u03b9\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + }, + "trigger_type": { + "no_update": "{entity_name} \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03b8\u03b7\u03ba\u03b5", + "update": "{entity_name} \u03ad\u03bb\u03b1\u03b2\u03b5 \u03bc\u03b9\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + } + }, "state": { "_": { "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc\u03c2", @@ -72,6 +82,10 @@ "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" }, + "update": { + "off": "\u03a0\u03bb\u03ae\u03c1\u03c9\u03c2 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03c9\u03bc\u03ad\u03bd\u03bf", + "on": "\u0394\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + }, "vibration": { "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" diff --git a/homeassistant/components/binary_sensor/translations/en.json b/homeassistant/components/binary_sensor/translations/en.json index 98c8a3a220a9a..a094b001c5a8f 100644 --- a/homeassistant/components/binary_sensor/translations/en.json +++ b/homeassistant/components/binary_sensor/translations/en.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} is not detecting problem", "is_no_smoke": "{entity_name} is not detecting smoke", "is_no_sound": "{entity_name} is not detecting sound", + "is_no_update": "{entity_name} is up-to-date", "is_no_vibration": "{entity_name} is not detecting vibration", "is_not_bat_low": "{entity_name} battery is normal", "is_not_cold": "{entity_name} is not cold", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} is unplugged", "is_not_powered": "{entity_name} is not powered", "is_not_present": "{entity_name} is not present", + "is_not_running": "{entity_name} is not running", + "is_not_tampered": "{entity_name} is not detecting tampering", "is_not_unsafe": "{entity_name} is safe", "is_occupied": "{entity_name} is occupied", "is_off": "{entity_name} is off", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} is powered", "is_present": "{entity_name} is present", "is_problem": "{entity_name} is detecting problem", + "is_running": "{entity_name} is running", "is_smoke": "{entity_name} is detecting smoke", "is_sound": "{entity_name} is detecting sound", + "is_tampered": "{entity_name} is detecting tampering", "is_unsafe": "{entity_name} is unsafe", + "is_update": "{entity_name} has an update available", "is_vibration": "{entity_name} is detecting vibration" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} connected", "gas": "{entity_name} started detecting gas", "hot": "{entity_name} became hot", + "is_not_tampered": "{entity_name} stopped detecting tampering", + "is_tampered": "{entity_name} started detecting tampering", "light": "{entity_name} started detecting light", "locked": "{entity_name} locked", "moist": "{entity_name} became moist", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} stopped detecting problem", "no_smoke": "{entity_name} stopped detecting smoke", "no_sound": "{entity_name} stopped detecting sound", + "no_update": "{entity_name} became up-to-date", "no_vibration": "{entity_name} stopped detecting vibration", "not_bat_low": "{entity_name} battery normal", "not_cold": "{entity_name} became not cold", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} unplugged", "not_powered": "{entity_name} not powered", "not_present": "{entity_name} not present", + "not_running": "{entity_name} is no longer running", + "not_tampered": "{entity_name} stopped detecting tampering", "not_unsafe": "{entity_name} became safe", "occupied": "{entity_name} became occupied", "opened": "{entity_name} opened", @@ -81,14 +92,30 @@ "powered": "{entity_name} powered", "present": "{entity_name} present", "problem": "{entity_name} started detecting problem", + "running": "{entity_name} started running", "smoke": "{entity_name} started detecting smoke", "sound": "{entity_name} started detecting sound", + "tampered": "{entity_name} started detecting tampering", "turned_off": "{entity_name} turned off", "turned_on": "{entity_name} turned on", "unsafe": "{entity_name} became unsafe", + "update": "{entity_name} got an update available", "vibration": "{entity_name} started detecting vibration" } }, + "device_class": { + "cold": "cold", + "gas": "gas", + "heat": "heat", + "moisture": "moisture", + "motion": "motion", + "occupancy": "occupancy", + "power": "power", + "problem": "problem", + "smoke": "smoke", + "sound": "sound", + "vibration": "vibration" + }, "state": { "_": { "off": "Off", @@ -166,6 +193,10 @@ "off": "OK", "on": "Problem" }, + "running": { + "off": "Not running", + "on": "Running" + }, "safety": { "off": "Safe", "on": "Unsafe" @@ -178,6 +209,10 @@ "off": "Clear", "on": "Detected" }, + "update": { + "off": "Up-to-date", + "on": "Update available" + }, "vibration": { "off": "Clear", "on": "Detected" diff --git a/homeassistant/components/binary_sensor/translations/es-419.json b/homeassistant/components/binary_sensor/translations/es-419.json index d8cc421909787..dad07f9b771f2 100644 --- a/homeassistant/components/binary_sensor/translations/es-419.json +++ b/homeassistant/components/binary_sensor/translations/es-419.json @@ -98,6 +98,10 @@ "off": "Normal", "on": "Baja" }, + "battery_charging": { + "off": "No esta cargando", + "on": "Cargando" + }, "cold": { "off": "Normal", "on": "Fr\u00edo" @@ -122,6 +126,10 @@ "off": "Normal", "on": "Caliente" }, + "light": { + "off": "Sin luz", + "on": "Luz detectada" + }, "lock": { "off": "Bloqueado", "on": "Desbloqueado" @@ -134,6 +142,10 @@ "off": "Despejado", "on": "Detectado" }, + "moving": { + "off": "Sin movimiento", + "on": "Movimiento" + }, "occupancy": { "off": "Despejado", "on": "Detectado" @@ -142,6 +154,10 @@ "off": "Cerrado", "on": "Abierto" }, + "plug": { + "off": "Desenchufado", + "on": "Enchufado" + }, "presence": { "off": "Fuera de casa", "on": "En Casa" diff --git a/homeassistant/components/binary_sensor/translations/es.json b/homeassistant/components/binary_sensor/translations/es.json index 05fc002ecb0e7..f72e08d593753 100644 --- a/homeassistant/components/binary_sensor/translations/es.json +++ b/homeassistant/components/binary_sensor/translations/es.json @@ -178,6 +178,10 @@ "off": "No detectado", "on": "Detectado" }, + "update": { + "off": "Actualizado", + "on": "Actualizaci\u00f3n disponible" + }, "vibration": { "off": "No detectado", "on": "Detectado" diff --git a/homeassistant/components/binary_sensor/translations/et.json b/homeassistant/components/binary_sensor/translations/et.json index 99fbec0b89ee3..f2c38f1bc7c01 100644 --- a/homeassistant/components/binary_sensor/translations/et.json +++ b/homeassistant/components/binary_sensor/translations/et.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} ei leia probleemi", "is_no_smoke": "{entity_name} ei tuvasta suitsu", "is_no_sound": "{entity_name} ei tuvasta heli", + "is_no_update": "{entity_name} on ajakohane", "is_no_vibration": "{entity_name} ei tuvasta vibratsiooni", "is_not_bat_low": "{entity_name} aku on laetud", "is_not_cold": "{entity_name} ei ole k\u00fclm", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} on lahti \u00fchendatud", "is_not_powered": "{entity_name} ei ole voolu all", "is_not_present": "{entity_name} puudub", + "is_not_running": "{entity_name} ei t\u00f6\u00f6ta", + "is_not_tampered": "{entity_name} ei tuvasta omavoli", "is_not_unsafe": "{entity_name} on turvaline", "is_occupied": "{entity_name} on h\u00f5ivatud", "is_off": "{entity_name} on v\u00e4lja l\u00fclitatud", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} on voolu all", "is_present": "{entity_name} on saadaval", "is_problem": "Olemil {entity_name} on probleem", + "is_running": "{entity_name} t\u00f6\u00f6tab", "is_smoke": "{entity_name} tuvastab suitsu", "is_sound": "{entity_name} tuvastab heli", + "is_tampered": "{entity_name} tuvastab omavolilise muutmist", "is_unsafe": "{entity_name} on ebaturvaline", + "is_update": "{entity_name} on saadaval uuendus", "is_vibration": "{entity_name} tuvastab vibratsiooni" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} on \u00fchendatud", "gas": "{entity_name} tuvastas gaasi(leket)", "hot": "{entity_name} muutus kuumaks", + "is_not_tampered": "{entity_name} l\u00f5petas omavolilise muutmise tuvastamise", + "is_tampered": "{entity_name} alustas omavolilise muutmise tuvastamist", "light": "{entity_name} tuvastas valgust", "locked": "{entity_name} on lukus", "moist": "{entity_name} muutus niiskeks", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} l\u00f5petas probleemi tuvastamise", "no_smoke": "{entity_name} l\u00f5petas suitsu tuvastamise", "no_sound": "{entity_name} l\u00f5petas heli tuvastamise", + "no_update": "{entity_name} on uuendatud", "no_vibration": "{entity_name} l\u00f5petas vibratsiooni tuvastamise", "not_bat_low": "{entity_name} aku on laetud", "not_cold": "{entity_name} ei ole enam k\u00fclm", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} \u00fchendati vooluv\u00f5rgust v\u00e4lja", "not_powered": "{entity_name} pole toidet", "not_present": "{entity_name} puudub", + "not_running": "{entity_name} ei t\u00f6\u00f6ta enam", + "not_tampered": "{entity_name} l\u00f5petas omavolilise muutmise tuvastamise", "not_unsafe": "{entity_name} muutus turvaliseks", "occupied": "{entity_name} h\u00f5ivati", "opened": "{entity_name} avanes", @@ -81,14 +92,30 @@ "powered": "{entity_name} l\u00fcltus voolu alla", "present": "{entity_name} on saadaval", "problem": "{entity_name} avastas probleemi", + "running": "{entity_name} alustas t\u00f6\u00f6d", "smoke": "{entity_name} tuvastas suitsu", "sound": "{entity_name} tuvastas heli", + "tampered": "{entity_name} tuvastas omavolilist muutmist", "turned_off": "{entity_name} l\u00fclitus v\u00e4lja", "turned_on": "{entity_name} l\u00fclitus sisse", "unsafe": "{entity_name} on ebaturvaline", + "update": "{entity_name} sai saadavaloleva uuenduse", "vibration": "{entity_name} registreeris vibratsiooni" } }, + "device_class": { + "cold": "jahutus", + "gas": "gaas", + "heat": "k\u00fcte", + "moisture": "niiskus", + "motion": "liikumine", + "occupancy": "h\u00f5ivatus", + "power": "v\u00f5imsus", + "problem": "probleem", + "smoke": "suits", + "sound": "heli", + "vibration": "vibratsioon" + }, "state": { "_": { "off": "V\u00e4ljas", @@ -166,6 +193,10 @@ "off": "OK", "on": "Probleem" }, + "running": { + "off": "Ei t\u00f6\u00f6ta", + "on": "T\u00f6\u00f6tab" + }, "safety": { "off": "Ohutu", "on": "Ohtlik" @@ -178,6 +209,10 @@ "off": "Puudub", "on": "Tuvastatud" }, + "update": { + "off": "Ajakohane", + "on": "Saadaval on uuendus" + }, "vibration": { "off": "Puudub", "on": "Tuvastatud" diff --git a/homeassistant/components/binary_sensor/translations/fr.json b/homeassistant/components/binary_sensor/translations/fr.json index ede13a68dc92f..b1a1d7ee351b9 100644 --- a/homeassistant/components/binary_sensor/translations/fr.json +++ b/homeassistant/components/binary_sensor/translations/fr.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} ne d\u00e9tecte pas de probl\u00e8me", "is_no_smoke": "{entity_name} ne d\u00e9tecte pas de fum\u00e9e", "is_no_sound": "{entity_name} ne d\u00e9tecte pas de son", + "is_no_update": "{entity_name} est \u00e0 jour", "is_no_vibration": "{entity_name} ne d\u00e9tecte pas de vibration", "is_not_bat_low": "{entity_name} batterie normale", "is_not_cold": "{entity_name} n'est pas froid", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} est d\u00e9branch\u00e9", "is_not_powered": "{entity_name} n'est pas aliment\u00e9", "is_not_present": "{entity_name} n'est pas pr\u00e9sent", + "is_not_running": "{entity_name} n'est pas en cours d'ex\u00e9cution", + "is_not_tampered": "{entity_name} ne d\u00e9tecte pas la falsification", "is_not_unsafe": "{entity_name} est en s\u00e9curit\u00e9", "is_occupied": "{entity_name} est occup\u00e9", "is_off": "{entity_name} est d\u00e9sactiv\u00e9", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} est aliment\u00e9", "is_present": "{entity_name} est pr\u00e9sent", "is_problem": "{entity_name} d\u00e9tecte un probl\u00e8me", + "is_running": "{entity_name} est en cours d'ex\u00e9cution", "is_smoke": "{entity_name} d\u00e9tecte de la fum\u00e9e", "is_sound": "{entity_name} d\u00e9tecte du son", + "is_tampered": "{entity_name} d\u00e9tecte une falsification", "is_unsafe": "{entity_name} est dangereux", + "is_update": "{entity_name} a une mise \u00e0 jour disponible", "is_vibration": "{entity_name} d\u00e9tecte des vibrations" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} connect\u00e9", "gas": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du gaz", "hot": "{entity_name} est devenu chaud", + "is_not_tampered": "{entity_name} a cess\u00e9 de d\u00e9tecter la falsification", + "is_tampered": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter une falsification", "light": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter la lumi\u00e8re", "locked": "{entity_name} verrouill\u00e9", "moist": "{entity_name} est devenu humide", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} a cess\u00e9 de d\u00e9tecter un probl\u00e8me", "no_smoke": "{entity_name} a cess\u00e9 de d\u00e9tecter de la fum\u00e9e", "no_sound": "{entity_name} a cess\u00e9 de d\u00e9tecter du bruit", + "no_update": "{entity_name} a \u00e9t\u00e9 mis \u00e0 jour", "no_vibration": "{entity_name} a cess\u00e9 de d\u00e9tecter des vibrations", "not_bat_low": "{entity_name} batterie normale", "not_cold": "{entity_name} n'est plus froid", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} d\u00e9branch\u00e9", "not_powered": "{entity_name} non aliment\u00e9", "not_present": "{entity_name} non pr\u00e9sent", + "not_running": "{entity_name} n'est plus en cours d'ex\u00e9cution", + "not_tampered": "{entity_name} a cess\u00e9 de d\u00e9tecter la falsification", "not_unsafe": "{entity_name} est devenu s\u00fbr", "occupied": "{entity_name} est devenu occup\u00e9", "opened": "{entity_name} ouvert", @@ -81,14 +92,30 @@ "powered": "{entity_name} aliment\u00e9", "present": "{entity_name} pr\u00e9sent", "problem": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter un probl\u00e8me", + "running": "{entity_name} commenc\u00e9 \u00e0 s'ex\u00e9cuter", "smoke": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter la fum\u00e9e", "sound": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter le son", + "tampered": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter une falsification", "turned_off": "{entity_name} est d\u00e9sactiv\u00e9", "turned_on": "{entity_name} est activ\u00e9", "unsafe": "{entity_name} est devenu dangereux", + "update": "{entity_name} a une mise \u00e0 jour disponible", "vibration": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter les vibrations" } }, + "device_class": { + "cold": "froid", + "gas": "gaz", + "heat": "Chauffer", + "moisture": "humidit\u00e9", + "motion": "mouvement", + "occupancy": "occupation", + "power": "Puissance", + "problem": "Probl\u00e8me", + "smoke": "fum\u00e9e", + "sound": "son", + "vibration": "vibration" + }, "state": { "_": { "off": "Inactif", @@ -111,12 +138,12 @@ "on": "Connect\u00e9" }, "door": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" }, "garage_door": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" }, "gas": { "off": "Non d\u00e9tect\u00e9", @@ -166,6 +193,10 @@ "off": "OK", "on": "Probl\u00e8me" }, + "running": { + "off": "\u00c0 l'arr\u00eat", + "on": "En marche" + }, "safety": { "off": "S\u00e9curis\u00e9", "on": "Dangereux" @@ -178,13 +209,17 @@ "off": "Non d\u00e9tect\u00e9", "on": "D\u00e9tect\u00e9" }, + "update": { + "off": "\u00c0 jour", + "on": "Mise \u00e0 jour disponible" + }, "vibration": { "off": "RAS", "on": "D\u00e9tect\u00e9e" }, "window": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" } }, "title": "Capteur binaire" diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index 5f4fb949b344e..43b3c4ff24611 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -1,50 +1,163 @@ { "device_automation": { "condition_type": { + "is_bat_low": "\u05e1\u05d5\u05dc\u05dc\u05ea {entity_name} \u05d7\u05dc\u05e9\u05d4", "is_cold": "{entity_name} \u05e7\u05e8", - "is_not_cold": "{entity_name} \u05dc\u05d0 \u05e7\u05e8" + "is_connected": "{entity_name} \u05de\u05d7\u05d5\u05d1\u05e8", + "is_gas": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05d2\u05d6", + "is_hot": "{entity_name} \u05d7\u05dd", + "is_light": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05d0\u05d5\u05e8", + "is_locked": "{entity_name} \u05e0\u05e2\u05d5\u05dc", + "is_moist": "{entity_name} \u05dc\u05d7", + "is_motion": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05ea\u05e0\u05d5\u05e2\u05d4", + "is_moving": "{entity_name} \u05d6\u05d6", + "is_no_gas": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05d2\u05d6", + "is_no_light": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05d0\u05d5\u05e8", + "is_no_motion": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05ea\u05e0\u05d5\u05e2\u05d4", + "is_no_problem": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05d1\u05e2\u05d9\u05d4", + "is_no_smoke": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05e2\u05e9\u05df", + "is_no_sound": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05e6\u05dc\u05d9\u05dc", + "is_no_update": "{entity_name} \u05de\u05e2\u05d5\u05d3\u05db\u05df", + "is_no_vibration": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05e8\u05d8\u05d8", + "is_not_bat_low": "\u05e1\u05d5\u05dc\u05dc\u05ea {entity_name} \u05ea\u05e7\u05d9\u05e0\u05d4", + "is_not_cold": "{entity_name} \u05dc\u05d0 \u05e7\u05e8", + "is_not_connected": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", + "is_not_hot": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05d7\u05dd", + "is_not_locked": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e0\u05e2\u05d5\u05dc", + "is_not_moist": "{entity_name} \u05d9\u05d1\u05e9", + "is_not_moving": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05d6\u05d6", + "is_not_occupied": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05ea\u05e4\u05d5\u05e1", + "is_not_open": "{entity_name} \u05e1\u05d2\u05d5\u05e8", + "is_not_plugged_in": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", + "is_not_powered": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc", + "is_not_present": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e7\u05d9\u05d9\u05dd", + "is_not_running": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc", + "is_not_tampered": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05d7\u05d1\u05dc\u05d4", + "is_not_unsafe": "{entity_name} \u05d1\u05d8\u05d5\u05d7", + "is_occupied": "{entity_name} \u05ea\u05e4\u05d5\u05e1", + "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", + "is_on": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc", + "is_open": "{entity_name} \u05e4\u05ea\u05d5\u05d7", + "is_plugged_in": "{entity_name} \u05de\u05d7\u05d5\u05d1\u05e8", + "is_powered": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc", + "is_present": "{entity_name} \u05e0\u05d5\u05db\u05d7", + "is_problem": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05d1\u05e2\u05d9\u05d4", + "is_running": "{entity_name} \u05e4\u05d5\u05e2\u05dc", + "is_smoke": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05e2\u05e9\u05df", + "is_sound": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05e6\u05dc\u05d9\u05dc", + "is_tampered": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05d7\u05d1\u05dc\u05d4", + "is_unsafe": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05d1\u05d8\u05d5\u05d7", + "is_update": "\u05e2\u05d3\u05db\u05d5\u05df \u05d6\u05de\u05d9\u05df \u05e2\u05d1\u05d5\u05e8 {entity_name}", + "is_vibration": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05e8\u05d8\u05d8" }, "trigger_type": { + "bat_low": "\u05e1\u05d5\u05dc\u05dc\u05ea {entity_name} \u05d7\u05dc\u05e9\u05d4", "cold": "{entity_name} \u05e0\u05d4\u05d9\u05d4 \u05e7\u05e8", - "not_cold": "{entity_name} \u05e0\u05d4\u05d9\u05d4 \u05dc\u05d0 \u05e7\u05e8" + "connected": "{entity_name} \u05de\u05d7\u05d5\u05d1\u05e8", + "gas": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05d2\u05d6", + "hot": "{entity_name} \u05e0\u05e2\u05e9\u05d4 \u05d7\u05dd", + "is_not_tampered": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d7\u05d1\u05dc\u05d4", + "is_tampered": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05d7\u05d1\u05dc\u05d4", + "light": "{entity_name} \u05d4\u05ea\u05d7\u05d9\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05d0\u05d5\u05e8", + "locked": "{entity_name} \u05e0\u05e2\u05d5\u05dc", + "moist": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05d7", + "motion": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05ea\u05e0\u05d5\u05e2\u05d4", + "moving": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05e0\u05d5\u05e2", + "no_gas": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d2\u05d6", + "no_light": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d0\u05d5\u05e8", + "no_motion": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05ea\u05e0\u05d5\u05e2\u05d4", + "no_problem": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d1\u05e2\u05d9\u05d4", + "no_smoke": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05e2\u05e9\u05df", + "no_sound": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05e6\u05dc\u05d9\u05dc", + "no_update": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05de\u05e2\u05d5\u05d3\u05db\u05df", + "no_vibration": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05e8\u05d8\u05d8", + "not_bat_low": "{entity_name} \u05e1\u05d5\u05dc\u05dc\u05d4 \u05e8\u05d2\u05d9\u05dc\u05d4", + "not_cold": "{entity_name} \u05e0\u05e2\u05e9\u05d4 \u05dc\u05d0 \u05e7\u05e8", + "not_connected": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", + "not_hot": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05d0 \u05d7\u05dd", + "not_locked": "{entity_name} \u05dc\u05d0 \u05e0\u05e2\u05d5\u05dc", + "not_moist": "{entity_name} \u05d4\u05ea\u05d9\u05d9\u05d1\u05e9", + "not_moving": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d5\u05d6", + "not_occupied": "{entity_name} \u05dc\u05d0 \u05e0\u05ea\u05e4\u05e1", + "not_opened": "{entity_name} \u05e0\u05e1\u05d2\u05e8", + "not_plugged_in": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", + "not_powered": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc", + "not_present": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e7\u05d9\u05d9\u05dd", + "not_running": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc \u05e2\u05d5\u05d3", + "not_unsafe": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05d1\u05d8\u05d5\u05d7", + "occupied": "{entity_name} \u05e0\u05ea\u05e4\u05e1", + "opened": "{entity_name} \u05e0\u05e4\u05ea\u05d7", + "plugged_in": "{entity_name} \u05de\u05d7\u05d5\u05d1\u05e8", + "powered": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc", + "present": "{entity_name} \u05e0\u05d5\u05db\u05d7", + "problem": "{entity_name} \u05d4\u05d7\u05dc\u05d4 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d1\u05e2\u05d9\u05d4", + "running": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05e4\u05e2\u05d5\u05dc", + "smoke": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05e2\u05e9\u05df", + "sound": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05e6\u05dc\u05d9\u05dc", + "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", + "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc", + "unsafe": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05dc\u05d0 \u05d1\u05d8\u05d5\u05d7", + "update": "{entity_name} \u05e7\u05d9\u05d1\u05dc \u05e2\u05d3\u05db\u05d5\u05df \u05d6\u05de\u05d9\u05df", + "vibration": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05e8\u05d8\u05d8" } }, + "device_class": { + "cold": "\u05e7\u05d5\u05e8", + "gas": "\u05d2\u05d6", + "heat": "\u05d7\u05d5\u05dd", + "moisture": "\u05dc\u05d7\u05d5\u05ea", + "motion": "\u05ea\u05e0\u05d5\u05e2\u05d4", + "occupancy": "\u05ea\u05e4\u05d5\u05e1\u05d4", + "power": "\u05db\u05d7", + "problem": "\u05d1\u05e2\u05d9\u05d4", + "smoke": "\u05e2\u05e9\u05df", + "sound": "\u05e7\u05d5\u05dc", + "vibration": "\u05e8\u05d8\u05d8" + }, "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" }, "battery": { "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", "on": "\u05e0\u05de\u05d5\u05da" }, + "battery_charging": { + "off": "\u05dc\u05d0 \u05e0\u05d8\u05e2\u05df", + "on": "\u05e0\u05d8\u05e2\u05df" + }, "cold": { - "off": "\u05e8\u05d2\u05d9\u05dc", - "on": "\u05e7\u05b7\u05e8" + "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", + "on": "\u05e7\u05e8" }, "connectivity": { "off": "\u05de\u05e0\u05d5\u05ea\u05e7", "on": "\u05de\u05d7\u05d5\u05d1\u05e8" }, "door": { - "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", - "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" }, "garage_door": { - "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", - "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" }, "gas": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "heat": { - "off": "\u05e8\u05d2\u05d9\u05dc", + "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", "on": "\u05d7\u05dd" }, + "light": { + "off": "\u05d0\u05d9\u05df \u05d0\u05d5\u05e8", + "on": "\u05d6\u05d5\u05d4\u05ea\u05d4 \u05ea\u05d0\u05d5\u05e8\u05d4" + }, "lock": { "off": "\u05e0\u05e2\u05d5\u05dc", - "on": "\u05dc\u05d0 \u05e0\u05e2\u05d5\u05dc" + "on": "\u05e4\u05ea\u05d5\u05d7" }, "moisture": { "off": "\u05d9\u05d1\u05e9", @@ -54,6 +167,10 @@ "off": "\u05e0\u05e7\u05d9", "on": "\u05d6\u05d5\u05d4\u05d4" }, + "moving": { + "off": "\u05dc\u05d0 \u05d6\u05d6", + "on": "\u05e0\u05e2" + }, "occupancy": { "off": "\u05e0\u05e7\u05d9", "on": "\u05d6\u05d5\u05d4\u05d4" @@ -62,29 +179,41 @@ "off": "\u05e1\u05d2\u05d5\u05e8", "on": "\u05e4\u05ea\u05d5\u05d7" }, + "plug": { + "off": "\u05de\u05e0\u05d5\u05ea\u05e7", + "on": "\u05de\u05d7\u05d5\u05d1\u05e8" + }, "presence": { - "off": "\u05dc\u05d0 \u05e0\u05d5\u05db\u05d7", - "on": "\u05e0\u05d5\u05db\u05d7" + "off": "\u05d1\u05d7\u05d5\u05e5", + "on": "\u05d1\u05d1\u05d9\u05ea" }, "problem": { "off": "\u05ea\u05e7\u05d9\u05df", "on": "\u05d1\u05e2\u05d9\u05d4" }, + "running": { + "off": "\u05dc\u05d0 \u05e4\u05d5\u05e2\u05dc", + "on": "\u05e4\u05d5\u05e2\u05dc" + }, "safety": { "off": "\u05d1\u05d8\u05d5\u05d7", "on": "\u05dc\u05d0 \u05d1\u05d8\u05d5\u05d7" }, "smoke": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "sound": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" + }, + "update": { + "off": "\u05e2\u05d3\u05db\u05e0\u05d9", + "on": "\u05e2\u05d3\u05db\u05d5\u05df \u05d6\u05de\u05d9\u05df" }, "vibration": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "window": { "off": "\u05e1\u05d2\u05d5\u05e8", diff --git a/homeassistant/components/binary_sensor/translations/hu.json b/homeassistant/components/binary_sensor/translations/hu.json index c4395ca806ce4..633a4bae372ae 100644 --- a/homeassistant/components/binary_sensor/translations/hu.json +++ b/homeassistant/components/binary_sensor/translations/hu.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} nem \u00e9szlel probl\u00e9m\u00e1t", "is_no_smoke": "{entity_name} nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", "is_no_sound": "{entity_name} nem \u00e9rz\u00e9kel hangot", + "is_no_update": "{entity_name} naprak\u00e9sz", "is_no_vibration": "{entity_name} nem \u00e9rz\u00e9kel rezg\u00e9st", "is_not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", "is_not_cold": "{entity_name} nem hideg", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} nincs csatlakoztatva", "is_not_powered": "{entity_name} nincs fesz\u00fcts\u00e9g alatt", "is_not_present": "{entity_name} nincs jelen", + "is_not_running": "{entity_name} nem fut", + "is_not_tampered": "{entity_name} nem \u00e9szlel manipul\u00e1l\u00e1st", "is_not_unsafe": "{entity_name} biztons\u00e1gos", "is_occupied": "{entity_name} foglalt", "is_off": "{entity_name} ki van kapcsolva", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} fesz\u00fclts\u00e9g alatt van", "is_present": "{entity_name} jelen van", "is_problem": "{entity_name} probl\u00e9m\u00e1t \u00e9szlel", + "is_running": "{entity_name} fut", "is_smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", "is_sound": "{entity_name} hangot \u00e9rz\u00e9kel", + "is_tampered": "{entity_name} manipul\u00e1l\u00e1st \u00e9szlel", "is_unsafe": "{entity_name} nem biztons\u00e1gos", + "is_update": "{entity_name} egy friss\u00edt\u00e9s \u00e1ll rendelkez\u00e9sre", "is_vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} csatlakozik", "gas": "{entity_name} g\u00e1zt \u00e9rz\u00e9kel", "hot": "{entity_name} felforr\u00f3sodik", + "is_not_tampered": "{entity_name} nem \u00e9szlelt manipul\u00e1l\u00e1st", + "is_tampered": "{entity_name} manipul\u00e1l\u00e1st \u00e9szlelt", "light": "{entity_name} f\u00e9nyt \u00e9rz\u00e9kel", "locked": "{entity_name} be lett z\u00e1rva", "moist": "{entity_name} nedves lett", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} m\u00e1r nem \u00e9szlel probl\u00e9m\u00e1t", "no_smoke": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", "no_sound": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel hangot", + "no_update": "{entity_name} naprak\u00e9sz lett", "no_vibration": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel rezg\u00e9st", "not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", "not_cold": "{entity_name} m\u00e1r nem hideg", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} m\u00e1r nincs csatlakoztatva", "not_powered": "{entity_name} m\u00e1r nincs fesz\u00fcts\u00e9g alatt", "not_present": "{entity_name} m\u00e1r nincs jelen", + "not_running": "{entity_name} m\u00e1r nem fut", + "not_tampered": "{entity_name} nem \u00e9szlel t\u00f6bb\u00e9 a manipul\u00e1l\u00e1st", "not_unsafe": "{entity_name} biztons\u00e1gos lett", "occupied": "{entity_name} foglalt lett", "opened": "{entity_name} ki lett nyitva", @@ -81,14 +92,30 @@ "powered": "{entity_name} m\u00e1r fesz\u00fclts\u00e9g alatt van", "present": "{entity_name} m\u00e1r jelen van", "problem": "{entity_name} probl\u00e9m\u00e1t \u00e9szlel", + "running": "{entity_name} elindult", "smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", "sound": "{entity_name} hangot \u00e9rz\u00e9kel", + "tampered": "{entity_name} manipul\u00e1l\u00e1st \u00e9szlelt", "turned_off": "{entity_name} ki lett kapcsolva", "turned_on": "{entity_name} be lett kapcsolva", "unsafe": "{entity_name} m\u00e1r nem biztons\u00e1gos", + "update": "{entity_name} el\u00e9rhet\u0151 friss\u00edt\u00e9s", "vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" } }, + "device_class": { + "cold": "h\u0171t\u00e9s", + "gas": "g\u00e1z", + "heat": "f\u0171t\u00e9s", + "moisture": "nedvess\u00e9g", + "motion": "mozg\u00e1s", + "occupancy": "foglalts\u00e1g", + "power": "teljes\u00edtm\u00e9ny", + "problem": "probl\u00e9ma", + "smoke": "f\u00fcst", + "sound": "hang", + "vibration": "rezg\u00e9s" + }, "state": { "_": { "off": "Ki", @@ -107,7 +134,7 @@ "on": "Hideg" }, "connectivity": { - "off": "Lekapcsol\u00f3dva", + "off": "Lev\u00e1lasztva", "on": "Kapcsol\u00f3dva" }, "door": { @@ -166,6 +193,10 @@ "off": "OK", "on": "Probl\u00e9ma" }, + "running": { + "off": "Nem fut", + "on": "Fut" + }, "safety": { "off": "Biztons\u00e1gos", "on": "Nem biztons\u00e1gos" @@ -178,6 +209,10 @@ "off": "Norm\u00e1l", "on": "\u00c9szlelve" }, + "update": { + "off": "Naprak\u00e9sz", + "on": "Friss\u00edt\u00e9s el\u00e9rhet\u0151" + }, "vibration": { "off": "Norm\u00e1l", "on": "\u00c9szlelve" diff --git a/homeassistant/components/binary_sensor/translations/id.json b/homeassistant/components/binary_sensor/translations/id.json index ac880aa28fa38..3f67814969428 100644 --- a/homeassistant/components/binary_sensor/translations/id.json +++ b/homeassistant/components/binary_sensor/translations/id.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} tidak mendeteksi masalah", "is_no_smoke": "{entity_name} tidak mendeteksi asap", "is_no_sound": "{entity_name} tidak mendeteksi suara", + "is_no_update": "{entity_name} sudah yang terbaru", "is_no_vibration": "{entity_name} tidak mendeteksi getaran", "is_not_bat_low": "Baterai {entity_name} normal", "is_not_cold": "{entity_name} tidak dingin", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} dicabut", "is_not_powered": "{entity_name} tidak ditenagai", "is_not_present": "{entity_name} tidak ada", + "is_not_running": "{entity_name} tidak berjalan", + "is_not_tampered": "{entity_name} tidak mendeteksi gangguan", "is_not_unsafe": "{entity_name} aman", "is_occupied": "{entity_name} ditempati", "is_off": "{entity_name} mati", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} ditenagai", "is_present": "{entity_name} ada", "is_problem": "{entity_name} mendeteksi masalah", + "is_running": "{entity_name} sedang berjalan", "is_smoke": "{entity_name} mendeteksi asap", "is_sound": "{entity_name} mendeteksi suara", + "is_tampered": "{entity_name} mendeteksi gangguan", "is_unsafe": "{entity_name} tidak aman", + "is_update": "{entity_name} memiliki pembaruan yang tersedia", "is_vibration": "{entity_name} mendeteksi getaran" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} terhubung", "gas": "{entity_name} mulai mendeteksi gas", "hot": "{entity_name} menjadi panas", + "is_not_tampered": "{entity_name} berhenti mendeteksi gangguan", + "is_tampered": "{entity_name} mulai mendeteksi gangguan", "light": "{entity_name} mulai mendeteksi cahaya", "locked": "{entity_name} terkunci", "moist": "{entity_name} menjadi lembab", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} berhenti mendeteksi masalah", "no_smoke": "{entity_name} berhenti mendeteksi asap", "no_sound": "{entity_name} berhenti mendeteksi suara", + "no_update": "{entity_name} menjadi yang terbaru", "no_vibration": "{entity_name} berhenti mendeteksi getaran", "not_bat_low": "Baterai {entity_name} normal", "not_cold": "{entity_name} menjadi tidak dingin", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} dicabut", "not_powered": "{entity_name} tidak ditenagai", "not_present": "{entity_name} tidak ada", + "not_running": "{entity_name} tidak lagi berjalan", + "not_tampered": "{entity_name} berhenti mendeteksi gangguan", "not_unsafe": "{entity_name} menjadi aman", "occupied": "{entity_name} menjadi ditempati", "opened": "{entity_name} terbuka", @@ -81,14 +92,30 @@ "powered": "{entity_name} ditenagai", "present": "{entity_name} ada", "problem": "{entity_name} mulai mendeteksi masalah", + "running": "{entity_name} mulai berjalan", "smoke": "{entity_name} mulai mendeteksi asap", "sound": "{entity_name} mulai mendeteksi suara", + "tampered": "{entity_name} mulai mendeteksi gangguan", "turned_off": "{entity_name} dimatikan", "turned_on": "{entity_name} dinyalakan", "unsafe": "{entity_name} menjadi tidak aman", + "update": "{entity_name} mendapat pembaruan yang tersedia", "vibration": "{entity_name} mulai mendeteksi getaran" } }, + "device_class": { + "cold": "dingin", + "gas": "gas", + "heat": "panas", + "moisture": "kelembaban", + "motion": "gerakan", + "occupancy": "okupansi", + "power": "daya", + "problem": "masalah", + "smoke": "asap", + "sound": "suara", + "vibration": "vibrasi" + }, "state": { "_": { "off": "Mati", @@ -166,6 +193,10 @@ "off": "Oke", "on": "Bermasalah" }, + "running": { + "off": "Tidak berjalan", + "on": "Berjalan" + }, "safety": { "off": "Aman", "on": "Tidak aman" @@ -178,6 +209,10 @@ "off": "Tidak ada", "on": "Terdeteksi" }, + "update": { + "off": "Diperbarui", + "on": "Pembaruan tersedia" + }, "vibration": { "off": "Tidak ada", "on": "Terdeteksi" diff --git a/homeassistant/components/binary_sensor/translations/is.json b/homeassistant/components/binary_sensor/translations/is.json index f53316ebd73e7..46b44913169d0 100644 --- a/homeassistant/components/binary_sensor/translations/is.json +++ b/homeassistant/components/binary_sensor/translations/is.json @@ -45,9 +45,13 @@ "on": "Hreyfing" }, "occupancy": { - "off": "Hreinsa", + "off": "Engin vi\u00f0vera", "on": "Uppg\u00f6tva\u00f0" }, + "opening": { + "off": "Loka\u00f0", + "on": "Opi\u00f0" + }, "presence": { "off": "Fjarverandi", "on": "Heima" diff --git a/homeassistant/components/binary_sensor/translations/it.json b/homeassistant/components/binary_sensor/translations/it.json index 68c427cbc0472..6f526a12681b3 100644 --- a/homeassistant/components/binary_sensor/translations/it.json +++ b/homeassistant/components/binary_sensor/translations/it.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} non sta rilevando un problema", "is_no_smoke": "{entity_name} non sta rilevando il fumo", "is_no_sound": "{entity_name} non sta rilevando il suono", + "is_no_update": "{entity_name} \u00e8 aggiornato", "is_no_vibration": "{entity_name} non sta rilevando la vibrazione", "is_not_bat_low": "{entity_name} la batteria \u00e8 normale", "is_not_cold": "{entity_name} non \u00e8 freddo", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} \u00e8 collegato", "is_not_powered": "{entity_name} non \u00e8 alimentato", "is_not_present": "{entity_name} non \u00e8 presente", + "is_not_running": "{entity_name} non \u00e8 in funzionamento", + "is_not_tampered": "{entity_name} non rileva manomissioni", "is_not_unsafe": "{entity_name} \u00e8 sicuro", "is_occupied": "{entity_name} \u00e8 occupato", "is_off": "{entity_name} \u00e8 spento", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} \u00e8 alimentato", "is_present": "{entity_name} \u00e8 presente", "is_problem": "{entity_name} sta rilevando un problema", + "is_running": "{entity_name} \u00e8 in funzionamento", "is_smoke": "{entity_name} sta rilevando il fumo", "is_sound": "{entity_name} sta rilevando il suono", + "is_tampered": "{entity_name} rileva manomissioni", "is_unsafe": "{entity_name} non \u00e8 sicuro", + "is_update": "{entity_name} ha un aggiornamento disponibile", "is_vibration": "{entity_name} sta rilevando la vibrazione" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} connesso", "gas": "{entity_name} ha iniziato a rilevare il gas", "hot": "{entity_name} \u00e8 diventato caldo", + "is_not_tampered": "{entity_name} ha smesso di rilevare manomissioni", + "is_tampered": "{entity_name} ha iniziato a rilevare manomissioni", "light": "{entity_name} ha iniziato a rilevare la luce", "locked": "{entity_name} bloccato", "moist": "{entity_name} diventato umido", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} ha smesso di rilevare un problema", "no_smoke": "{entity_name} ha smesso la rilevazione di fumo", "no_sound": "{entity_name} ha smesso di rilevare il suono", + "no_update": "{entity_name} \u00e8 diventato aggiornato", "no_vibration": "{entity_name} ha smesso di rilevare le vibrazioni", "not_bat_low": "{entity_name} batteria normale", "not_cold": "{entity_name} non \u00e8 diventato freddo", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} \u00e8 scollegato", "not_powered": "{entity_name} non \u00e8 alimentato", "not_present": "{entity_name} non \u00e8 presente", + "not_running": "{entity_name} non \u00e8 pi\u00f9 in funzione", + "not_tampered": "{entity_name} ha smesso di rilevare manomissioni", "not_unsafe": "{entity_name} \u00e8 diventato sicuro", "occupied": "{entity_name} \u00e8 diventato occupato", "opened": "{entity_name} \u00e8 aperto", @@ -81,14 +92,30 @@ "powered": "{entity_name} \u00e8 alimentato", "present": "{entity_name} \u00e8 presente", "problem": "{entity_name} ha iniziato a rilevare un problema", + "running": "{entity_name} ha iniziato a funzionare", "smoke": "{entity_name} ha iniziato la rilevazione di fumo", "sound": "{entity_name} ha iniziato il rilevamento del suono", + "tampered": "{entity_name} ha iniziato a rilevare manomissioni", "turned_off": "{entity_name} disattivato", "turned_on": "{entity_name} attivato", "unsafe": "{entity_name} diventato non sicuro", + "update": "{entity_name} ha ottenuto un aggiornamento disponibile", "vibration": "{entity_name} iniziato a rilevare le vibrazioni" } }, + "device_class": { + "cold": "freddo", + "gas": "gas", + "heat": "caldo", + "moisture": "umidit\u00e0", + "motion": "movimento", + "occupancy": "occupazione", + "power": "potenza", + "problem": "problema", + "smoke": "fumo", + "sound": "suono", + "vibration": "vibrazione" + }, "state": { "_": { "off": "Spento", @@ -166,6 +193,10 @@ "off": "OK", "on": "Problema" }, + "running": { + "off": "Non in esecuzione", + "on": "In esecuzione" + }, "safety": { "off": "Sicuro", "on": "Non Sicuro" @@ -178,6 +209,10 @@ "off": "Assente", "on": "Rilevato" }, + "update": { + "off": "Aggiornato", + "on": "Aggiornamento disponibile" + }, "vibration": { "off": "Assente", "on": "Rilevata" diff --git a/homeassistant/components/binary_sensor/translations/ja.json b/homeassistant/components/binary_sensor/translations/ja.json index 5434f8687bf54..c7a2049315226 100644 --- a/homeassistant/components/binary_sensor/translations/ja.json +++ b/homeassistant/components/binary_sensor/translations/ja.json @@ -1,4 +1,121 @@ { + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u96fb\u6c60\u6b8b\u91cf\u304c\u5c11\u306a\u304f\u306a\u3063\u3066\u3044\u307e\u3059", + "is_cold": "{entity_name} \u51b7\u3048\u3066\u3044\u308b", + "is_connected": "{entity_name} \u304c\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "is_gas": "{entity_name} \u304c\u30ac\u30b9\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_hot": "{entity_name} \u71b1\u3044", + "is_light": "{entity_name} \u304c\u5149\u3092\u691c\u77e5\u3057\u3066\u3044\u307e\u3059", + "is_locked": "{entity_name} \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059", + "is_moist": "{entity_name} \u306f\u6e7f\u3063\u3066\u3044\u307e\u3059", + "is_motion": "{entity_name} \u306f\u3001\u52d5\u304d\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_moving": "{entity_name} \u304c\u79fb\u52d5\u4e2d\u3067\u3059", + "is_no_gas": "{entity_name} \u306f\u3001\u30ac\u30b9\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_light": "{entity_name} \u306f\u3001\u5149\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_motion": "{entity_name} \u306f\u3001\u52d5\u304d\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_problem": "{entity_name} \u306f\u3001\u554f\u984c\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_smoke": "{entity_name} \u306f\u3001\u7159\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_sound": "{entity_name} \u306f\u3001\u97f3\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_update": "{entity_name} \u306f\u6700\u65b0\u3067\u3059", + "is_no_vibration": "{entity_name} \u306f\u632f\u52d5\u3092\u611f\u77e5\u3057\u3066\u3044\u307e\u305b\u3093", + "is_not_bat_low": "{entity_name} \u30d0\u30c3\u30c6\u30ea\u30fc\u306f\u6b63\u5e38\u3067\u3059", + "is_not_cold": "{entity_name} \u51b7\u3048\u3066\u3044\u307e\u305b\u3093", + "is_not_connected": "{entity_name} \u304c\u5207\u65ad\u3055\u308c\u307e\u3057\u305f", + "is_not_hot": "{entity_name} \u306f\u71b1\u304f\u3042\u308a\u307e\u305b\u3093", + "is_not_locked": "{entity_name} \u306e\u30ed\u30c3\u30af\u306f\u89e3\u9664\u3055\u308c\u3066\u3044\u307e\u3059", + "is_not_moist": "{entity_name} \u306f\u4e7e\u71e5\u3057\u3066\u3044\u307e\u3059", + "is_not_moving": "{entity_name} \u306f\u52d5\u3044\u3066\u3044\u307e\u305b\u3093", + "is_not_occupied": "{entity_name} \u306f\u5360\u6709\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "is_not_open": "{entity_name} \u306f\u9589\u3058\u3066\u3044\u307e\u3059", + "is_not_plugged_in": "{entity_name} \u30d7\u30e9\u30b0\u304c\u629c\u304b\u308c\u3066\u3044\u307e\u3059", + "is_not_powered": "{entity_name} \u306f\u96fb\u529b\u304c\u4f9b\u7d66\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "is_not_present": "{entity_name} \u304c\u5b58\u5728\u3057\u307e\u305b\u3093", + "is_not_running": "{entity_name} \u306f\u5b9f\u884c\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "is_not_tampered": "{entity_name} \u306f\u6539\u7ac4(tampering)\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_not_unsafe": "{entity_name} \u306f\u5b89\u5168\u3067\u3059", + "is_occupied": "{entity_name} \u306f\u5360\u6709\u3055\u308c\u3066\u3044\u307e\u3059", + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059", + "is_open": "{entity_name} \u304c\u958b\u3044\u3066\u3044\u307e\u3059", + "is_plugged_in": "{entity_name} \u304c\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "is_powered": "{entity_name} \u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u307e\u3059", + "is_present": "{entity_name} \u304c\u5b58\u5728\u3057\u307e\u3059", + "is_problem": "{entity_name} \u304c\u554f\u984c\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_running": "{entity_name} \u304c\u5b9f\u884c\u3055\u308c\u3066\u3044\u307e\u3059", + "is_smoke": "{entity_name} \u304c\u7159\u3092\u691c\u77e5\u3057\u3066\u3044\u307e\u3059", + "is_sound": "{entity_name} \u304c\u97f3\u3092\u691c\u77e5\u3057\u3066\u3044\u307e\u3059", + "is_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_unsafe": "{entity_name} \u306f\u5b89\u5168\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "is_update": "{entity_name} \u306b\u5229\u7528\u53ef\u80fd\u306a\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u304c\u3042\u308a\u307e\u3059", + "is_vibration": "{entity_name} \u304c\u632f\u52d5\u3092\u611f\u77e5\u3057\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "bat_low": "{entity_name} \u96fb\u6c60\u6b8b\u91cf\u304c\u5c11\u306a\u304f\u306a\u3063\u3066\u3044\u307e\u3059", + "cold": "{entity_name} \u51b7\u3048\u3066\u3044\u307e\u3059", + "connected": "{entity_name} \u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "gas": "{entity_name} \u304c\u30ac\u30b9\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "hot": "{entity_name} \u6e29\u307e\u3063\u3066\u3044\u307e\u3059", + "is_not_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "is_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "light": "{entity_name} \u306f\u3001\u5149\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "locked": "{entity_name} \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059", + "moist": "{entity_name} \u304c\u6e7f\u3063\u305f", + "motion": "{entity_name} \u306f\u3001\u52d5\u304d\u3092\u691c\u51fa\u3057\u59cb\u3081\u307e\u3057\u305f", + "moving": "{entity_name} \u306f\u3001\u79fb\u52d5\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "no_gas": "{entity_name} \u306f\u3001\u30ac\u30b9\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_light": "{entity_name} \u306f\u3001\u5149\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_motion": "{entity_name} \u306f\u3001\u52d5\u304d\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_problem": "{entity_name} \u306f\u3001\u554f\u984c\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_smoke": "{entity_name} \u306f\u3001\u7159\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_sound": "{entity_name} \u306f\u3001\u97f3\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_update": "{entity_name} \u304c\u6700\u65b0\u306b\u306a\u308a\u307e\u3057\u305f", + "no_vibration": "{entity_name} \u304c\u632f\u52d5\u3092\u611f\u77e5\u3057\u306a\u304f\u306a\u3063\u305f", + "not_bat_low": "{entity_name} \u30d0\u30c3\u30c6\u30ea\u30fc\u6b63\u5e38", + "not_cold": "{entity_name} \u306f\u51b7\u3048\u3066\u3044\u307e\u305b\u3093", + "not_connected": "{entity_name} \u304c\u5207\u65ad\u3055\u308c\u307e\u3057\u305f", + "not_hot": "{entity_name} \u6e29\u307e\u3063\u3066\u3044\u307e\u305b\u3093", + "not_locked": "{entity_name} \u306e\u30ed\u30c3\u30af\u304c\u89e3\u9664\u3055\u308c\u307e\u3057\u305f", + "not_moist": "{entity_name} \u306f\u4e7e\u3044\u3066\u3044\u307e\u305b\u3093", + "not_moving": "{entity_name} \u304c\u52d5\u304d\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "not_occupied": "{entity_name} \u304c\u5360\u6709\u3055\u308c\u306a\u304f\u306a\u308a\u307e\u3057\u305f", + "not_opened": "{entity_name} \u30af\u30ed\u30fc\u30ba\u30c9", + "not_plugged_in": "{entity_name} \u306e\u30d7\u30e9\u30b0\u304c\u629c\u304b\u308c\u307e\u3057\u305f", + "not_powered": "{entity_name} \u306f\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u307e\u305b\u3093", + "not_present": "{entity_name} \u304c\u5b58\u5728\u3057\u307e\u305b\u3093", + "not_running": "{entity_name} \u306f\u3082\u3046\u5b9f\u884c\u3055\u308c\u3066\u3044\u306a\u3044", + "not_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "not_unsafe": "{entity_name} \u304c\u5b89\u5168\u306b\u306a\u308a\u307e\u3057\u305f", + "occupied": "{entity_name} \u304c\u5360\u6709\u3055\u308c\u307e\u3057\u305f", + "opened": "{entity_name} \u304c\u958b\u304b\u308c\u307e\u3057\u305f", + "plugged_in": "{entity_name} \u304c\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "powered": "{entity_name} \u96fb\u6e90", + "present": "{entity_name} \u304c\u5b58\u5728", + "problem": "{entity_name} \u304c\u554f\u984c\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "running": "{entity_name} \u306e\u5b9f\u884c\u3092\u958b\u59cb", + "smoke": "{entity_name} \u304c\u7159\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "sound": "{entity_name} \u304c\u97f3\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059", + "unsafe": "{entity_name} \u306f\u5b89\u5168\u3067\u306f\u306a\u304f\u306a\u308a\u307e\u3057\u305f", + "update": "{entity_name} \u306f\u3001\u5229\u7528\u53ef\u80fd\u306a\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u3092\u53d6\u5f97\u3057\u307e\u3057\u305f\u3002", + "vibration": "{entity_name} \u304c\u632f\u52d5\u3092\u611f\u77e5\u3057\u59cb\u3081\u307e\u3057\u305f" + } + }, + "device_class": { + "cold": "\u51b7\u305f\u3044", + "gas": "\u30ac\u30b9", + "heat": "\u71b1", + "moisture": "\u6e7f\u6c17", + "motion": "\u30e2\u30fc\u30b7\u30e7\u30f3", + "occupancy": "\u5360\u6709", + "power": "\u30d1\u30ef\u30fc", + "problem": "\u554f\u984c", + "smoke": "\u7159", + "sound": "\u97f3", + "vibration": "\u632f\u52d5" + }, "state": { "_": { "off": "\u30aa\u30d5", @@ -8,6 +125,10 @@ "off": "\u901a\u5e38", "on": "\u4f4e" }, + "battery_charging": { + "off": "\u5145\u96fb\u3057\u3066\u3044\u306a\u3044", + "on": "\u5145\u96fb" + }, "cold": { "off": "\u901a\u5e38", "on": "\u4f4e\u6e29" @@ -18,23 +139,27 @@ }, "door": { "off": "\u9589\u9396", - "on": "\u958b\u653e" + "on": "\u30aa\u30fc\u30d7\u30f3" }, "garage_door": { "off": "\u9589\u9396", - "on": "\u958b\u653e" + "on": "\u30aa\u30fc\u30d7\u30f3" }, "gas": { - "off": "\u672a\u691c\u51fa", + "off": "\u30af\u30ea\u30a2", "on": "\u691c\u51fa" }, "heat": { "off": "\u6b63\u5e38", "on": "\u9ad8\u6e29" }, + "light": { + "off": "\u30e9\u30a4\u30c8\u306a\u3057", + "on": "\u30e9\u30a4\u30c8\u3092\u691c\u51fa" + }, "lock": { - "off": "\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f", - "on": "\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + "off": "\u65bd\u9320\u4e2d", + "on": "\u30ed\u30c3\u30af\u89e3\u9664" }, "moisture": { "off": "\u30c9\u30e9\u30a4", @@ -44,40 +169,57 @@ "off": "\u672a\u691c\u51fa", "on": "\u691c\u51fa" }, + "moving": { + "off": "\u52d5\u3044\u3066\u3044\u306a\u3044", + "on": "\u52d5\u3044\u3066\u3044\u308b" + }, "occupancy": { "off": "\u672a\u691c\u51fa", "on": "\u691c\u51fa" }, "opening": { "off": "\u9589\u9396", - "on": "\u958b\u653e" + "on": "\u30aa\u30fc\u30d7\u30f3" + }, + "plug": { + "off": "\u30a2\u30f3\u30d7\u30e9\u30b0\u30c9", + "on": "\u30d7\u30e9\u30b0\u30a4\u30f3" }, "presence": { "off": "\u5916\u51fa", "on": "\u5728\u5b85" }, "problem": { - "off": "OK" + "off": "OK", + "on": "\u554f\u984c" + }, + "running": { + "off": "\u30e9\u30f3\u30cb\u30f3\u30b0\u3067\u306f\u306a\u3044", + "on": "\u30e9\u30f3\u30cb\u30f3\u30b0" }, "safety": { "off": "\u5b89\u5168", "on": "\u5371\u967a" }, "smoke": { - "off": "\u672a\u691c\u51fa", + "off": "\u30af\u30ea\u30a2", "on": "\u691c\u51fa" }, "sound": { "off": "\u672a\u691c\u51fa", "on": "\u691c\u51fa" }, + "update": { + "off": "\u6700\u65b0", + "on": "\u66f4\u65b0\u53ef\u80fd" + }, "vibration": { - "off": "\u672a\u691c\u51fa", + "off": "\u30af\u30ea\u30a2", "on": "\u691c\u51fa" }, "window": { "off": "\u9589\u9396", - "on": "\u958b\u653e" + "on": "\u30aa\u30fc\u30d7\u30f3" } }, "title": "\u30d0\u30a4\u30ca\u30ea\u30bb\u30f3\u30b5\u30fc" diff --git a/homeassistant/components/binary_sensor/translations/nl.json b/homeassistant/components/binary_sensor/translations/nl.json index 9352bfa8d470b..b4fbaf43b4535 100644 --- a/homeassistant/components/binary_sensor/translations/nl.json +++ b/homeassistant/components/binary_sensor/translations/nl.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} detecteert geen probleem", "is_no_smoke": "{entity_name} detecteert geen rook", "is_no_sound": "{entity_name} detecteert geen geluid", + "is_no_update": "{entity_name} is up-to-date", "is_no_vibration": "{entity_name} detecteert geen trillingen", "is_not_bat_low": "{entity_name} batterij is normaal", "is_not_cold": "{entity_name} is niet koud", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} is niet aangesloten", "is_not_powered": "{entity_name} is niet van stroom voorzien...", "is_not_present": "{entity_name} is niet aanwezig", + "is_not_running": "{entity_name} is niet langer actief", + "is_not_tampered": "{entity_name} detecteert geen sabotage", "is_not_unsafe": "{entity_name} is veilig", "is_occupied": "{entity_name} bezet is", "is_off": "{entity_name} is uitgeschakeld", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} is van stroom voorzien....", "is_present": "{entity_name} is aanwezig", "is_problem": "{entity_name} detecteert een probleem", + "is_running": "{entity_name} is actief", "is_smoke": "{entity_name} detecteert rook", "is_sound": "{entity_name} detecteert geluid", + "is_tampered": "{entity_name} detecteert sabotage", "is_unsafe": "{entity_name} is onveilig", + "is_update": "{entity_name} heeft een update beschikbaar", "is_vibration": "{entity_name} detecteert trillingen" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} verbonden", "gas": "{entity_name} begon gas te detecteren", "hot": "{entity_name} werd heet", + "is_not_tampered": "{entity_name} gestopt met het detecteren van sabotage", + "is_tampered": "{entity_name} begonnen met het detecteren van sabotage", "light": "{entity_name} begon licht te detecteren", "locked": "{entity_name} vergrendeld", "moist": "{entity_name} werd vochtig", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} gestopt met het detecteren van het probleem", "no_smoke": "{entity_name} gestopt met het detecteren van rook", "no_sound": "{entity_name} gestopt met het detecteren van geluid", + "no_update": "{entity_name} werd ge\u00fcpdatet", "no_vibration": "{entity_name} gestopt met het detecteren van trillingen", "not_bat_low": "{entity_name} batterij normaal", "not_cold": "{entity_name} werd niet koud", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} niet verbonden", "not_powered": "{entity_name} niet ingeschakeld", "not_present": "{entity_name} is niet aanwezig", + "not_running": "{entity_name} is niet langer actief", + "not_tampered": "{entity_name} gestopt met het detecteren van sabotage", "not_unsafe": "{entity_name} werd veilig", "occupied": "{entity_name} werd bezet", "opened": "{entity_name} geopend", @@ -81,14 +92,30 @@ "powered": "{entity_name} heeft vermogen", "present": "{entity_name} aanwezig", "problem": "{entity_name} begonnen met het detecteren van een probleem", + "running": "{entity_name} is actief geworden", "smoke": "{entity_name} begon rook te detecteren", "sound": "{entity_name} begon geluid te detecteren", + "tampered": "{entity_name} begonnen met het detecteren van sabotage", "turned_off": "{entity_name} uitgeschakeld", "turned_on": "{entity_name} ingeschakeld", "unsafe": "{entity_name} werd onveilig", + "update": "{entity_name} kreeg een update beschikbaar", "vibration": "{entity_name} begon trillingen te detecteren" } }, + "device_class": { + "cold": "koud", + "gas": "gas", + "heat": "warmte", + "moisture": "vochtigheid", + "motion": "beweging", + "occupancy": "bezetting", + "power": "power", + "problem": "probleem", + "smoke": "rook", + "sound": "geluid", + "vibration": "trilling" + }, "state": { "_": { "off": "Uit", @@ -166,6 +193,10 @@ "off": "OK", "on": "Probleem" }, + "running": { + "off": "Niet actief", + "on": "Actief" + }, "safety": { "off": "Veilig", "on": "Onveilig" @@ -178,6 +209,10 @@ "off": "Niet gedetecteerd", "on": "Gedetecteerd" }, + "update": { + "off": "Up-to-date", + "on": "Update beschikbaar" + }, "vibration": { "off": "Niet gedetecteerd", "on": "Gedetecteerd" diff --git a/homeassistant/components/binary_sensor/translations/no.json b/homeassistant/components/binary_sensor/translations/no.json index 023fec6cc39ac..a9bdf88d930ec 100644 --- a/homeassistant/components/binary_sensor/translations/no.json +++ b/homeassistant/components/binary_sensor/translations/no.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} registrerer ikke et problem", "is_no_smoke": "{entity_name} registrerer ikke r\u00f8yk", "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_update": "{entity_name} er oppdatert", "is_no_vibration": "{entity_name} registrerer ikke bevegelse", "is_not_bat_low": "{entity_name} batteri er normalt", "is_not_cold": "{entity_name} er ikke kald", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} er koblet fra", "is_not_powered": "{entity_name} er spenningsl\u00f8s", "is_not_present": "{entity_name} er ikke tilstede", + "is_not_running": "{entity_name} kj\u00f8rer ikke", + "is_not_tampered": "{entity_name} oppdager ikke manipulering", "is_not_unsafe": "{entity_name} er trygg", "is_occupied": "{entity_name} er opptatt", "is_off": "{entity_name} er sl\u00e5tt av", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} er spenningssatt", "is_present": "{entity_name} er tilstede", "is_problem": "{entity_name} registrerer et problem", + "is_running": "{entity_name} kj\u00f8rer", "is_smoke": "{entity_name} registrerer r\u00f8yk", "is_sound": "{entity_name} registrerer lyd", + "is_tampered": "{entity_name} oppdager manipulering", "is_unsafe": "{entity_name} er utrygg", + "is_update": "{entity_name} har en tilgjengelig oppdatering", "is_vibration": "{entity_name} registrerer vibrasjon" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} tilkoblet", "gas": "{entity_name} begynte \u00e5 registrere gass", "hot": "{entity_name} ble varm", + "is_not_tampered": "{entity_name} sluttet \u00e5 oppdage manipulering", + "is_tampered": "{entity_name} begynte \u00e5 oppdage manipulering", "light": "{entity_name} begynte \u00e5 registrere lys", "locked": "{entity_name} l\u00e5st", "moist": "{entity_name} ble fuktig", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} sluttet \u00e5 registrere problem", "no_smoke": "{entity_name} sluttet \u00e5 registrere r\u00f8yk", "no_sound": "{entity_name} sluttet \u00e5 registrere lyd", + "no_update": "{entity_name} ble oppdatert", "no_vibration": "{entity_name} sluttet \u00e5 registrere vibrasjon", "not_bat_low": "{entity_name} batteri normalt", "not_cold": "{entity_name} ble ikke lenger kald", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} koblet fra", "not_powered": "{entity_name} spenningsl\u00f8s", "not_present": "{entity_name} ikke til stede", + "not_running": "{entity_name} kj\u00f8rer ikke lenger", + "not_tampered": "{entity_name} sluttet \u00e5 oppdage manipulering", "not_unsafe": "{entity_name} ble trygg", "occupied": "{entity_name} ble opptatt", "opened": "{entity_name} \u00e5pnet", @@ -81,14 +92,30 @@ "powered": "{entity_name} spenningssatt", "present": "{entity_name} tilstede", "problem": "{entity_name} begynte \u00e5 registrere et problem", + "running": "{entity_name} begynte \u00e5 kj\u00f8re", "smoke": "{entity_name} begynte \u00e5 registrere r\u00f8yk", "sound": "{entity_name} begynte \u00e5 registrere lyd", + "tampered": "{entity_name} begynte \u00e5 oppdage manipulering", "turned_off": "{entity_name} sl\u00e5tt av", "turned_on": "{entity_name} sl\u00e5tt p\u00e5", "unsafe": "{entity_name} ble usikker", + "update": "{entity_name} har en oppdatering tilgjengelig", "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" } }, + "device_class": { + "cold": "kald", + "gas": "Gass", + "heat": "varme", + "moisture": "fuktighet", + "motion": "bevegelse", + "occupancy": "Bruk", + "power": "kraft", + "problem": "problem", + "smoke": "r\u00f8yk", + "sound": "lyd", + "vibration": "vibrasjon" + }, "state": { "_": { "off": "Av", @@ -166,6 +193,10 @@ "off": "", "on": "" }, + "running": { + "off": "Kj\u00f8rer ikke", + "on": "Kj\u00f8rer" + }, "safety": { "off": "Sikker", "on": "Usikker" @@ -178,6 +209,10 @@ "off": "Klart", "on": "Oppdaget" }, + "update": { + "off": "Oppdatert", + "on": "Oppdatering tilgjengelig" + }, "vibration": { "off": "Klart", "on": "Oppdaget" diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index 726765aea0255..277598ccd3ce6 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -17,6 +17,7 @@ "is_no_problem": "sensor {entity_name} nie wykrywa problemu", "is_no_smoke": "sensor {entity_name} nie wykrywa dymu", "is_no_sound": "sensor {entity_name} nie wykrywa d\u017awi\u0119ku", + "is_no_update": "dla {entity_name} nie ma dost\u0119pnej aktualizacji", "is_no_vibration": "sensor {entity_name} nie wykrywa wibracji", "is_not_bat_low": "bateria {entity_name} nie jest roz\u0142adowana", "is_not_cold": "sensor {entity_name} nie wykrywa zimna", @@ -30,6 +31,8 @@ "is_not_plugged_in": "sensor {entity_name} wykrywa od\u0142\u0105czenie", "is_not_powered": "sensor {entity_name} nie wykrywa zasilania", "is_not_present": "sensor {entity_name} nie wykrywa obecno\u015bci", + "is_not_running": "{entity_name} nie dzia\u0142a", + "is_not_tampered": "sensor {entity_name} nie wykrywa naruszenia", "is_not_unsafe": "sensor {entity_name} nie wykrywa zagro\u017cenia", "is_occupied": "sensor {entity_name} jest zaj\u0119ty", "is_off": "sensor {entity_name} jest wy\u0142\u0105czony", @@ -39,9 +42,12 @@ "is_powered": "sensor {entity_name} wykrywa zasilanie", "is_present": "sensor {entity_name} wykrywa obecno\u015b\u0107", "is_problem": "sensor {entity_name} wykrywa problem", + "is_running": "{entity_name} dzia\u0142a", "is_smoke": "sensor {entity_name} wykrywa dym", "is_sound": "sensor {entity_name} wykrywa d\u017awi\u0119k", + "is_tampered": "sensor {entity_name} wykrywa naruszenie", "is_unsafe": "sensor {entity_name} wykrywa zagro\u017cenie", + "is_update": "dla {entity_name} jest dost\u0119pna aktualizacja", "is_vibration": "sensor {entity_name} wykrywa wibracje" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "nast\u0105pi pod\u0142\u0105czenie {entity_name}", "gas": "sensor {entity_name} wykryje gaz", "hot": "sensor {entity_name} wykryje gor\u0105co", + "is_not_tampered": "sensor {entity_name} przestanie wykrywa\u0107 naruszenie", + "is_tampered": "sensor {entity_name} wykryje naruszenie", "light": "sensor {entity_name} wykryje \u015bwiat\u0142o", "locked": "nast\u0105pi zamkni\u0119cie {entity_name}", "moist": "nast\u0105pi wykrycie wilgoci {entity_name}", @@ -61,6 +69,7 @@ "no_problem": "sensor {entity_name} przestanie wykrywa\u0107 problem", "no_smoke": "sensor {entity_name} przestanie wykrywa\u0107 dym", "no_sound": "sensor {entity_name} przestanie wykrywa\u0107 d\u017awi\u0119k", + "no_update": "wykonano aktualizacj\u0119 dla {entity_name}", "no_vibration": "sensor {entity_name} przestanie wykrywa\u0107 wibracje", "not_bat_low": "nast\u0105pi na\u0142adowanie baterii {entity_name}", "not_cold": "sensor {entity_name} przestanie wykrywa\u0107 zimno", @@ -74,6 +83,8 @@ "not_plugged_in": "nast\u0105pi od\u0142\u0105czenie {entity_name}", "not_powered": "nast\u0105pi od\u0142\u0105czenie zasilania {entity_name}", "not_present": "sensor {entity_name} przestanie wykrywa\u0107 obecno\u015b\u0107", + "not_running": "zako\u0144czy si\u0119 dzia\u0142anie {entity_name}", + "not_tampered": "sensor {entity_name} przestanie wykrywa\u0107 naruszenie", "not_unsafe": "sensor {entity_name} przestanie wykrywa\u0107 zagro\u017cenie", "occupied": "sensor {entity_name} stanie si\u0119 zaj\u0119ty", "opened": "nast\u0105pi otwarcie {entity_name}", @@ -81,14 +92,30 @@ "powered": "nast\u0105pi pod\u0142\u0105czenie zasilenia {entity_name}", "present": "sensor {entity_name} wykryje obecno\u015b\u0107", "problem": "sensor {entity_name} wykryje problem", + "running": "rozpocznie si\u0119 dzia\u0142anie {entity_name}", "smoke": "sensor {entity_name} wykryje dym", "sound": "sensor {entity_name} wykryje d\u017awi\u0119k", + "tampered": "sensor {entity_name} wykryje naruszenie", "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}", "unsafe": "sensor {entity_name} wykryje zagro\u017cenie", + "update": "{entity_name} ma dost\u0119pn\u0105 aktualizacj\u0119", "vibration": "sensor {entity_name} wykryje wibracje" } }, + "device_class": { + "cold": "zimno", + "gas": "gaz", + "heat": "gor\u0105co", + "moisture": "wilgotno\u015b\u0107", + "motion": "ruch", + "occupancy": "obecno\u015b\u0107", + "power": "zasilanie", + "problem": "problem", + "smoke": "dym", + "sound": "d\u017awi\u0119k", + "vibration": "wibracja" + }, "state": { "_": { "off": "wy\u0142.", @@ -166,6 +193,10 @@ "off": "ok", "on": "problem" }, + "running": { + "off": "nie dzia\u0142a", + "on": "dzia\u0142a" + }, "safety": { "off": "brak zagro\u017cenia", "on": "zagro\u017cenie" @@ -178,6 +209,10 @@ "off": "brak", "on": "wykryto" }, + "update": { + "off": "brak aktualizacji", + "on": "dost\u0119pna aktualizacja" + }, "vibration": { "off": "brak", "on": "wykryto" diff --git a/homeassistant/components/binary_sensor/translations/pt-BR.json b/homeassistant/components/binary_sensor/translations/pt-BR.json index 52671ca0425ed..711ac35f9f613 100644 --- a/homeassistant/components/binary_sensor/translations/pt-BR.json +++ b/homeassistant/components/binary_sensor/translations/pt-BR.json @@ -1,4 +1,27 @@ { + "device_automation": { + "condition_type": { + "is_motion": "{entity_name} est\u00e1 detectando movimento", + "is_no_motion": "{entity_name} n\u00e3o est\u00e1 detectando movimento" + }, + "trigger_type": { + "motion": "{entity_name} come\u00e7ou a detectar movimento", + "no_motion": "{entity_name} parou de detectar movimento" + } + }, + "device_class": { + "cold": "frio", + "gas": "g\u00e1s", + "heat": "calor", + "moisture": "umidade", + "motion": "movimento", + "occupancy": "presen\u00e7a", + "power": "energia", + "problem": "problema", + "smoke": "fuma\u00e7a", + "sound": "som", + "vibration": "vibra\u00e7\u00e3o" + }, "state": { "_": { "off": "Desligado", diff --git a/homeassistant/components/binary_sensor/translations/ru.json b/homeassistant/components/binary_sensor/translations/ru.json index fe9e677354718..6a30d031c7031 100644 --- a/homeassistant/components/binary_sensor/translations/ru.json +++ b/homeassistant/components/binary_sensor/translations/ru.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", "is_no_sound": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_no_update": "{entity_name} \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f", "is_no_vibration": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", "is_not_bat_low": "{entity_name} \u0432 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", "is_not_cold": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", "is_not_powered": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0438\u0442\u0430\u043d\u0438\u0435", "is_not_present": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_not_running": "{entity_name} \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442", + "is_not_tampered": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u043d\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u0438\u0435", "is_not_unsafe": "{entity_name} \u0432 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", "is_occupied": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", "is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0438\u0442\u0430\u043d\u0438\u0435", "is_present": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", "is_problem": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "is_running": "{entity_name} \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442", "is_smoke": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", "is_sound": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_tampered": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u043d\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u0438\u0435", "is_unsafe": "{entity_name} \u0432 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_update": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 {entity_name}", "is_vibration": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "gas": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437", "hot": "{entity_name} \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0435\u0442\u0441\u044f", + "is_not_tampered": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u043d\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u0438\u0435", + "is_tampered": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u043d\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u0438\u0435", "light": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0441\u0432\u0435\u0442", "locked": "{entity_name} \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f", "moist": "{entity_name} \u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0432\u043b\u0430\u0436\u043d\u044b\u043c", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", "no_smoke": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", "no_sound": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "no_update": "{entity_name} \u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0435\u0442\u0441\u044f", "no_vibration": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", "not_bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u0437\u0430\u0440\u044f\u0434", "not_cold": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0442\u044c\u0441\u044f", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", "not_powered": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u043f\u0438\u0442\u0430\u043d\u0438\u044f", "not_present": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "not_running": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c", + "not_tampered": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u043d\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u0438\u0435", "not_unsafe": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", "occupied": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", "opened": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", @@ -81,18 +92,34 @@ "powered": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u043f\u0438\u0442\u0430\u043d\u0438\u044f", "present": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", "problem": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "running": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c", "smoke": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", "sound": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "tampered": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u043d\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u0438\u0435", "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "unsafe": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "update": "\u0421\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 {entity_name}", "vibration": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" } }, + "device_class": { + "cold": "\u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "gas": "\u0433\u0430\u0437", + "heat": "\u043d\u0430\u0433\u0440\u0435\u0432", + "moisture": "\u0432\u043b\u0430\u0433\u0430", + "motion": "\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "occupancy": "\u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "power": "\u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c", + "problem": "\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430", + "smoke": "\u0434\u044b\u043c", + "sound": "\u0437\u0432\u0443\u043a", + "vibration": "\u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044f" + }, "state": { "_": { - "off": "\u0412\u044b\u043a\u043b", - "on": "\u0412\u043a\u043b" + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" }, "battery": { "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439", @@ -127,8 +154,8 @@ "on": "\u041d\u0430\u0433\u0440\u0435\u0432" }, "light": { - "off": "\u0412\u044b\u043a\u043b", - "on": "\u0412\u043a\u043b" + "off": "\u041d\u0435\u0442 \u0441\u0432\u0435\u0442\u0430", + "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d \u0441\u0432\u0435\u0442" }, "lock": { "off": "\u0417\u0430\u043a\u0440\u044b\u0442", @@ -166,6 +193,10 @@ "off": "\u041e\u041a", "on": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c\u0430" }, + "running": { + "off": "\u041d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442", + "on": "\u0420\u0430\u0431\u043e\u0442\u0430\u0435\u0442" + }, "safety": { "off": "\u0411\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e", "on": "\u041d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e" @@ -178,6 +209,10 @@ "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d" }, + "update": { + "off": "\u041d\u0435\u0442 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0439", + "on": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435" + }, "vibration": { "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430", "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430" diff --git a/homeassistant/components/binary_sensor/translations/sl.json b/homeassistant/components/binary_sensor/translations/sl.json index 02c4eedeba95f..cc34982ad0a8f 100644 --- a/homeassistant/components/binary_sensor/translations/sl.json +++ b/homeassistant/components/binary_sensor/translations/sl.json @@ -30,6 +30,7 @@ "is_not_plugged_in": "{entity_name} je odklopljen", "is_not_powered": "{entity_name} ni napajan", "is_not_present": "{entity_name} ni prisoten", + "is_not_tampered": "{entity_name} ne zaznava nedovoljenih posegov", "is_not_unsafe": "{entity_name} je varen", "is_occupied": "{entity_name} je zaseden", "is_off": "{entity_name} je izklopljen", @@ -41,6 +42,7 @@ "is_problem": "{entity_name} zaznava te\u017eavo", "is_smoke": "{entity_name} zaznava dim", "is_sound": "{entity_name} zaznava zvok", + "is_tampered": "{entity_name} zaznava nedovoljeno poseganje", "is_unsafe": "{entity_name} ni varen", "is_vibration": "{entity_name} zaznava vibracije" }, @@ -50,6 +52,8 @@ "connected": "{entity_name} povezan", "gas": "{entity_name} za\u010del zaznavati plin", "hot": "{entity_name} je postal vro\u010d", + "is_not_tampered": "{entity_name} je prenehal zaznavati nedovoljena dejanja", + "is_tampered": "{entity_name} je za\u010del zaznavati nedovoljeno poseganje", "light": "{entity_name} za\u010del zaznavati svetlobo", "locked": "{entity_name} zaklenjen", "moist": "{entity_name} postal vla\u017een", @@ -89,6 +93,19 @@ "vibration": "{entity_name} je za\u010del odkrivat vibracije" } }, + "device_class": { + "cold": "hladno", + "gas": "plin", + "heat": "toplota", + "moisture": "vlaga", + "motion": "gibanje", + "occupancy": "zasedenost", + "power": "mo\u010d", + "problem": "te\u017eava", + "smoke": "dim", + "sound": "zvok", + "vibration": "vibracija" + }, "state": { "_": { "off": "Izklju\u010den", @@ -164,6 +181,10 @@ "off": "OK", "on": "Te\u017eava" }, + "running": { + "off": "Ni v teku", + "on": "V teku" + }, "safety": { "off": "Varno", "on": "Nevarno" diff --git a/homeassistant/components/binary_sensor/translations/th.json b/homeassistant/components/binary_sensor/translations/th.json index b8f41eb2b73a1..30c0a5fbe2e70 100644 --- a/homeassistant/components/binary_sensor/translations/th.json +++ b/homeassistant/components/binary_sensor/translations/th.json @@ -1,4 +1,13 @@ { + "device_class": { + "cold": "\u0e40\u0e22\u0e47\u0e19", + "gas": "\u0e41\u0e01\u0e4a\u0e2a", + "heat": "\u0e04\u0e27\u0e32\u0e21\u0e23\u0e49\u0e2d\u0e19", + "problem": "\u0e1b\u0e31\u0e0d\u0e2b\u0e32", + "smoke": "\u0e04\u0e27\u0e31\u0e19", + "sound": "\u0e40\u0e2a\u0e35\u0e22\u0e07", + "vibration": "\u0e01\u0e32\u0e23\u0e2a\u0e31\u0e48\u0e19" + }, "state": { "_": { "off": "\u0e1b\u0e34\u0e14", diff --git a/homeassistant/components/binary_sensor/translations/tr.json b/homeassistant/components/binary_sensor/translations/tr.json index daf44cc967b55..086e139c533f8 100644 --- a/homeassistant/components/binary_sensor/translations/tr.json +++ b/homeassistant/components/binary_sensor/translations/tr.json @@ -1,10 +1,121 @@ { "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} pili zay\u0131f", + "is_cold": "{entity_name} so\u011fuk", + "is_connected": "{entity_name} ba\u011fl\u0131", + "is_gas": "{entity_name} gaz alg\u0131l\u0131yor", + "is_hot": "{entity_name} s\u0131cak", + "is_light": "{entity_name} \u0131\u015f\u0131k alg\u0131l\u0131yor", + "is_locked": "{entity_name} kilitli", + "is_moist": "{entity_name} nemli", + "is_motion": "{entity_name} hareket alg\u0131l\u0131yor", + "is_moving": "{entity_name} ta\u015f\u0131n\u0131yor", + "is_no_gas": "{entity_name} gaz alg\u0131lam\u0131yor", + "is_no_light": "{entity_name} \u0131\u015f\u0131\u011f\u0131 alg\u0131lam\u0131yor", + "is_no_motion": "{entity_name} hareketi alg\u0131lam\u0131yor", + "is_no_problem": "{entity_name} sorun alg\u0131lam\u0131yor", + "is_no_smoke": "{entity_name} duman alg\u0131lam\u0131yor", + "is_no_sound": "{entity_name} sesi alg\u0131lam\u0131yor", + "is_no_update": "{entity_name} g\u00fcncel", + "is_no_vibration": "{entity_name} titre\u015fim alg\u0131lam\u0131yor", + "is_not_bat_low": "{entity_name} pili normal", + "is_not_cold": "{entity_name} so\u011fuk de\u011fil", + "is_not_connected": "{entity_name} ba\u011flant\u0131s\u0131 kesildi", + "is_not_hot": "{entity_name} s\u0131cak de\u011fil", + "is_not_locked": "{entity_name} kilidi a\u00e7\u0131ld\u0131", + "is_not_moist": "{entity_name} kuru", + "is_not_moving": "{entity_name} hareket etmiyor", + "is_not_occupied": "{entity_name} me\u015fgul de\u011fil", + "is_not_open": "{entity_name} kapat\u0131ld\u0131", + "is_not_plugged_in": "{entity_name} fi\u015fi \u00e7ekildi", + "is_not_powered": "{entity_name} desteklenmiyor", + "is_not_present": "{entity_name} mevcut de\u011fil", + "is_not_running": "{entity_name} \u00e7al\u0131\u015fm\u0131yor", + "is_not_tampered": "{entity_name} , kurcalamay\u0131 alg\u0131lam\u0131yor", + "is_not_unsafe": "{entity_name} g\u00fcvenli", + "is_occupied": "{entity_name} dolu", + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k", + "is_open": "{entity_name} a\u00e7\u0131k", + "is_plugged_in": "{entity_name} tak\u0131l\u0131", + "is_powered": "{entity_name} destekleniyor", + "is_present": "{entity_name} mevcut", + "is_problem": "{entity_name} sorun alg\u0131l\u0131yor", + "is_running": "{entity_name} \u00e7al\u0131\u015f\u0131yor", + "is_smoke": "{entity_name} duman alg\u0131l\u0131yor", + "is_sound": "{entity_name} sesi alg\u0131l\u0131yor", + "is_tampered": "{entity_name} , kurcalama alg\u0131l\u0131yor", + "is_unsafe": "{entity_name} g\u00fcvenli de\u011fil", + "is_update": "{entity_name} i\u00e7in bir g\u00fcncelleme mevcut", + "is_vibration": "{entity_name} titre\u015fim alg\u0131l\u0131yor" + }, "trigger_type": { + "bat_low": "{entity_name} pil seviyesi d\u00fc\u015f\u00fck", + "cold": "{entity_name} so\u011fudu", + "connected": "{entity_name} ba\u011fland\u0131", + "gas": "{entity_name} gaz alg\u0131lamaya ba\u015flad\u0131", + "hot": "{entity_name} \u0131s\u0131nd\u0131", + "is_not_tampered": "{entity_name} kurcalamay\u0131 alg\u0131lamay\u0131 durdurdu", + "is_tampered": "{entity_name} , kurcalamay\u0131 alg\u0131lamaya ba\u015flad\u0131", + "light": "{entity_name} \u0131\u015f\u0131\u011f\u0131 alg\u0131lamaya ba\u015flad\u0131", + "locked": "{entity_name} kilitlendi", "moist": "{entity_name} nemli oldu", - "not_opened": "{entity_name} kapat\u0131ld\u0131" + "motion": "{entity_name} hareket alg\u0131lamaya ba\u015flad\u0131", + "moving": "{entity_name} ta\u015f\u0131nmaya ba\u015flad\u0131", + "no_gas": "{entity_name} gaz alg\u0131lamay\u0131 durdurdu", + "no_light": "{entity_name} \u0131\u015f\u0131\u011f\u0131 alg\u0131lamay\u0131 durdurdu", + "no_motion": "{entity_name} hareket alg\u0131lamay\u0131 durdurdu", + "no_problem": "{entity_name} sorunu alg\u0131lamay\u0131 durdurdu", + "no_smoke": "{entity_name} duman alg\u0131lamay\u0131 durdurdu", + "no_sound": "{entity_name} ses alg\u0131lamay\u0131 durdurdu", + "no_update": "{entity_name} g\u00fcncellendi", + "no_vibration": "{entity_name} titre\u015fimi alg\u0131lamay\u0131 durdurdu", + "not_bat_low": "{entity_name} pil normal", + "not_cold": "{entity_name} so\u011fuk olmad\u0131", + "not_connected": "{entity_name} ba\u011flant\u0131s\u0131 kesildi", + "not_hot": "{entity_name} s\u0131cak olmad\u0131", + "not_locked": "{entity_name} kilidi a\u00e7\u0131ld\u0131", + "not_moist": "{entity_name} kuru hale geldi", + "not_moving": "{entity_name} hareket etmeyi durdurdu", + "not_occupied": "{entity_name} dolu de\u011fil", + "not_opened": "{entity_name} kapat\u0131ld\u0131", + "not_plugged_in": "{entity_name} fi\u015fi \u00e7ekildi", + "not_powered": "{entity_name} desteklenmiyor", + "not_present": "{entity_name} mevcut de\u011fil", + "not_running": "{entity_name} art\u0131k \u00e7al\u0131\u015fm\u0131yor", + "not_tampered": "{entity_name} kurcalamay\u0131 alg\u0131lamay\u0131 durdurdu", + "not_unsafe": "{entity_name} g\u00fcvenli hale geldi", + "occupied": "{entity_name} i\u015fgal edildi", + "opened": "{entity_name} a\u00e7\u0131ld\u0131", + "plugged_in": "{entity_name} tak\u0131l\u0131", + "powered": "{entity_name} destekleniyor", + "present": "{entity_name} mevcut", + "problem": "{entity_name} sorun alg\u0131lamaya ba\u015flad\u0131", + "running": "{entity_name} \u00e7al\u0131\u015fmaya ba\u015flad\u0131", + "smoke": "{entity_name} duman alg\u0131lamaya ba\u015flad\u0131", + "sound": "{entity_name} sesi alg\u0131lamaya ba\u015flad\u0131", + "tampered": "{entity_name} , kurcalamay\u0131 alg\u0131lamaya ba\u015flad\u0131", + "turned_off": "{entity_name} kapat\u0131ld\u0131", + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131", + "unsafe": "{entity_name} g\u00fcvensiz hale geldi", + "update": "{entity_name} bir g\u00fcncelleme ald\u0131", + "vibration": "{entity_name} , titre\u015fimi alg\u0131lamaya ba\u015flad\u0131" } }, + "device_class": { + "cold": "so\u011fuk", + "gas": "gaz", + "heat": "s\u0131cakl\u0131k", + "moisture": "nem", + "motion": "hareket", + "occupancy": "doluluk", + "power": "g\u00fc\u00e7", + "problem": "sorun", + "smoke": "duman", + "sound": "ses", + "vibration": "titre\u015fim" + }, "state": { "_": { "off": "Kapal\u0131", @@ -24,14 +135,14 @@ }, "connectivity": { "off": "Ba\u011flant\u0131 kesildi", - "on": "Ba\u011fl\u0131" + "on": "Ba\u011fland\u0131" }, "door": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" }, "garage_door": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" }, "gas": { @@ -47,8 +158,8 @@ "on": "I\u015f\u0131k alg\u0131land\u0131" }, "lock": { - "off": "Kilit kapal\u0131", - "on": "Kilit a\u00e7\u0131k" + "off": "Kilitli", + "on": "Kilitli de\u011fil" }, "moisture": { "off": "Kuru", @@ -67,7 +178,7 @@ "on": "Alg\u0131land\u0131" }, "opening": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" }, "plug": { @@ -75,13 +186,17 @@ "on": "Tak\u0131l\u0131" }, "presence": { - "off": "D\u0131\u015farda", + "off": "D\u0131\u015far\u0131da", "on": "Evde" }, "problem": { "off": "Tamam", "on": "Sorun" }, + "running": { + "off": "\u00c7al\u0131\u015fm\u0131yor", + "on": "\u00c7al\u0131\u015f" + }, "safety": { "off": "G\u00fcvenli", "on": "G\u00fcvensiz" @@ -94,12 +209,16 @@ "off": "Temiz", "on": "Alg\u0131land\u0131" }, + "update": { + "off": "G\u00fcncel", + "on": "G\u00fcncelle\u015ftirme kullan\u0131labilir" + }, "vibration": { "off": "Temiz", "on": "Alg\u0131land\u0131" }, "window": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" } }, diff --git a/homeassistant/components/binary_sensor/translations/zh-Hans.json b/homeassistant/components/binary_sensor/translations/zh-Hans.json index 82cd0d3ccfec9..0c556e7a9c05b 100644 --- a/homeassistant/components/binary_sensor/translations/zh-Hans.json +++ b/homeassistant/components/binary_sensor/translations/zh-Hans.json @@ -178,6 +178,10 @@ "off": "\u6b63\u5e38", "on": "\u89e6\u53d1" }, + "update": { + "off": "\u5df2\u662f\u6700\u65b0", + "on": "\u6709\u66f4\u65b0" + }, "vibration": { "off": "\u6b63\u5e38", "on": "\u89e6\u53d1" diff --git a/homeassistant/components/binary_sensor/translations/zh-Hant.json b/homeassistant/components/binary_sensor/translations/zh-Hant.json index bf50782743ecf..05a3f288f2a95 100644 --- a/homeassistant/components/binary_sensor/translations/zh-Hant.json +++ b/homeassistant/components/binary_sensor/translations/zh-Hant.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name}\u672a\u5075\u6e2c\u5230\u554f\u984c", "is_no_smoke": "{entity_name}\u672a\u5075\u6e2c\u5230\u7159\u9727", "is_no_sound": "{entity_name}\u672a\u5075\u6e2c\u5230\u8072\u97f3", + "is_no_update": "{entity_name} \u5df2\u6700\u65b0", "is_no_vibration": "{entity_name}\u672a\u5075\u6e2c\u5230\u9707\u52d5", "is_not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", "is_not_cold": "{entity_name}\u4e0d\u51b7", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name}\u672a\u63d2\u5165", "is_not_powered": "{entity_name}\u672a\u901a\u96fb", "is_not_present": "{entity_name}\u672a\u51fa\u73fe", + "is_not_running": "{entity_name} \u672a\u5728\u57f7\u884c", + "is_not_tampered": "{entity_name}\u672a\u5075\u6e2c\u5230\u6e1b\u5f31", "is_not_unsafe": "{entity_name}\u5b89\u5168", "is_occupied": "{entity_name}\u6709\u4eba", "is_off": "{entity_name}\u95dc\u9589", @@ -39,9 +42,12 @@ "is_powered": "{entity_name}\u901a\u96fb", "is_present": "{entity_name}\u51fa\u73fe", "is_problem": "{entity_name}\u6b63\u5075\u6e2c\u5230\u554f\u984c", + "is_running": "{entity_name} \u6b63\u5728\u57f7\u884c", "is_smoke": "{entity_name}\u6b63\u5075\u6e2c\u5230\u7159\u9727", "is_sound": "{entity_name}\u6b63\u5075\u6e2c\u5230\u8072\u97f3", + "is_tampered": "{entity_name}\u5075\u6e2c\u5230\u6e1b\u5f31\u4f5c\u4e2d", "is_unsafe": "{entity_name}\u4e0d\u5b89\u5168", + "is_update": "{entity_name} \u6709\u66f4\u65b0", "is_vibration": "{entity_name}\u6b63\u5075\u6e2c\u5230\u9707\u52d5" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name}\u5df2\u9023\u7dda", "gas": "{entity_name}\u5df2\u958b\u59cb\u5075\u6e2c\u6c23\u9ad4", "hot": "{entity_name}\u5df2\u8b8a\u71b1", + "is_not_tampered": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u6e1b\u5f31", + "is_tampered": "{entity_name}\u5df2\u5075\u6e2c\u5230\u6e1b\u5f31", "light": "{entity_name}\u5df2\u958b\u59cb\u5075\u6e2c\u5149\u7dda", "locked": "{entity_name}\u5df2\u4e0a\u9396", "moist": "{entity_name}\u5df2\u8b8a\u6f6e\u6fd5", @@ -61,6 +69,7 @@ "no_problem": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", "no_smoke": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", "no_sound": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", + "no_update": "{entity_name} \u5df2\u6700\u65b0", "no_vibration": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", "not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", "not_cold": "{entity_name}\u5df2\u4e0d\u51b7", @@ -74,6 +83,8 @@ "not_plugged_in": "{entity_name}\u672a\u63d2\u5165", "not_powered": "{entity_name}\u672a\u901a\u96fb", "not_present": "{entity_name}\u672a\u51fa\u73fe", + "not_running": "{entity_name} \u4e0d\u518d\u57f7\u884c", + "not_tampered": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u6e1b\u5f31", "not_unsafe": "{entity_name}\u5df2\u5b89\u5168", "occupied": "{entity_name}\u8b8a\u6210\u6709\u4eba", "opened": "{entity_name}\u5df2\u958b\u555f", @@ -81,14 +92,30 @@ "powered": "{entity_name}\u5df2\u901a\u96fb", "present": "{entity_name}\u5df2\u51fa\u73fe", "problem": "{entity_name}\u5df2\u5075\u6e2c\u5230\u554f\u984c", + "running": "{entity_name} \u958b\u59cb\u57f7\u884c", "smoke": "{entity_name}\u5df2\u5075\u6e2c\u5230\u7159\u9727", "sound": "{entity_name}\u5df2\u5075\u6e2c\u5230\u8072\u97f3", + "tampered": "{entity_name}\u5df2\u5075\u6e2c\u5230\u6e1b\u5f31", "turned_off": "{entity_name}\u5df2\u95dc\u9589", "turned_on": "{entity_name}\u5df2\u958b\u555f", "unsafe": "{entity_name}\u5df2\u4e0d\u5b89\u5168", + "update": "{entity_name} \u6709\u66f4\u65b0", "vibration": "{entity_name}\u5df2\u5075\u6e2c\u5230\u9707\u52d5" } }, + "device_class": { + "cold": "\u51b7", + "gas": "\u6c23\u9ad4", + "heat": "\u71b1", + "moisture": "\u6fd5\u6c23", + "motion": "\u52d5\u4f5c", + "occupancy": "\u4f54\u7a7a", + "power": "\u96fb\u529b", + "problem": "\u7570\u5e38", + "smoke": "\u7159\u9727", + "sound": "\u8072\u97f3", + "vibration": "\u9707\u52d5" + }, "state": { "_": { "off": "\u95dc\u9589", @@ -166,6 +193,10 @@ "off": "\u78ba\u5b9a", "on": "\u7570\u5e38" }, + "running": { + "off": "\u672a\u57f7\u884c", + "on": "\u57f7\u884c\u4e2d" + }, "safety": { "off": "\u5b89\u5168", "on": "\u5371\u96aa" @@ -178,6 +209,10 @@ "off": "\u672a\u89f8\u767c", "on": "\u5df2\u89f8\u767c" }, + "update": { + "off": "\u5df2\u6700\u65b0", + "on": "\u6709\u66f4\u65b0" + }, "vibration": { "off": "\u672a\u5075\u6e2c", "on": "\u5075\u6e2c" @@ -187,5 +222,5 @@ "on": "\u958b\u555f" } }, - "title": "\u4e8c\u9032\u4f4d\u50b3\u611f\u5668" + "title": "\u4e8c\u9032\u4f4d\u611f\u6e2c\u5668" } \ No newline at end of file diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index 4acce03d6faaf..553d0aafa055a 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -1,13 +1,18 @@ """Bitcoin information service that uses blockchain.com.""" +from __future__ import annotations + from datetime import timedelta import logging from blockchain import exchangerates, statistics import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_OPTIONS, TIME_MINUTES, @@ -25,34 +30,112 @@ SCAN_INTERVAL = timedelta(minutes=5) -OPTION_TYPES = { - "exchangerate": ["Exchange rate (1 BTC)", None], - "trade_volume_btc": ["Trade volume", "BTC"], - "miners_revenue_usd": ["Miners revenue", "USD"], - "btc_mined": ["Mined", "BTC"], - "trade_volume_usd": ["Trade volume", "USD"], - "difficulty": ["Difficulty", None], - "minutes_between_blocks": ["Time between Blocks", TIME_MINUTES], - "number_of_transactions": ["No. of Transactions", None], - "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"], - "timestamp": ["Timestamp", None], - "mined_blocks": ["Mined Blocks", None], - "blocks_size": ["Block size", None], - "total_fees_btc": ["Total fees", "BTC"], - "total_btc_sent": ["Total sent", "BTC"], - "estimated_btc_sent": ["Estimated sent", "BTC"], - "total_btc": ["Total", "BTC"], - "total_blocks": ["Total Blocks", None], - "next_retarget": ["Next retarget", None], - "estimated_transaction_volume_usd": ["Est. Transaction volume", "USD"], - "miners_revenue_btc": ["Miners revenue", "BTC"], - "market_price_usd": ["Market price", "USD"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="exchangerate", + name="Exchange rate (1 BTC)", + ), + SensorEntityDescription( + key="trade_volume_btc", + name="Trade volume", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="miners_revenue_usd", + name="Miners revenue", + native_unit_of_measurement="USD", + ), + SensorEntityDescription( + key="btc_mined", + name="Mined", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="trade_volume_usd", + name="Trade volume", + native_unit_of_measurement="USD", + ), + SensorEntityDescription( + key="difficulty", + name="Difficulty", + ), + SensorEntityDescription( + key="minutes_between_blocks", + name="Time between Blocks", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="number_of_transactions", + name="No. of Transactions", + ), + SensorEntityDescription( + key="hash_rate", + name="Hash rate", + native_unit_of_measurement=f"PH/{TIME_SECONDS}", + ), + SensorEntityDescription( + key="timestamp", + name="Timestamp", + ), + SensorEntityDescription( + key="mined_blocks", + name="Mined Blocks", + ), + SensorEntityDescription( + key="blocks_size", + name="Block size", + ), + SensorEntityDescription( + key="total_fees_btc", + name="Total fees", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_btc_sent", + name="Total sent", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="estimated_btc_sent", + name="Estimated sent", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_btc", + name="Total", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_blocks", + name="Total Blocks", + ), + SensorEntityDescription( + key="next_retarget", + name="Next retarget", + ), + SensorEntityDescription( + key="estimated_transaction_volume_usd", + name="Est. Transaction volume", + native_unit_of_measurement="USD", + ), + SensorEntityDescription( + key="miners_revenue_btc", + name="Miners revenue", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="market_price_usd", + name="Market price", + native_unit_of_measurement="USD", + ), +) + +OPTION_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DISPLAY_OPTIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(OPTION_TYPES)] + cv.ensure_list, [vol.In(OPTION_KEYS)] ), vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, } @@ -69,49 +152,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): currency = DEFAULT_CURRENCY data = BitcoinData() - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(BitcoinSensor(data, variable, currency)) + entities = [ + BitcoinSensor(data, currency, description) + for description in SENSOR_TYPES + if description.key in config[CONF_DISPLAY_OPTIONS] + ] - add_entities(dev, True) + add_entities(entities, True) class BitcoinSensor(SensorEntity): """Representation of a Bitcoin sensor.""" - def __init__(self, data, option_type, currency): + _attr_attribution = ATTRIBUTION + _attr_icon = ICON + + def __init__(self, data, currency, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = OPTION_TYPES[option_type][0] - self._unit_of_measurement = OPTION_TYPES[option_type][1] self._currency = currency - self.type = option_type - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data and updates the states.""" @@ -119,49 +179,50 @@ def update(self): stats = self.data.stats ticker = self.data.ticker - if self.type == "exchangerate": - self._state = ticker[self._currency].p15min - self._unit_of_measurement = self._currency - elif self.type == "trade_volume_btc": - self._state = f"{stats.trade_volume_btc:.1f}" - elif self.type == "miners_revenue_usd": - self._state = f"{stats.miners_revenue_usd:.0f}" - elif self.type == "btc_mined": - self._state = str(stats.btc_mined * 0.00000001) - elif self.type == "trade_volume_usd": - self._state = f"{stats.trade_volume_usd:.1f}" - elif self.type == "difficulty": - self._state = f"{stats.difficulty:.0f}" - elif self.type == "minutes_between_blocks": - self._state = f"{stats.minutes_between_blocks:.2f}" - elif self.type == "number_of_transactions": - self._state = str(stats.number_of_transactions) - elif self.type == "hash_rate": - self._state = f"{stats.hash_rate * 0.000001:.1f}" - elif self.type == "timestamp": - self._state = stats.timestamp - elif self.type == "mined_blocks": - self._state = str(stats.mined_blocks) - elif self.type == "blocks_size": - self._state = f"{stats.blocks_size:.1f}" - elif self.type == "total_fees_btc": - self._state = f"{stats.total_fees_btc * 0.00000001:.2f}" - elif self.type == "total_btc_sent": - self._state = f"{stats.total_btc_sent * 0.00000001:.2f}" - elif self.type == "estimated_btc_sent": - self._state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" - elif self.type == "total_btc": - self._state = f"{stats.total_btc * 0.00000001:.2f}" - elif self.type == "total_blocks": - self._state = f"{stats.total_blocks:.0f}" - elif self.type == "next_retarget": - self._state = f"{stats.next_retarget:.2f}" - elif self.type == "estimated_transaction_volume_usd": - self._state = f"{stats.estimated_transaction_volume_usd:.2f}" - elif self.type == "miners_revenue_btc": - self._state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" - elif self.type == "market_price_usd": - self._state = f"{stats.market_price_usd:.2f}" + sensor_type = self.entity_description.key + if sensor_type == "exchangerate": + self._attr_native_value = ticker[self._currency].p15min + self._attr_native_unit_of_measurement = self._currency + elif sensor_type == "trade_volume_btc": + self._attr_native_value = f"{stats.trade_volume_btc:.1f}" + elif sensor_type == "miners_revenue_usd": + self._attr_native_value = f"{stats.miners_revenue_usd:.0f}" + elif sensor_type == "btc_mined": + self._attr_native_value = str(stats.btc_mined * 0.00000001) + elif sensor_type == "trade_volume_usd": + self._attr_native_value = f"{stats.trade_volume_usd:.1f}" + elif sensor_type == "difficulty": + self._attr_native_value = f"{stats.difficulty:.0f}" + elif sensor_type == "minutes_between_blocks": + self._attr_native_value = f"{stats.minutes_between_blocks:.2f}" + elif sensor_type == "number_of_transactions": + self._attr_native_value = str(stats.number_of_transactions) + elif sensor_type == "hash_rate": + self._attr_native_value = f"{stats.hash_rate * 0.000001:.1f}" + elif sensor_type == "timestamp": + self._attr_native_value = stats.timestamp + elif sensor_type == "mined_blocks": + self._attr_native_value = str(stats.mined_blocks) + elif sensor_type == "blocks_size": + self._attr_native_value = f"{stats.blocks_size:.1f}" + elif sensor_type == "total_fees_btc": + self._attr_native_value = f"{stats.total_fees_btc * 0.00000001:.2f}" + elif sensor_type == "total_btc_sent": + self._attr_native_value = f"{stats.total_btc_sent * 0.00000001:.2f}" + elif sensor_type == "estimated_btc_sent": + self._attr_native_value = f"{stats.estimated_btc_sent * 0.00000001:.2f}" + elif sensor_type == "total_btc": + self._attr_native_value = f"{stats.total_btc * 0.00000001:.2f}" + elif sensor_type == "total_blocks": + self._attr_native_value = f"{stats.total_blocks:.0f}" + elif sensor_type == "next_retarget": + self._attr_native_value = f"{stats.next_retarget:.2f}" + elif sensor_type == "estimated_transaction_volume_usd": + self._attr_native_value = f"{stats.estimated_transaction_volume_usd:.2f}" + elif sensor_type == "miners_revenue_btc": + self._attr_native_value = f"{stats.miners_revenue_btc * 0.00000001:.1f}" + elif sensor_type == "market_price_usd": + self._attr_native_value = f"{stats.market_price_usd:.2f}" class BitcoinData: diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index d0cade31a7240..f83751fb5034b 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -31,40 +31,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): route = config[CONF_ROUTE] data = Bizkaibus(stop, route) - add_entities([BizkaibusSensor(data, stop, route, name)], True) + add_entities([BizkaibusSensor(data, name)], True) class BizkaibusSensor(SensorEntity): """The class for handling the data.""" - def __init__(self, data, stop, route, name): + _attr_native_unit_of_measurement = TIME_MINUTES + + def __init__(self, data, name): """Initialize the sensor.""" self.data = data - self.stop = stop - self.route = route - self._name = name - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return TIME_MINUTES + self._attr_name = name def update(self): """Get the latest data from the webservice.""" self.data.update() with suppress(TypeError): - self._state = self.data.info[0][ATTR_DUE_IN] + self._attr_native_value = self.data.info[0][ATTR_DUE_IN] class Bizkaibus: diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 9ae696a5276be..5407580612e63 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -131,6 +131,8 @@ def service_handle(service): class BlackbirdZone(MediaPlayerEntity): """Representation of a Blackbird matrix zone.""" + _attr_supported_features = SUPPORT_BLACKBIRD + def __init__(self, blackbird, sources, zone_id, zone_name): """Initialize new zone.""" self._blackbird = blackbird @@ -139,55 +141,28 @@ def __init__(self, blackbird, sources, zone_id, zone_name): # dict source name -> source_id self._source_name_id = {v: k for k, v in sources.items()} # ordered list of all source names - self._source_names = sorted( + self._attr_source_list = sorted( self._source_name_id.keys(), key=lambda v: self._source_name_id[v] ) self._zone_id = zone_id - self._name = zone_name - self._state = None - self._source = None + self._attr_name = zone_name def update(self): """Retrieve latest state.""" state = self._blackbird.zone_status(self._zone_id) if not state: return - self._state = STATE_ON if state.power else STATE_OFF + self._attr_state = STATE_ON if state.power else STATE_OFF idx = state.av if idx in self._source_id_name: - self._source = self._source_id_name[idx] + self._attr_source = self._source_id_name[idx] else: - self._source = None - - @property - def name(self): - """Return the name of the zone.""" - return self._name - - @property - def state(self): - """Return the state of the zone.""" - return self._state - - @property - def supported_features(self): - """Return flag of media commands that are supported.""" - return SUPPORT_BLACKBIRD + self._attr_source = None @property def media_title(self): """Return the current source as media title.""" - return self._source - - @property - def source(self): - """Return the current input source of the device.""" - return self._source - - @property - def source_list(self): - """List of available input sources.""" - return self._source_names + return self.source def set_all_zones(self, source): """Set all zones to one source.""" diff --git a/homeassistant/components/blackbird/services.yaml b/homeassistant/components/blackbird/services.yaml index a783dff241bf5..7b3096c25e4f6 100644 --- a/homeassistant/components/blackbird/services.yaml +++ b/homeassistant/components/blackbird/services.yaml @@ -1,9 +1,20 @@ set_all_zones: + name: Set all zones description: Set all Blackbird zones to a single source. fields: entity_id: + name: Entity description: Name of any blackbird zone. + required: true example: "media_player.zone_1" + selector: + entity: + integration: blackbird + domain: media_player source: + name: Source description: Name of source to switch to. + required: true example: "Source 1" + selector: + text: diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index fe2265ed78d28..b6a0045940d9b 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -6,22 +6,29 @@ from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["cover", "sensor", "switch", "air_quality", "light", "climate"] +PLATFORMS = [ + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, + Platform.AIR_QUALITY, + Platform.LIGHT, + Platform.CLIMATE, +] PARALLEL_UPDATES = 0 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" websession = async_get_clientsession(hass) @@ -47,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -79,16 +86,16 @@ class BleBoxEntity(Entity): def __init__(self, feature): """Initialize a BleBox entity.""" self._feature = feature - - @property - def name(self): - """Return the internal entity name.""" - return self._feature.full_name - - @property - def unique_id(self): - """Return a unique id.""" - return self._feature.unique_id + self._attr_name = feature.full_name + self._attr_unique_id = feature.unique_id + product = feature.product + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, product.unique_id)}, + manufacturer=product.brand, + model=product.model, + name=product.name, + sw_version=product.firmware_version, + ) async def async_update(self): """Update the entity state.""" @@ -96,15 +103,3 @@ async def async_update(self): await self._feature.async_update() except Error as ex: _LOGGER.error("Updating '%s' failed: %s", self.name, ex) - - @property - def device_info(self): - """Return device information for this entity.""" - product = self._feature.product - return { - "identifiers": {(DOMAIN, product.unique_id)}, - "name": product.name, - "manufacturer": product.brand, - "model": product.model, - "sw_version": product.firmware_version, - } diff --git a/homeassistant/components/blebox/air_quality.py b/homeassistant/components/blebox/air_quality.py index e7e9bac1f97a1..debf0201a3fbb 100644 --- a/homeassistant/components/blebox/air_quality.py +++ b/homeassistant/components/blebox/air_quality.py @@ -15,10 +15,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxAirQualityEntity(BleBoxEntity, AirQualityEntity): """Representation of a BleBox air quality feature.""" - @property - def icon(self): - """Return the icon.""" - return "mdi:blur" + _attr_icon = "mdi:blur" @property def particulate_matter_0_1(self): diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 4ee8cf9be7656..5dec5725607fa 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -25,10 +25,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxClimateEntity(BleBoxEntity, ClimateEntity): """Representation of a BleBox climate feature (saunaBox).""" - @property - def supported_features(self): - """Return the supported climate features.""" - return SUPPORT_TARGET_TEMPERATURE + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT] + _attr_temperature_unit = TEMP_CELSIUS @property def hvac_mode(self): @@ -41,23 +40,12 @@ def hvac_mode(self): @property def hvac_action(self): """Return the actual current HVAC action.""" - is_on = self._feature.is_on - if not is_on: + if not (is_on := self._feature.is_on): return None if is_on is None else CURRENT_HVAC_OFF # NOTE: In practice, there's no need to handle case when is_heating is None return CURRENT_HVAC_HEAT if self._feature.is_heating else CURRENT_HVAC_IDLE - @property - def hvac_modes(self): - """Return a list of possible HVAC modes.""" - return [HVAC_MODE_OFF, HVAC_MODE_HEAT] - - @property - def temperature_unit(self): - """Return the temperature unit.""" - return TEMP_CELSIUS - @property def max_temp(self): """Return the maximum temperature supported.""" diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index d310232f77617..17dffe154d1c6 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -91,7 +91,7 @@ async def async_step_user(self, user_input=None): addr = host_port(user_input) - for entry in hass.config_entries.async_entries(DOMAIN): + for entry in self._async_current_entries(): if addr == host_port(entry.data): host, port = addr return self.async_abort( diff --git a/homeassistant/components/blebox/const.py b/homeassistant/components/blebox/const.py index f5eba403c75dd..401aa0a877112 100644 --- a/homeassistant/components/blebox/const.py +++ b/homeassistant/components/blebox/const.py @@ -1,16 +1,15 @@ """Constants for the BleBox devices integration.""" from homeassistant.components.cover import ( - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GATE, - DEVICE_CLASS_SHUTTER, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, + CoverDeviceClass, ) -from homeassistant.components.switch import DEVICE_CLASS_SWITCH -from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.const import TEMP_CELSIUS DOMAIN = "blebox" PRODUCT = "product" @@ -24,11 +23,11 @@ UNKNOWN = "unknown" BLEBOX_TO_HASS_DEVICE_CLASSES = { - "shutter": DEVICE_CLASS_SHUTTER, - "gatebox": DEVICE_CLASS_DOOR, - "gate": DEVICE_CLASS_GATE, - "relay": DEVICE_CLASS_SWITCH, - "temperature": DEVICE_CLASS_TEMPERATURE, + "shutter": CoverDeviceClass.SHUTTER, + "gatebox": CoverDeviceClass.DOOR, + "gate": CoverDeviceClass.GATE, + "relay": SwitchDeviceClass.SWITCH, + "temperature": SensorDeviceClass.TEMPERATURE, } BLEBOX_TO_HASS_COVER_STATES = { diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 620adacf3f65b..b107dba1e7fb7 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -27,23 +27,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxCoverEntity(BleBoxEntity, CoverEntity): """Representation of a BleBox cover feature.""" - @property - def state(self): - """Return the equivalent HA cover state.""" - return BLEBOX_TO_HASS_COVER_STATES[self._feature.state] - - @property - def device_class(self): - """Return the device class.""" - return BLEBOX_TO_HASS_DEVICE_CLASSES[self._feature.device_class] - - @property - def supported_features(self): - """Return the supported cover features.""" - position = SUPPORT_SET_POSITION if self._feature.is_slider else 0 - stop = SUPPORT_STOP if self._feature.has_stop else 0 - - return position | stop | SUPPORT_OPEN | SUPPORT_CLOSE + def __init__(self, feature): + """Initialize a BleBox cover feature.""" + super().__init__(feature) + self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] + position = SUPPORT_SET_POSITION if feature.is_slider else 0 + stop = SUPPORT_STOP if feature.has_stop else 0 + self._attr_supported_features = position | stop | SUPPORT_OPEN | SUPPORT_CLOSE @property def current_cover_position(self): @@ -88,5 +78,5 @@ async def async_stop_cover(self, **kwargs): await self._feature.async_stop() def _is_state(self, state_name): - value = self.state + value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index a825d102717a7..efbdb03879483 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -5,19 +5,13 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ATTR_WHITE_VALUE, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_WHITE_VALUE, + ATTR_RGBW_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_ONOFF, + COLOR_MODE_RGBW, LightEntity, ) -from homeassistant.util.color import ( - color_hs_to_RGB, - color_rgb_to_hex, - color_RGB_to_hs, - rgb_hex_to_rgb_list, -) +from homeassistant.util.color import color_rgb_to_hex, rgb_hex_to_rgb_list from . import BleBoxEntity, create_blebox_entities @@ -35,16 +29,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxLightEntity(BleBoxEntity, LightEntity): """Representation of BleBox lights.""" - @property - def supported_features(self): - """Return supported features.""" - white = SUPPORT_WHITE_VALUE if self._feature.supports_white else 0 - color = SUPPORT_COLOR if self._feature.supports_color else 0 - brightness = SUPPORT_BRIGHTNESS if self._feature.supports_brightness else 0 - return white | color | brightness + def __init__(self, feature): + """Initialize a BleBox light.""" + super().__init__(feature) + self._attr_supported_color_modes = {self.color_mode} @property - def is_on(self): + def is_on(self) -> bool: """Return if light is on.""" return self._feature.is_on @@ -54,25 +45,26 @@ def brightness(self): return self._feature.brightness @property - def white_value(self): - """Return the white value.""" - return self._feature.white_value + def color_mode(self): + """Return the color mode.""" + if self._feature.supports_white and self._feature.supports_color: + return COLOR_MODE_RGBW + if self._feature.supports_brightness: + return COLOR_MODE_BRIGHTNESS + return COLOR_MODE_ONOFF @property - def hs_color(self): + def rgbw_color(self): """Return the hue and saturation.""" - rgbw_hex = self._feature.rgbw_hex - if rgbw_hex is None: + if (rgbw_hex := self._feature.rgbw_hex) is None: return None - rgb = rgb_hex_to_rgb_list(rgbw_hex)[0:3] - return color_RGB_to_hs(*rgb) + return tuple(rgb_hex_to_rgb_list(rgbw_hex)[0:4]) async def async_turn_on(self, **kwargs): """Turn the light on.""" - white = kwargs.get(ATTR_WHITE_VALUE) - hs_color = kwargs.get(ATTR_HS_COLOR) + rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) feature = self._feature @@ -81,12 +73,9 @@ async def async_turn_on(self, **kwargs): if brightness is not None: value = feature.apply_brightness(value, brightness) - if white is not None: - value = feature.apply_white(value, white) - - if hs_color is not None: - raw_rgb = color_rgb_to_hex(*color_hs_to_RGB(*hs_color)) - value = feature.apply_color(value, raw_rgb) + if rgbw is not None: + value = feature.apply_white(value, rgbw[3]) + value = feature.apply_color(value, color_rgb_to_hex(*rgbw[0:3])) try: await self._feature.async_on(value) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 00b4b61c507ed..39c0d37e2e3c3 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -3,7 +3,7 @@ "name": "BleBox devices", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", - "requirements": ["blebox_uniapi==1.3.2"], - "codeowners": ["@gadgetmobile"], + "requirements": ["blebox_uniapi==1.3.3"], + "codeowners": ["@bbx-a", "@bbx-jp"], "iot_class": "local_polling" } diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index c1b9d8501c10f..200661dcd1c30 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -17,17 +17,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxSensorEntity(BleBoxEntity, SensorEntity): """Representation of a BleBox sensor feature.""" + def __init__(self, feature): + """Initialize a BleBox sensor feature.""" + super().__init__(feature) + self._attr_native_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] + self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] + @property - def state(self): + def native_value(self): """Return the state.""" return self._feature.current - - @property - def unit_of_measurement(self): - """Return the unit.""" - return BLEBOX_TO_UNIT_MAP[self._feature.unit] - - @property - def device_class(self): - """Return the device class.""" - return BLEBOX_TO_HASS_DEVICE_CLASSES[self._feature.device_class] diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index e88773db639f4..3769235e943a5 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -15,10 +15,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxSwitchEntity(BleBoxEntity, SwitchEntity): """Representation of a BleBox switch feature.""" - @property - def device_class(self): - """Return the device class.""" - return BLEBOX_TO_HASS_DEVICE_CLASSES[self._feature.device_class] + def __init__(self, feature): + """Initialize a BleBox switch feature.""" + super().__init__(feature) + self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] @property def is_on(self): diff --git a/homeassistant/components/blebox/translations/bg.json b/homeassistant/components/blebox/translations/bg.json new file mode 100644 index 0000000000000..7f4a28945075f --- /dev/null +++ b/homeassistant/components/blebox/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blebox/translations/ca.json b/homeassistant/components/blebox/translations/ca.json index aba528fafb57d..96a3a9f37addc 100644 --- a/homeassistant/components/blebox/translations/ca.json +++ b/homeassistant/components/blebox/translations/ca.json @@ -9,14 +9,14 @@ "unknown": "Error inesperat", "unsupported_version": "El dispositiu BleBox t\u00e9 un firmware obsolet. Primer actualitza'l." }, - "flow_title": "Dispositiu BleBox: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { "host": "Adre\u00e7a IP", "port": "Port" }, - "description": "Configura el teu dispositiu BleBox per a integrar-lo a Home Assistant.", + "description": "Configura la integraci\u00f3 d'un dispositiu BleBox amb Home Assistant.", "title": "Configuraci\u00f3 del dispositiu BleBox" } } diff --git a/homeassistant/components/blebox/translations/de.json b/homeassistant/components/blebox/translations/de.json index 37c8dde54e5e4..c104a96fe4641 100644 --- a/homeassistant/components/blebox/translations/de.json +++ b/homeassistant/components/blebox/translations/de.json @@ -7,17 +7,17 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler", - "unsupported_version": "Das BleBox-Ger\u00e4t hat eine veraltete Firmware. Bitte aktualisieren Sie es zuerst." + "unsupported_version": "Das BleBox-Ger\u00e4t hat eine veraltete Firmware. Bitte aktualisiere es zuerst." }, - "flow_title": "BleBox-Ger\u00e4t: {name} ( {host} )", + "flow_title": "{name} ( {host} )", "step": { "user": { "data": { - "host": "IP Adresse", + "host": "IP-Adresse", "port": "Port" }, - "description": "Richten Sie Ihre BleBox f\u00fcr die Integration mit dem Home Assistant ein.", - "title": "Richten Sie Ihr BleBox-Ger\u00e4t ein" + "description": "Richte deine BleBox f\u00fcr die Integration mit dem Home Assistant ein.", + "title": "Richte dein BleBox-Ger\u00e4t ein" } } } diff --git a/homeassistant/components/blebox/translations/es-419.json b/homeassistant/components/blebox/translations/es-419.json index eb0545e4fa428..89bafe049f2d3 100644 --- a/homeassistant/components/blebox/translations/es-419.json +++ b/homeassistant/components/blebox/translations/es-419.json @@ -16,7 +16,8 @@ "host": "Direcci\u00f3n IP", "port": "Puerto" }, - "description": "Configure su BleBox para integrarse con Home Assistant." + "description": "Configure su BleBox para integrarse con Home Assistant.", + "title": "Configure su dispositivo BleBox" } } } diff --git a/homeassistant/components/blebox/translations/et.json b/homeassistant/components/blebox/translations/et.json index 64ca64c1d80cf..913428a897ec4 100644 --- a/homeassistant/components/blebox/translations/et.json +++ b/homeassistant/components/blebox/translations/et.json @@ -9,7 +9,7 @@ "unknown": "Tundmatu viga", "unsupported_version": "BleBoxi seadmel on vananenud p\u00fcsivara. Esmalt v\u00e4rskenda seda." }, - "flow_title": "BleBoxi seade: {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/fr.json b/homeassistant/components/blebox/translations/fr.json index d30d026d177dc..6a5224f9239e9 100644 --- a/homeassistant/components/blebox/translations/fr.json +++ b/homeassistant/components/blebox/translations/fr.json @@ -2,14 +2,14 @@ "config": { "abort": { "address_already_configured": "Un p\u00e9riph\u00e9rique BleBox est d\u00e9j\u00e0 configur\u00e9 \u00e0 {address}.", - "already_configured": "Ce p\u00e9riph\u00e9rique BleBox est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de connecter le p\u00e9riph\u00e9rique BleBox. (V\u00e9rifiez les journaux pour les erreurs.)", - "unknown": "Erreur inconnue lors de la connexion au p\u00e9riph\u00e9rique BleBox. (V\u00e9rifiez les journaux pour les erreurs.)", + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue", "unsupported_version": "L'appareil BleBox a un micrologiciel obsol\u00e8te. Veuillez d'abord le mettre \u00e0 jour." }, - "flow_title": "P\u00e9riph\u00e9rique Blebox: {name} ({host)}", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/he.json b/homeassistant/components/blebox/translations/he.json index 001f8457f1437..c904fe2bb04bf 100644 --- a/homeassistant/components/blebox/translations/he.json +++ b/homeassistant/components/blebox/translations/he.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/hu.json b/homeassistant/components/blebox/translations/hu.json index 9649d70d976dd..0d9e2b5a3ff71 100644 --- a/homeassistant/components/blebox/translations/hu.json +++ b/homeassistant/components/blebox/translations/hu.json @@ -1,20 +1,23 @@ { "config": { "abort": { + "address_already_configured": "Egy BleBox-eszk\u00f6z m\u00e1r konfigur\u00e1lva van a {address} c\u00edmen.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "unsupported_version": "A BleBox eszk\u00f6z elavult firmware-rel rendelkezik. El\u0151sz\u00f6r friss\u00edtsd." + "unsupported_version": "A BleBox eszk\u00f6z elavult firmware-rel rendelkezik. K\u00e9rem, friss\u00edtse el\u0151bb." }, - "flow_title": "BleBox eszk\u00f6z: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { "host": "IP c\u00edm", "port": "Port" - } + }, + "description": "\u00c1ll\u00edtsa be a BleBox k\u00e9sz\u00fcl\u00e9ket a Homeassistantba val\u00f3 integr\u00e1ci\u00f3hoz.", + "title": "\u00c1ll\u00edtsa be a BleBox eszk\u00f6zt" } } } diff --git a/homeassistant/components/blebox/translations/id.json b/homeassistant/components/blebox/translations/id.json index 2ef604d1bff6f..f0bb4d34746c3 100644 --- a/homeassistant/components/blebox/translations/id.json +++ b/homeassistant/components/blebox/translations/id.json @@ -9,7 +9,7 @@ "unknown": "Kesalahan yang tidak diharapkan", "unsupported_version": "Firmware Perangkat BleBox sudah usang. Tingkatkan terlebih dulu." }, - "flow_title": "Perangkat BleBox: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/it.json b/homeassistant/components/blebox/translations/it.json index 265a158e22d62..6d377840e906f 100644 --- a/homeassistant/components/blebox/translations/it.json +++ b/homeassistant/components/blebox/translations/it.json @@ -9,7 +9,7 @@ "unknown": "Errore imprevisto", "unsupported_version": "Il dispositivo BleBox ha un firmware obsoleto. Si prega di aggiornarlo prima." }, - "flow_title": "Dispositivo BleBox: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/ja.json b/homeassistant/components/blebox/translations/ja.json new file mode 100644 index 0000000000000..87e840f043e5b --- /dev/null +++ b/homeassistant/components/blebox/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "address_already_configured": "{address} \u306b\u306f\u3059\u3067\u306bBleBox\u30c7\u30d0\u30a4\u30b9\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "unsupported_version": "BleBox device\u306e\u30d5\u30a1\u30fc\u30e0\u30a6\u30a7\u30a2\u304c\u53e4\u304f\u306a\u3063\u3066\u3044\u307e\u3059\u3002\u6700\u521d\u306b\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "BleBox\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "BleBox\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blebox/translations/nl.json b/homeassistant/components/blebox/translations/nl.json index 0fad3e9264fe1..65a775e6f72de 100644 --- a/homeassistant/components/blebox/translations/nl.json +++ b/homeassistant/components/blebox/translations/nl.json @@ -9,7 +9,7 @@ "unknown": "Onverwachte fout", "unsupported_version": "BleBox-apparaat heeft verouderde firmware. Upgrade het eerst." }, - "flow_title": "BleBox-apparaat: {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/no.json b/homeassistant/components/blebox/translations/no.json index cf46945950fc1..3ab5987eba7a3 100644 --- a/homeassistant/components/blebox/translations/no.json +++ b/homeassistant/components/blebox/translations/no.json @@ -9,7 +9,7 @@ "unknown": "Uventet feil", "unsupported_version": "BleBox-enheten har utdatert fastvare. Vennligst oppgrader den f\u00f8rst." }, - "flow_title": "BleBox-enhet: {name} ({host})", + "flow_title": "{name} ( {host} )", "step": { "user": { "data": { diff --git a/homeassistant/components/blebox/translations/pl.json b/homeassistant/components/blebox/translations/pl.json index 9f61e308f64bc..0174380794e2b 100644 --- a/homeassistant/components/blebox/translations/pl.json +++ b/homeassistant/components/blebox/translations/pl.json @@ -9,14 +9,14 @@ "unknown": "Nieoczekiwany b\u0142\u0105d", "unsupported_version": "Urz\u0105dzenie BleBox ma nieaktualny firmware. Prosz\u0119 go najpierw zaktualizowa\u0107." }, - "flow_title": "Urz\u0105dzenie BleBox: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { "host": "Adres IP", "port": "Port" }, - "description": "Skonfiguruj BleBox, aby zintegrowa\u0107 si\u0119 z Home Assistantem.", + "description": "Skonfiguruj BleBox, aby zintegrowa\u0107 go z Home Assistantem.", "title": "Konfiguracja urz\u0105dzenia BleBox" } } diff --git a/homeassistant/components/blebox/translations/ru.json b/homeassistant/components/blebox/translations/ru.json index 4fd361021ebd5..4b3528ec4fe1c 100644 --- a/homeassistant/components/blebox/translations/ru.json +++ b/homeassistant/components/blebox/translations/ru.json @@ -9,14 +9,14 @@ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", "unsupported_version": "\u041f\u0440\u043e\u0448\u0438\u0432\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0441\u0442\u0430\u0440\u0435\u043b\u0430. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0435\u0451." }, - "flow_title": "BleBox device: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { "host": "IP-\u0430\u0434\u0440\u0435\u0441", "port": "\u041f\u043e\u0440\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BleBox.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BleBox.", "title": "BleBox" } } diff --git a/homeassistant/components/blebox/translations/tr.json b/homeassistant/components/blebox/translations/tr.json index 31df3fb5e3074..ab4fe91a09733 100644 --- a/homeassistant/components/blebox/translations/tr.json +++ b/homeassistant/components/blebox/translations/tr.json @@ -6,14 +6,18 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "unknown": "Beklenmeyen hata" + "unknown": "Beklenmeyen hata", + "unsupported_version": "BleBox cihaz\u0131n\u0131n g\u00fcncel olmayan bellenimi var. L\u00fctfen \u00f6nce y\u00fckseltin." }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "port": "Port" - } + }, + "description": "BleBox'\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n.", + "title": "BleBox cihaz\u0131n\u0131z\u0131 kurun" } } } diff --git a/homeassistant/components/blebox/translations/zh-Hant.json b/homeassistant/components/blebox/translations/zh-Hant.json index a763442db7de1..612596882f41d 100644 --- a/homeassistant/components/blebox/translations/zh-Hant.json +++ b/homeassistant/components/blebox/translations/zh-Hant.json @@ -9,7 +9,7 @@ "unknown": "\u672a\u9810\u671f\u932f\u8aa4", "unsupported_version": "BleBox \u88dd\u7f6e\u97cc\u9ad4\u904e\u820a\uff0c\u8acb\u5148\u9032\u884c\u66f4\u65b0\u3002" }, - "flow_title": "BleBox \u88dd\u7f6e\uff1a{name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index ce47fcf79087b..5845c61c3306d 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -7,7 +7,13 @@ import voluptuous as vol from homeassistant.components import persistent_notification -from homeassistant.components.blink.const import ( +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import CONF_FILENAME, CONF_NAME, CONF_PIN, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS, @@ -15,11 +21,6 @@ SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) -from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_FILENAME, CONF_NAME, CONF_PIN, CONF_SCAN_INTERVAL -from homeassistant.core import callback -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index ed2b46acaa1b2..b81c06685308f 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -8,8 +8,9 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED, ) +from homeassistant.helpers.entity import DeviceInfo -from .const import DEFAULT_ATTRIBUTION, DOMAIN +from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -29,56 +30,31 @@ async def async_setup_entry(hass, config, async_add_entities): class BlinkSyncModule(AlarmControlPanelEntity): """Representation of a Blink Alarm Control Panel.""" + _attr_icon = ICON + _attr_supported_features = SUPPORT_ALARM_ARM_AWAY + def __init__(self, data, name, sync): """Initialize the alarm control panel.""" self.data = data self.sync = sync self._name = name - self._state = None - - @property - def unique_id(self): - """Return the unique id for the sync module.""" - return self.sync.serial - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_AWAY - - @property - def name(self): - """Return the name of the panel.""" - return f"{DOMAIN} {self._name}" - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attr = self.sync.attributes - attr["network_info"] = self.data.networks - attr["associated_cameras"] = list(self.sync.cameras) - attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION - return attr + self._attr_unique_id = sync.serial + self._attr_name = f"{DOMAIN} {name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sync.serial)}, name=name, manufacturer=DEFAULT_BRAND + ) def update(self): """Update the state of the device.""" _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) self.data.refresh() - mode = self.sync.arm - if mode: - self._state = STATE_ALARM_ARMED_AWAY - else: - self._state = STATE_ALARM_DISARMED + self._attr_state = ( + STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + ) + self.sync.attributes["network_info"] = self.data.networks + self.sync.attributes["associated_cameras"] = list(self.sync.cameras) + self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION + self._attr_extra_state_attributes = self.sync.attributes def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index f69c94f0f5e67..737d5372a1506 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,68 +1,73 @@ """Support for Blink system camera control.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_MOTION, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) +from homeassistant.helpers.entity import DeviceInfo, EntityCategory -from .const import DOMAIN, TYPE_BATTERY, TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED +from .const import ( + DEFAULT_BRAND, + DOMAIN, + TYPE_BATTERY, + TYPE_CAMERA_ARMED, + TYPE_MOTION_DETECTED, +) -BINARY_SENSORS = { - TYPE_BATTERY: ["Battery", DEVICE_CLASS_BATTERY], - TYPE_CAMERA_ARMED: ["Camera Armed", None], - TYPE_MOTION_DETECTED: ["Motion Detected", DEVICE_CLASS_MOTION], -} +BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=TYPE_BATTERY, + name="Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key=TYPE_CAMERA_ARMED, + name="Camera Armed", + ), + BinarySensorEntityDescription( + key=TYPE_MOTION_DETECTED, + name="Motion Detected", + device_class=BinarySensorDeviceClass.MOTION, + ), +) async def async_setup_entry(hass, config, async_add_entities): """Set up the blink binary sensors.""" data = hass.data[DOMAIN][config.entry_id] - entities = [] - for camera in data.cameras: - for sensor_type in BINARY_SENSORS: - entities.append(BlinkBinarySensor(data, camera, sensor_type)) + entities = [ + BlinkBinarySensor(data, camera, description) + for camera in data.cameras + for description in BINARY_SENSORS_TYPES + ] async_add_entities(entities) class BlinkBinarySensor(BinarySensorEntity): """Representation of a Blink binary sensor.""" - def __init__(self, data, camera, sensor_type): + def __init__(self, data, camera, description: BinarySensorEntityDescription): """Initialize the sensor.""" self.data = data - self._type = sensor_type - name, device_class = BINARY_SENSORS[sensor_type] - self._name = f"{DOMAIN} {camera} {name}" - self._device_class = device_class + self.entity_description = description + self._attr_name = f"{DOMAIN} {camera} {description.name}" self._camera = data.cameras[camera] - self._state = None - self._unique_id = f"{self._camera.serial}-{self._type}" - - @property - def name(self): - """Return the name of the blink sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return self._unique_id - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state + self._attr_unique_id = f"{self._camera.serial}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._camera.serial)}, + name=camera, + manufacturer=DEFAULT_BRAND, + model=self._camera.camera_type, + ) def update(self): """Update sensor state.""" self.data.refresh() - state = self._camera.attributes[self._type] - if self._type == TYPE_BATTERY: + state = self._camera.attributes[self.entity_description.key] + if self.entity_description.key == TYPE_BATTERY: state = state != "ok" - self._state = state + self._attr_is_on = state diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 5085686494e41..6a264afee3502 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,8 +1,11 @@ """Support for Blink system camera.""" +from __future__ import annotations + import logging from homeassistant.components.camera import Camera from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import DeviceInfo from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER @@ -32,23 +35,16 @@ def __init__(self, data, name, camera): """Initialize a camera.""" super().__init__() self.data = data - self._name = f"{DOMAIN} {name}" + self._attr_name = f"{DOMAIN} {name}" self._camera = camera - self._unique_id = f"{camera.serial}-camera" - self.response = None - self.current_image = None - self.last_image = None - _LOGGER.debug("Initialized blink camera %s", self._name) - - @property - def name(self): - """Return the camera name.""" - return self._name - - @property - def unique_id(self): - """Return the unique camera id.""" - return self._unique_id + self._attr_unique_id = f"{camera.serial}-camera" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, camera.serial)}, + name=name, + manufacturer=DEFAULT_BRAND, + model=camera.camera_type, + ) + _LOGGER.debug("Initialized blink camera %s", self.name) @property def extra_state_attributes(self): @@ -57,16 +53,18 @@ def extra_state_attributes(self): def enable_motion_detection(self): """Enable motion detection for the camera.""" - self._camera.set_motion_detect(True) + self._camera.arm = True + self.data.refresh() def disable_motion_detection(self): """Disable motion detection for the camera.""" - self._camera.set_motion_detect(False) + self._camera.arm = False + self.data.refresh() @property def motion_detection_enabled(self): """Return the state of the camera.""" - return self._camera.motion_enabled + return self._camera.arm @property def brand(self): @@ -78,6 +76,8 @@ def trigger_camera(self): self._camera.snap_picture() self.data.refresh() - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.image_from_cache.content diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index b46243a12d988..a4bee490fb3f7 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -6,11 +6,6 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.components.blink.const import ( - DEFAULT_SCAN_INTERVAL, - DEVICE_ID, - DOMAIN, -) from homeassistant.const import ( CONF_PASSWORD, CONF_PIN, @@ -19,6 +14,8 @@ ) from homeassistant.core import callback +from .const import DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index c93adbec46b65..8986782031fd3 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -1,4 +1,6 @@ """Constants for Blink.""" +from homeassistant.const import Platform + DOMAIN = "blink" DEVICE_ID = "Home Assistant" @@ -23,4 +25,9 @@ SERVICE_SAVE_VIDEO = "save_video" SERVICE_SEND_PIN = "send_pin" -PLATFORMS = ("alarm_control_panel", "binary_sensor", "camera", "sensor") +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, +] diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 7172406d6711a..b90e7e845cfc2 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -2,7 +2,7 @@ "domain": "blink", "name": "Blink", "documentation": "https://www.home-assistant.io/integrations/blink", - "requirements": ["blinkpy==0.17.0"], + "requirements": ["blinkpy==0.18.0"], "codeowners": ["@fronzbot"], "dhcp": [ { diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 1ec61900091d4..bb58a01e534cf 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,35 +1,46 @@ """Support for Blink system camera sensors.""" +from __future__ import annotations + import logging -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - TEMP_FAHRENHEIT, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, ) +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, TEMP_FAHRENHEIT +from homeassistant.helpers.entity import DeviceInfo, EntityCategory -from .const import DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH +from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH _LOGGER = logging.getLogger(__name__) -SENSORS = { - TYPE_TEMPERATURE: ["Temperature", TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE], - TYPE_WIFI_STRENGTH: [ - "Wifi Signal", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - DEVICE_CLASS_SIGNAL_STRENGTH, - ], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key=TYPE_WIFI_STRENGTH, + name="Wifi Signal", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) async def async_setup_entry(hass, config, async_add_entities): """Initialize a Blink sensor.""" data = hass.data[DOMAIN][config.entry_id] - entities = [] - for camera in data.cameras: - for sensor_type in SENSORS: - entities.append(BlinkSensor(data, camera, sensor_type)) + entities = [ + BlinkSensor(data, camera, description) + for camera in data.cameras + for description in SENSOR_TYPES + ] async_add_entities(entities) @@ -37,54 +48,32 @@ async def async_setup_entry(hass, config, async_add_entities): class BlinkSensor(SensorEntity): """A Blink camera sensor.""" - def __init__(self, data, camera, sensor_type): + def __init__(self, data, camera, description: SensorEntityDescription): """Initialize sensors from Blink camera.""" - name, units, device_class = SENSORS[sensor_type] - self._name = f"{DOMAIN} {camera} {name}" - self._camera_name = name - self._type = sensor_type - self._device_class = device_class + self.entity_description = description + self._attr_name = f"{DOMAIN} {camera} {description.name}" self.data = data self._camera = data.cameras[camera] - self._state = None - self._unit_of_measurement = units - self._unique_id = f"{self._camera.serial}-{self._type}" - self._sensor_key = self._type - if self._type == "temperature": - self._sensor_key = "temperature_calibrated" - - @property - def name(self): - """Return the name of the camera.""" - return self._name - - @property - def unique_id(self): - """Return the unique id for the camera sensor.""" - return self._unique_id - - @property - def state(self): - """Return the camera's current state.""" - return self._state - - @property - def device_class(self): - """Return the device's class.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self._attr_unique_id = f"{self._camera.serial}-{description.key}" + self._sensor_key = ( + "temperature_calibrated" + if description.key == "temperature" + else description.key + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._camera.serial)}, + name=camera, + manufacturer=DEFAULT_BRAND, + model=self._camera.camera_type, + ) def update(self): """Retrieve sensor data from the camera.""" self.data.refresh() try: - self._state = self._camera.attributes[self._sensor_key] + self._attr_native_value = self._camera.attributes[self._sensor_key] except KeyError: - self._state = None + self._attr_native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 6ea4e2aa9ac02..89af4799c858a 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,28 +1,43 @@ # Describes the format for available Blink services blink_update: + name: Update description: Force a refresh. trigger_camera: + name: Trigger camera description: Request camera to take new image. - fields: - entity_id: - description: Name(s) of camera entities to take new image. - example: "camera.living_room_camera" + target: + entity: + integration: blink + domain: camera save_video: + name: Save video description: Save last recorded video clip to local file. fields: name: + name: Name description: Name of camera to grab video from. + required: true example: "Living Room" + selector: + text: filename: + name: File name description: Filename to writable path (directory may need to be included in whitelist_dirs in config) + required: true example: "/tmp/video.mp4" + selector: + text: send_pin: + name: Send pin description: Send a new PIN to blink for 2FA. fields: pin: + name: Pin description: PIN received from blink. Leave empty if you only received a verification email. example: "abc123" + selector: + text: diff --git a/homeassistant/components/blink/translations/bg.json b/homeassistant/components/blink/translations/bg.json new file mode 100644 index 0000000000000..32c84eeb1dc3b --- /dev/null +++ b/homeassistant/components/blink/translations/bg.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "2fa": { + "data": { + "2fa": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u0435\u043d \u043a\u043e\u0434" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u041f\u0418\u041d \u043a\u043e\u0434\u0430, \u0438\u0437\u043f\u0440\u0430\u0442\u0435\u043d \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u0438\u043c\u0435\u0439\u043b", + "title": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043d\u0430 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435 (\u0441\u0435\u043a\u0443\u043d\u0434\u0438)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/ca.json b/homeassistant/components/blink/translations/ca.json index bd5079d2a16d1..695db588b0def 100644 --- a/homeassistant/components/blink/translations/ca.json +++ b/homeassistant/components/blink/translations/ca.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_access_token": "Token d'acc\u00e9s no v\u00e0lid", + "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/blink/translations/de.json b/homeassistant/components/blink/translations/de.json index 86fa2b609ad6b..8d3911d5f80bb 100644 --- a/homeassistant/components/blink/translations/de.json +++ b/homeassistant/components/blink/translations/de.json @@ -14,7 +14,7 @@ "data": { "2fa": "Zwei-Faktor Authentifizierungscode" }, - "description": "Gib die an deine E-Mail gesendete Pin ein. Wenn die E-Mail keine PIN enth\u00e4lt, lass das Feld leer.", + "description": "Gib die an deine E-Mail gesendete Pin ein", "title": "Zwei-Faktor-Authentifizierung" }, "user": { diff --git a/homeassistant/components/blink/translations/es-419.json b/homeassistant/components/blink/translations/es-419.json new file mode 100644 index 0000000000000..d44527dd7cae9 --- /dev/null +++ b/homeassistant/components/blink/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "2fa": { + "data": { + "2fa": "C\u00f3digo de dos factores" + }, + "description": "Ingrese el PIN enviado a su correo electr\u00f3nico", + "title": "Autenticaci\u00f3n de dos factores" + }, + "user": { + "title": "Iniciar sesi\u00f3n con cuenta Blink" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Intervalo de escaneo (segundos)" + }, + "description": "Configurar la integraci\u00f3n de Blink", + "title": "Opciones de Blink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/fr.json b/homeassistant/components/blink/translations/fr.json index 23bb7fb91dd8f..bef14d641f71b 100644 --- a/homeassistant/components/blink/translations/fr.json +++ b/homeassistant/components/blink/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "P\u00e9riph\u00e9rique d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -20,7 +20,7 @@ "user": { "data": { "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous avec un compte Blink" } diff --git a/homeassistant/components/blink/translations/he.json b/homeassistant/components/blink/translations/he.json new file mode 100644 index 0000000000000..764c41136e2a1 --- /dev/null +++ b/homeassistant/components/blink/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/hu.json b/homeassistant/components/blink/translations/hu.json index e56b142a5b03d..1822dfbcf508a 100644 --- a/homeassistant/components/blink/translations/hu.json +++ b/homeassistant/components/blink/translations/hu.json @@ -14,14 +14,26 @@ "data": { "2fa": "K\u00e9tfaktoros k\u00f3d" }, - "description": "Add meg az e-mail c\u00edmedre k\u00fcld\u00f6tt pint", + "description": "Adja meg az e-mail c\u00edm\u00e9re k\u00fcld\u00f6tt PIN-t", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" }, "user": { "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Jelentkezzen be Blink-fi\u00f3kkal" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "Blink integr\u00e1ci\u00f3 konfigur\u00e1l\u00e1sa", + "title": "Villog\u00e1si lehet\u0151s\u00e9gek" } } } diff --git a/homeassistant/components/blink/translations/it.json b/homeassistant/components/blink/translations/it.json index 6dee5d9c02fe5..5e052c2d95e83 100644 --- a/homeassistant/components/blink/translations/it.json +++ b/homeassistant/components/blink/translations/it.json @@ -32,7 +32,7 @@ "data": { "scan_interval": "Intervallo di scansione (secondi)" }, - "description": "Configurare l'integrazione di Blink", + "description": "Configura l'integrazione di Blink", "title": "Opzioni di Blink" } } diff --git a/homeassistant/components/blink/translations/ja.json b/homeassistant/components/blink/translations/ja.json new file mode 100644 index 0000000000000..40724c01d42d9 --- /dev/null +++ b/homeassistant/components/blink/translations/ja.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "2fa": { + "data": { + "2fa": "2\u8981\u7d20\u30b3\u30fc\u30c9" + }, + "description": "E\u30e1\u30fc\u30eb\u3067\u9001\u3089\u308c\u3066\u304d\u305fPIN\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "2\u8981\u7d20\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Blink\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u30b5\u30a4\u30f3\u30a4\u30f3" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)" + }, + "description": "Blink\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a", + "title": "Blink \u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/tr.json b/homeassistant/components/blink/translations/tr.json index 8193ff9d8bee7..1a7444cb64497 100644 --- a/homeassistant/components/blink/translations/tr.json +++ b/homeassistant/components/blink/translations/tr.json @@ -5,19 +5,35 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci", + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, "step": { "2fa": { - "description": "E-postan\u0131za g\u00f6nderilen PIN kodunu girin" + "data": { + "2fa": "\u0130ki ad\u0131ml\u0131 kimlik do\u011frulama kodu" + }, + "description": "E-postan\u0131za g\u00f6nderilen PIN kodunu girin", + "title": "\u0130ki fakt\u00f6rl\u00fc kimlik do\u011frulama" }, "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Blink hesab\u0131yla oturum a\u00e7\u0131n" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)" + }, + "description": "Blink entegrasyonunu yap\u0131land\u0131r\u0131n", + "title": "Blink Se\u00e7enekleri" } } } diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index d11204207561e..a45eadc3d3a9a 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -42,57 +42,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlinkStickLight(LightEntity): """Representation of a BlinkStick light.""" + _attr_supported_features = SUPPORT_BLINKSTICK + def __init__(self, stick, name): """Initialize the light.""" self._stick = stick - self._name = name - self._serial = stick.get_serial() - self._hs_color = None - self._brightness = None - - @property - def name(self): - """Return the name of the light.""" - return self._name - - @property - def brightness(self): - """Read back the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Read back the color of the light.""" - return self._hs_color - - @property - def is_on(self): - """Return True if entity is on.""" - return self._brightness > 0 - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_BLINKSTICK + self._attr_name = name def update(self): """Read back the device state.""" rgb_color = self._stick.get_color() hsv = color_util.color_RGB_to_hsv(*rgb_color) - self._hs_color = hsv[:2] - self._brightness = hsv[2] + self._attr_hs_color = hsv[:2] + self._attr_brightness = hsv[2] + self._attr_is_on = self.brightness > 0 def turn_on(self, **kwargs): """Turn the device on.""" if ATTR_HS_COLOR in kwargs: - self._hs_color = kwargs[ATTR_HS_COLOR] + self._attr_hs_color = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] else: - self._brightness = 255 + self._attr_brightness = 255 + self._attr_is_on = self.brightness > 0 rgb_color = color_util.color_hsv_to_RGB( - self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100 + self.hs_color[0], self.hs_color[1], self.brightness / 255 * 100 ) self._stick.set_color(red=rgb_color[0], green=rgb_color[1], blue=rgb_color[2]) diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index 2520d2b1fcc03..05f8fe65fb3c7 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -2,7 +2,7 @@ "domain": "blinksticklight", "name": "BlinkStick", "documentation": "https://www.home-assistant.io/integrations/blinksticklight", - "requirements": ["blinkstick==1.1.8"], + "requirements": ["blinkstick==1.2.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/blinkt/light.py b/homeassistant/components/blinkt/light.py index bb9bbf315e49e..e6a3ecd362dcd 100644 --- a/homeassistant/components/blinkt/light.py +++ b/homeassistant/components/blinkt/light.py @@ -41,77 +41,43 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlinktLight(LightEntity): """Representation of a Blinkt! Light.""" + _attr_supported_features = SUPPORT_BLINKT + _attr_should_poll = False + _attr_assumed_state = True + def __init__(self, blinkt, name, index): """Initialize a Blinkt Light. Default brightness and white color. """ self._blinkt = blinkt - self._name = f"{name}_{index}" + self._attr_name = f"{name}_{index}" self._index = index - self._is_on = False - self._brightness = 255 - self._hs_color = [0, 0] - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def brightness(self): - """Read back the brightness of the light. - - Returns integer in the range of 1-255. - """ - return self._brightness - - @property - def hs_color(self): - """Read back the color of the light.""" - return self._hs_color - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_BLINKT - - @property - def is_on(self): - """Return true if light is on.""" - return self._is_on - - @property - def should_poll(self): - """Return if we should poll this device.""" - return False - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True + self._attr_is_on = False + self._attr_brightness = 255 + self._attr_hs_color = [0, 0] def turn_on(self, **kwargs): """Instruct the light to turn on and set correct brightness & color.""" if ATTR_HS_COLOR in kwargs: - self._hs_color = kwargs[ATTR_HS_COLOR] + self._attr_hs_color = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] - percent_bright = self._brightness / 255 - rgb_color = color_util.color_hs_to_RGB(*self._hs_color) + percent_bright = self.brightness / 255 + rgb_color = color_util.color_hs_to_RGB(*self.hs_color) self._blinkt.set_pixel( self._index, rgb_color[0], rgb_color[1], rgb_color[2], percent_bright ) self._blinkt.show() - self._is_on = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Instruct the light to turn off.""" self._blinkt.set_pixel(self._index, 0, 0, 0, 0) self._blinkt.show() - self._is_on = False + self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 3ecf4bee3190a..9d31d4c05838b 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -46,39 +46,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlockchainSensor(SensorEntity): """Representation of a Blockchain.com sensor.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + _attr_icon = ICON + _attr_native_unit_of_measurement = "BTC" + def __init__(self, name, addresses): """Initialize the sensor.""" - self._name = name + self._attr_name = name self.addresses = addresses - self._state = None - self._unit_of_measurement = "BTC" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest state of the sensor.""" - - self._state = get_balance(self.addresses) + self._attr_native_value = get_balance(self.addresses) diff --git a/homeassistant/components/bloomsky/__init__.py b/homeassistant/components/bloomsky/__init__.py index fa8d3160dc8d5..e04f473191863 100644 --- a/homeassistant/components/bloomsky/__init__.py +++ b/homeassistant/components/bloomsky/__init__.py @@ -1,17 +1,13 @@ """Support for BloomSky weather station.""" from datetime import timedelta +from http import HTTPStatus import logging from aiohttp.hdrs import AUTHORIZATION import requests import voluptuous as vol -from homeassistant.const import ( - CONF_API_KEY, - HTTP_METHOD_NOT_ALLOWED, - HTTP_OK, - HTTP_UNAUTHORIZED, -) +from homeassistant.const import CONF_API_KEY from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -72,12 +68,12 @@ def refresh_devices(self): headers={AUTHORIZATION: self._api_key}, timeout=10, ) - if response.status_code == HTTP_UNAUTHORIZED: + if response.status_code == HTTPStatus.UNAUTHORIZED: raise RuntimeError("Invalid API_KEY") - if response.status_code == HTTP_METHOD_NOT_ALLOWED: + if response.status_code == HTTPStatus.METHOD_NOT_ALLOWED: _LOGGER.error("You have no bloomsky devices configured") return - if response.status_code != HTTP_OK: + if response.status_code != HTTPStatus.OK: _LOGGER.error("Invalid HTTP response: %s", response.status_code) return # Create dictionary keyed off of the device unique id diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index 4234b4fb14583..2b8cdd57c954d 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -2,8 +2,8 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_MONITORED_CONDITIONS @@ -11,7 +11,7 @@ from . import DOMAIN -SENSOR_TYPES = {"Rain": DEVICE_CLASS_MOISTURE, "Night": None} +SENSOR_TYPES = {"Rain": BinarySensorDeviceClass.MOISTURE, "Night": None} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -39,37 +39,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BloomSkySensor(BinarySensorEntity): """Representation of a single binary sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): + def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name """Initialize a BloomSky binary sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = f"{device['DeviceName']} {sensor_name}" - self._state = None - self._unique_id = f"{self._device_id}-{self._sensor_name}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the BloomSky device and this sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return SENSOR_TYPES.get(self._sensor_name) - - @property - def is_on(self): - """Return true if binary sensor is on.""" - return self._state + self._attr_name = f"{device['DeviceName']} {sensor_name}" + self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_device_class = SENSOR_TYPES.get(sensor_name) def update(self): """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() - self._state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + self._attr_is_on = self._bloomsky.devices[self._device_id]["Data"][ + self._sensor_name + ] diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py index e14e2f5c68bf1..a7255a74d4cec 100644 --- a/homeassistant/components/bloomsky/camera.py +++ b/homeassistant/components/bloomsky/camera.py @@ -1,4 +1,6 @@ """Support for a camera of a BloomSky weather station.""" +from __future__ import annotations + import logging import requests @@ -25,7 +27,7 @@ class BloomSkyCamera(Camera): def __init__(self, bs, device): """Initialize access to the BloomSky camera images.""" super().__init__() - self._name = device["DeviceName"] + self._attr_name = device["DeviceName"] self._id = device["DeviceID"] self._bloomsky = bs self._url = "" @@ -35,8 +37,11 @@ def __init__(self, bs, device): # to download the same image over and over. self._last_image = "" self._logger = logging.getLogger(__name__) + self._attr_unique_id = self._id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Update the camera's image if it has changed.""" try: self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] @@ -51,13 +56,3 @@ def camera_image(self): return None return self._last_image - - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def name(self): - """Return the name of this BloomSky device.""" - return self._name diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 4dc52e1a85cf5..f0bc31a337a1d 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -1,10 +1,15 @@ """Support the sensor of a BloomSky weather station.""" import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) from homeassistant.const import ( AREA_SQUARE_METERS, CONF_MONITORED_CONDITIONS, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, PRESSURE_INHG, PRESSURE_MBAR, @@ -31,7 +36,7 @@ "Humidity": PERCENTAGE, "Pressure": PRESSURE_INHG, "Luminance": f"cd/{AREA_SQUARE_METERS}", - "Voltage": "mV", + "Voltage": ELECTRIC_POTENTIAL_MILLIVOLT, } # Metric units @@ -40,7 +45,12 @@ "Humidity": PERCENTAGE, "Pressure": PRESSURE_MBAR, "Luminance": f"cd/{AREA_SQUARE_METERS}", - "Voltage": "mV", + "Voltage": ELECTRIC_POTENTIAL_MILLIVOLT, +} + +# Device class +SENSOR_DEVICE_CLASS = { + "Temperature": SensorDeviceClass.TEMPERATURE, } # Which sensors to format numerically @@ -72,44 +82,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BloomSkySensor(SensorEntity): """Representation of a single sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): + def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name """Initialize a BloomSky sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = f"{device['DeviceName']} {sensor_name}" - self._state = None - self._unique_id = f"{self._device_id}-{self._sensor_name}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the BloomSky device and this sensor.""" - return self._name - - @property - def state(self): - """Return the current state, eg. value, of this sensor.""" - return self._state + self._attr_name = f"{device['DeviceName']} {sensor_name}" + self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_native_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get( + sensor_name, None + ) + if self._bloomsky.is_metric: + self._attr_native_unit_of_measurement = SENSOR_UNITS_METRIC.get( + sensor_name, None + ) @property - def unit_of_measurement(self): - """Return the sensor units.""" - if self._bloomsky.is_metric: - return SENSOR_UNITS_METRIC.get(self._sensor_name, None) - return SENSOR_UNITS_IMPERIAL.get(self._sensor_name, None) + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_DEVICE_CLASS.get(self._sensor_name) def update(self): """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() - state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] - - if self._sensor_name in FORMAT_NUMBERS: - self._state = f"{state:.2f}" - else: - self._state = state + self._attr_native_value = ( + f"{state:.2f}" if self._sensor_name in FORMAT_NUMBERS else state + ) diff --git a/homeassistant/components/blueprint/errors.py b/homeassistant/components/blueprint/errors.py index b5032af932697..4b14201652f4b 100644 --- a/homeassistant/components/blueprint/errors.py +++ b/homeassistant/components/blueprint/errors.py @@ -45,7 +45,7 @@ def __init__( blueprint_name: str, blueprint_data: Any, msg_or_exc: vol.Invalid, - ): + ) -> None: """Initialize an invalid blueprint error.""" if isinstance(msg_or_exc, vol.Invalid): msg_or_exc = humanize_error(blueprint_data, msg_or_exc) @@ -61,7 +61,7 @@ def __init__( class InvalidBlueprintInputs(BlueprintException): """When we encountered invalid blueprint inputs.""" - def __init__(self, domain: str, msg: str): + def __init__(self, domain: str, msg: str) -> None: """Initialize an invalid blueprint inputs error.""" super().__init__( domain, diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index 99dffb114e114..de39741d8edac 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -59,9 +59,7 @@ def _get_github_import_url(url: str) -> str: if url.startswith("https://raw.githubusercontent.com/"): return url - match = GITHUB_FILE_PATTERN.match(url) - - if match is None: + if (match := GITHUB_FILE_PATTERN.match(url)) is None: raise UnsupportedUrl("Not a GitHub file url") repo, path = match.groups() @@ -74,8 +72,7 @@ def _get_community_post_import_url(url: str) -> str: Async friendly. """ - match = COMMUNITY_TOPIC_PATTERN.match(url) - if match is None: + if (match := COMMUNITY_TOPIC_PATTERN.match(url)) is None: raise UnsupportedUrl("Not a topic url") _topic, post = match.groups() diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 797f9bd1512b9..a814676471051 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -255,8 +255,7 @@ async def async_get_blueprint(self, blueprint_path: str) -> Blueprint: def load_from_cache(): """Load blueprint from cache.""" - blueprint = self._blueprints[blueprint_path] - if blueprint is None: + if (blueprint := self._blueprints[blueprint_path]) is None: raise FailedToLoad( self.domain, blueprint_path, @@ -316,7 +315,7 @@ def _create_file(self, blueprint: Blueprint, blueprint_path: str) -> None: raise FileAlreadyExists(self.domain, blueprint_path) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(blueprint.yaml()) + path.write_text(blueprint.yaml(), encoding="utf-8") async def async_add_blueprint( self, blueprint: Blueprint, blueprint_path: str diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 648ff2a180943..bfefff3660137 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -3,6 +3,6 @@ "name": "Bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound", "requirements": ["xmltodict==0.12.0"], - "codeowners": [], + "codeowners": ["@thrawnarn"], "iot_class": "local_polling" } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index dff45ca68bd70..c91a2dedca328 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -2,6 +2,7 @@ import asyncio from asyncio import CancelledError from datetime import timedelta +from http import HTTPStatus import logging from urllib import parse @@ -38,7 +39,6 @@ CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - HTTP_OK, STATE_IDLE, STATE_OFF, STATE_PAUSED, @@ -106,8 +106,6 @@ def _add_player(hass, async_add_entities, host, port=None, name=None): """Add Bluesound players.""" - if host in [x.host for x in hass.data[DATA_BLUESOUND]]: - return @callback def _init_player(event=None): @@ -127,6 +125,11 @@ def _stop_polling(): @callback def _add_player_cb(): """Add player after first sync fetch.""" + if player.id in [x.id for x in hass.data[DATA_BLUESOUND]]: + _LOGGER.warning("Player already added %s", player.id) + return + + hass.data[DATA_BLUESOUND].append(player) async_add_entities([player]) _LOGGER.info("Added device with name: %s", player.name) @@ -138,7 +141,6 @@ def _add_player_cb(): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) player = BluesoundPlayer(hass, host, port, name, _add_player_cb) - hass.data[DATA_BLUESOUND].append(player) if hass.is_running: _init_player() @@ -160,8 +162,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) return - hosts = config.get(CONF_HOSTS) - if hosts: + if hosts := config.get(CONF_HOSTS): for host in hosts: _add_player( hass, @@ -173,15 +174,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_service_handler(service): """Map services to method of Bluesound devices.""" - method = SERVICE_TO_METHOD.get(service.service) - if not method: + if not (method := SERVICE_TO_METHOD.get(service.service)): return params = { key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID } - entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: + if entity_ids := service.data.get(ATTR_ENTITY_ID): target_players = [ player for player in hass.data[DATA_BLUESOUND] @@ -193,8 +192,8 @@ async def async_service_handler(service): for player in target_players: await getattr(player, method["method"])(**params) - for service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[service]["schema"] + for service, method in SERVICE_TO_METHOD.items(): + schema = method["schema"] hass.services.async_register( DOMAIN, service, async_service_handler, schema=schema ) @@ -211,6 +210,7 @@ def __init__(self, hass, host, port=None, name=None, init_callback=None): self._polling_session = async_get_clientsession(hass) self._polling_task = None # The actual polling task. self._name = name + self._id = None self._icon = None self._capture_items = [] self._services_items = [] @@ -228,6 +228,7 @@ def __init__(self, hass, host, port=None, name=None, init_callback=None): self._bluesound_device_name = None self._init_callback = init_callback + if self.port is None: self.port = DEFAULT_PORT @@ -254,26 +255,29 @@ async def force_update_sync_status(self, on_updated_cb=None, raise_timeout=False if not self._name: self._name = self._sync_status.get("@name", self.host) + if not self._id: + self._id = self._sync_status.get("@id", None) if not self._bluesound_device_name: self._bluesound_device_name = self._sync_status.get("@name", self.host) if not self._icon: self._icon = self._sync_status.get("@icon", self.host) - master = self._sync_status.get("master") - if master is not None: + if (master := self._sync_status.get("master")) is not None: self._is_master = False master_host = master.get("#text") + master_port = master.get("@port", "11000") + master_id = f"{master_host}:{master_port}" master_device = [ device for device in self._hass.data[DATA_BLUESOUND] - if device.host == master_host + if device.id == master_id ] - if master_device and master_host != self.host: + if master_device and master_id != self.id: self._master = master_device[0] else: self._master = None - _LOGGER.error("Master not found %s", master_host) + _LOGGER.error("Master not found %s", master_id) else: if self._master is not None: self._master = None @@ -291,14 +295,14 @@ async def _start_poll_command(self): await self.async_update_status() except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): - _LOGGER.info("Node %s is offline, retrying later", self._name) + _LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() except CancelledError: - _LOGGER.debug("Stopping the polling of node %s", self._name) + _LOGGER.debug("Stopping the polling of node %s:%s", self.name, self.port) except Exception: - _LOGGER.exception("Unexpected error in %s", self._name) + _LOGGER.exception("Unexpected error in %s:%s", self.name, self.port) raise def start_polling(self): @@ -318,12 +322,14 @@ async def async_init(self, triggered=None): await self.force_update_sync_status(self._init_callback, True) except (asyncio.TimeoutError, ClientError): - _LOGGER.info("Node %s is offline, retrying later", self.host) + _LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port) self._retry_remove = async_track_time_interval( self._hass, self.async_init, NODE_RETRY_INITIATION ) except Exception: - _LOGGER.exception("Unexpected when initiating error in %s", self.host) + _LOGGER.exception( + "Unexpected when initiating error in %s:%s", self.host, self.port + ) raise async def async_update(self): @@ -352,10 +358,10 @@ async def send_bluesound_command( try: websession = async_get_clientsession(self._hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await websession.get(url) - if response.status == HTTP_OK: + if response.status == HTTPStatus.OK: result = await response.text() if result: data = xmltodict.parse(result) @@ -370,9 +376,9 @@ async def send_bluesound_command( except (asyncio.TimeoutError, aiohttp.ClientError): if raise_timeout: - _LOGGER.info("Timeout: %s", self.host) + _LOGGER.info("Timeout: %s:%s", self.host, self.port) raise - _LOGGER.debug("Failed communicating: %s", self.host) + _LOGGER.debug("Failed communicating: %s:%s", self.host, self.port) return None return data @@ -394,12 +400,12 @@ async def async_update_status(self): try: - with async_timeout.timeout(125): + async with async_timeout.timeout(125): response = await self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE} ) - if response.status == HTTP_OK: + if response.status == HTTPStatus.OK: result = await response.text() self._is_online = True self._last_status_update = dt_util.utcnow() @@ -407,7 +413,7 @@ async def async_update_status(self): group_name = self._status.get("groupName") if group_name != self._group_name: - _LOGGER.debug("Group name change detected on device: %s", self.host) + _LOGGER.debug("Group name change detected on device: %s", self.id) self._group_name = group_name # rebuild ordered list of entity_ids that are in the group, master is first @@ -580,8 +586,7 @@ def media_artist(self): if self.is_grouped and not self.is_master: return self._group_name - artist = self._status.get("artist") - if not artist: + if not (artist := self._status.get("artist")): artist = self._status.get("title2") return artist @@ -591,8 +596,7 @@ def media_album_name(self): if self._status is None or (self.is_grouped and not self.is_master): return None - album = self._status.get("album") - if not album: + if not (album := self._status.get("album")): album = self._status.get("title3") return album @@ -602,8 +606,7 @@ def media_image_url(self): if self._status is None or (self.is_grouped and not self.is_master): return None - url = self._status.get("image") - if not url: + if not (url := self._status.get("image")): return if url[0] == "/": url = f"http://{self.host}:{self.port}{url}" @@ -620,8 +623,7 @@ def media_position(self): if self._last_status_update is None or mediastate == STATE_IDLE: return None - position = self._status.get("secs") - if position is None: + if (position := self._status.get("secs")) is None: return None position = float(position) @@ -636,8 +638,7 @@ def media_duration(self): if self._status is None or (self.is_grouped and not self.is_master): return None - duration = self._status.get("totlen") - if duration is None: + if (duration := self._status.get("totlen")) is None: return None return float(duration) @@ -668,6 +669,11 @@ def is_volume_muted(self): mute = bool(int(mute)) return mute + @property + def id(self): + """Get id of device.""" + return self._id + @property def name(self): """Return the name of the device.""" @@ -712,8 +718,7 @@ def source(self): if self._status is None or (self.is_grouped and not self.is_master): return None - current_service = self._status.get("service", "") - if current_service == "": + if (current_service := self._status.get("service", "")) == "": return "" stream_url = self._status.get("streamUrl", "") @@ -841,8 +846,8 @@ async def async_join(self, master): if master_device: _LOGGER.debug( "Trying to join player: %s to master: %s", - self.host, - master_device[0].host, + self.id, + master_device[0].id, ) await master_device[0].async_add_slave(self) @@ -887,7 +892,7 @@ async def async_unjoin(self): if self._master is None: return - _LOGGER.debug("Trying to unjoin player: %s", self.host) + _LOGGER.debug("Trying to unjoin player: %s", self.id) await self._master.async_remove_slave(self) async def async_add_slave(self, slave_device): @@ -906,7 +911,7 @@ async def async_increase_timer(self): """Increase sleep time on player.""" sleep_time = await self.send_bluesound_command("/Sleep") if sleep_time is None: - _LOGGER.error("Error while increasing sleep time on player: %s", self.host) + _LOGGER.error("Error while increasing sleep time on player: %s", self.id) return 0 return int(sleep_time.get("sleep", "0")) diff --git a/homeassistant/components/bluesound/services.yaml b/homeassistant/components/bluesound/services.yaml index 0ca12c9e2ae46..7c04cc00f3985 100644 --- a/homeassistant/components/bluesound/services.yaml +++ b/homeassistant/components/bluesound/services.yaml @@ -1,30 +1,55 @@ join: + name: Join description: Group player together. fields: master: + name: Master description: Entity ID of the player that should become the master of the group. - example: "media_player.bluesound_livingroom" + required: true + selector: + entity: + integration: bluesound + domain: media_player entity_id: - description: Name(s) of entities that will coordinate the grouping. Platform dependent. - example: "media_player.bluesound_livingroom" + name: Entity + description: Name of entity that will coordinate the grouping. Platform dependent. + selector: + entity: + integration: bluesound + domain: media_player unjoin: + name: Unjoin description: Unjoin the player from a group. fields: entity_id: - description: Name(s) of entities that will be unjoined from their group. Platform dependent. - example: "media_player.bluesound_livingroom" + name: Entity + description: Name of entity that will be unjoined from their group. Platform dependent. + selector: + entity: + integration: bluesound + domain: media_player set_sleep_timer: + name: Set sleep timer description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" fields: entity_id: + name: Entity description: Name(s) of entities that will have a timer set. - example: "media_player.bluesound_livingroom" + selector: + entity: + integration: bluesound + domain: media_player clear_sleep_timer: + name: Clear sleep timer description: Clear a Bluesound timer. fields: entity_id: + name: Entity description: Name(s) of entities that will have the timer cleared. - example: "media_player.bluesound_livingroom" + selector: + entity: + integration: bluesound + domain: media_player diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 6fb6f2109f13f..499e6923bd8cd 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -7,7 +7,9 @@ import pygatt import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, +) from homeassistant.components.device_tracker.const import ( CONF_SCAN_INTERVAL, CONF_TRACK_NEW, @@ -36,7 +38,7 @@ BLE_PREFIX = "BLE_" MIN_SEEN_NEW = 5 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_TRACK_BATTERY, default=False): cv.boolean, vol.Optional( diff --git a/homeassistant/components/bluetooth_tracker/const.py b/homeassistant/components/bluetooth_tracker/const.py index b481efa296f78..8257e5554eccf 100644 --- a/homeassistant/components/bluetooth_tracker/const.py +++ b/homeassistant/components/bluetooth_tracker/const.py @@ -1,3 +1,9 @@ """Constants for the Bluetooth Tracker component.""" -DOMAIN = "bluetooth_tracker" -SERVICE_UPDATE = "update" +from typing import Final + +DOMAIN: Final = "bluetooth_tracker" +SERVICE_UPDATE: Final = "update" + +BT_PREFIX: Final = "BT_" +CONF_REQUEST_RSSI: Final = "request_rssi" +DEFAULT_DEVICE_ID: Final = -1 diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 11037e2bc24d8..8883f600019cf 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -2,13 +2,18 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta import logging +from typing import Any, Final import bluetooth # pylint: disable=import-error from bt_proximity import BluetoothRSSI import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, +) from homeassistant.components.device_tracker.const import ( CONF_SCAN_INTERVAL, CONF_TRACK_NEW, @@ -18,24 +23,26 @@ ) from homeassistant.components.device_tracker.legacy import ( YAML_DEVICES, + Device, async_load_config, ) from homeassistant.const import CONF_DEVICE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType + +from .const import ( + BT_PREFIX, + CONF_REQUEST_RSSI, + DEFAULT_DEVICE_ID, + DOMAIN, + SERVICE_UPDATE, +) -from .const import DOMAIN, SERVICE_UPDATE - -_LOGGER = logging.getLogger(__name__) - -BT_PREFIX = "BT_" - -CONF_REQUEST_RSSI = "request_rssi" - -DEFAULT_DEVICE_ID = -1 +_LOGGER: Final = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_TRACK_NEW): cv.boolean, vol.Optional(CONF_REQUEST_RSSI): cv.boolean, @@ -46,9 +53,9 @@ ) -def is_bluetooth_device(device) -> bool: +def is_bluetooth_device(device: Device) -> bool: """Check whether a device is a bluetooth device by its mac.""" - return device.mac and device.mac[:3].upper() == BT_PREFIX + return device.mac is not None and device.mac[:3].upper() == BT_PREFIX def discover_devices(device_id: int) -> list[tuple[str, str]]: @@ -61,11 +68,15 @@ def discover_devices(device_id: int) -> list[tuple[str, str]]: device_id=device_id, ) _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) - return result + return result # type: ignore[no-any-return] async def see_device( - hass: HomeAssistant, async_see, mac: str, device_name: str, rssi=None + hass: HomeAssistant, + async_see: Callable[..., Awaitable[None]], + mac: str, + device_name: str, + rssi: tuple[int] | None = None, ) -> None: """Mark a device as seen.""" attributes = {} @@ -88,14 +99,18 @@ async def get_tracking_devices(hass: HomeAssistant) -> tuple[set[str], set[str]] """ yaml_path: str = hass.config.path(YAML_DEVICES) - devices = await async_load_config(yaml_path, hass, 0) + devices = await async_load_config(yaml_path, hass, timedelta(0)) bluetooth_devices = [device for device in devices if is_bluetooth_device(device)] devices_to_track: set[str] = { - device.mac[3:] for device in bluetooth_devices if device.track + device.mac[3:] + for device in bluetooth_devices + if device.track and device.mac is not None } devices_to_not_track: set[str] = { - device.mac[3:] for device in bluetooth_devices if not device.track + device.mac[3:] + for device in bluetooth_devices + if not device.track and device.mac is not None } return devices_to_track, devices_to_not_track @@ -104,16 +119,19 @@ async def get_tracking_devices(hass: HomeAssistant) -> tuple[set[str], set[str]] def lookup_name(mac: str) -> str | None: """Lookup a Bluetooth device name.""" _LOGGER.debug("Scanning %s", mac) - return bluetooth.lookup_name(mac, timeout=5) + return bluetooth.lookup_name(mac, timeout=5) # type: ignore[no-any-return] async def async_setup_scanner( - hass: HomeAssistant, config: dict, async_see, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_see: Callable[..., Awaitable[None]], + discovery_info: dict[str, Any] | None = None, +) -> bool: """Set up the Bluetooth Scanner.""" device_id: int = config[CONF_DEVICE_ID] - interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - request_rssi = config.get(CONF_REQUEST_RSSI, False) + interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + request_rssi: bool = config.get(CONF_REQUEST_RSSI, False) update_bluetooth_lock = asyncio.Lock() # If track new devices is true discover new devices on startup. @@ -128,21 +146,21 @@ async def async_setup_scanner( if request_rssi: _LOGGER.debug("Detecting RSSI for devices") - async def perform_bluetooth_update(): + async def perform_bluetooth_update() -> None: """Discover Bluetooth devices and update status.""" _LOGGER.debug("Performing Bluetooth devices discovery and update") - tasks = [] + tasks: list[Awaitable[None]] = [] try: if track_new: devices = await hass.async_add_executor_job(discover_devices, device_id) - for mac, device_name in devices: + for mac, _device_name in devices: if mac not in devices_to_track and mac not in devices_to_not_track: devices_to_track.add(mac) for mac in devices_to_track: - device_name = await hass.async_add_executor_job(lookup_name, mac) - if device_name is None: + friendly_name = await hass.async_add_executor_job(lookup_name, mac) + if friendly_name is None: # Could not lookup device name continue @@ -152,7 +170,7 @@ async def perform_bluetooth_update(): rssi = await hass.async_add_executor_job(client.request_rssi) client.close() - tasks.append(see_device(hass, async_see, mac, device_name, rssi)) + tasks.append(see_device(hass, async_see, mac, friendly_name, rssi)) if tasks: await asyncio.wait(tasks) @@ -160,7 +178,7 @@ async def perform_bluetooth_update(): except bluetooth.BluetoothError: _LOGGER.exception("Error looking up Bluetooth device") - async def update_bluetooth(now=None): + async def update_bluetooth(now: datetime | None = None) -> None: """Lookup Bluetooth devices and update status.""" # If an update is in progress, we don't do anything if update_bluetooth_lock.locked(): @@ -173,7 +191,7 @@ async def update_bluetooth(now=None): async with update_bluetooth_lock: await perform_bluetooth_update() - async def handle_manual_update_bluetooth(call): + async def handle_manual_update_bluetooth(call: ServiceCall) -> None: """Update bluetooth devices on demand.""" await update_bluetooth() diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index a41720c2c4f7e..ccf48a9b8c37e 100644 --- a/homeassistant/components/bluetooth_tracker/manifest.json +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -2,7 +2,7 @@ "domain": "bluetooth_tracker", "name": "Bluetooth Tracker", "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", - "requirements": ["bt_proximity==0.2", "pybluez==0.22"], + "requirements": ["bt_proximity==0.2.1", "pybluez==0.22"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/bluetooth_tracker/services.yaml b/homeassistant/components/bluetooth_tracker/services.yaml index 01b31eee63e76..3150403dbf105 100644 --- a/homeassistant/components/bluetooth_tracker/services.yaml +++ b/homeassistant/components/bluetooth_tracker/services.yaml @@ -1,2 +1,3 @@ update: + name: Update description: Trigger manual tracker update diff --git a/homeassistant/components/bme280/__init__.py b/homeassistant/components/bme280/__init__.py index 87de36fdf02fa..eca9ac85bc92d 100644 --- a/homeassistant/components/bme280/__init__.py +++ b/homeassistant/components/bme280/__init__.py @@ -1 +1,98 @@ """The bme280 component.""" +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL +from homeassistant.helpers import config_validation as cv, discovery + +from .const import ( + CONF_DELTA_TEMP, + CONF_FILTER_MODE, + CONF_I2C_ADDRESS, + CONF_I2C_BUS, + CONF_OPERATION_MODE, + CONF_OVERSAMPLING_HUM, + CONF_OVERSAMPLING_PRES, + CONF_OVERSAMPLING_TEMP, + CONF_SPI_BUS, + CONF_SPI_DEV, + CONF_T_STANDBY, + DEFAULT_DELTA_TEMP, + DEFAULT_FILTER_MODE, + DEFAULT_I2C_ADDRESS, + DEFAULT_I2C_BUS, + DEFAULT_MONITORED, + DEFAULT_NAME, + DEFAULT_OPERATION_MODE, + DEFAULT_OVERSAMPLING_HUM, + DEFAULT_OVERSAMPLING_PRES, + DEFAULT_OVERSAMPLING_TEMP, + DEFAULT_SCAN_INTERVAL, + DEFAULT_T_STANDBY, + DOMAIN, + SENSOR_KEYS, +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SPI_BUS): vol.Coerce(int), + vol.Optional(CONF_SPI_DEV): vol.Coerce(int), + vol.Optional( + CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS + ): cv.string, + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce( + int + ), + vol.Optional( + CONF_DELTA_TEMP, default=DEFAULT_DELTA_TEMP + ): vol.Coerce(float), + vol.Optional( + CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED + ): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)]), + vol.Optional( + CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP + ): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES + ): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM + ): vol.Coerce(int), + vol.Optional( + CONF_OPERATION_MODE, default=DEFAULT_OPERATION_MODE + ): vol.Coerce(int), + vol.Optional( + CONF_T_STANDBY, default=DEFAULT_T_STANDBY + ): vol.Coerce(int), + vol.Optional( + CONF_FILTER_MODE, default=DEFAULT_FILTER_MODE + ): vol.Coerce(int), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up BME280 component.""" + bme280_config = config[DOMAIN] + for bme280_conf in bme280_config: + discovery_info = {SENSOR_DOMAIN: bme280_conf} + hass.async_create_task( + discovery.async_load_platform( + hass, SENSOR_DOMAIN, DOMAIN, discovery_info, config + ) + ) + return True diff --git a/homeassistant/components/bme280/const.py b/homeassistant/components/bme280/const.py new file mode 100644 index 0000000000000..1bb0828dd1e86 --- /dev/null +++ b/homeassistant/components/bme280/const.py @@ -0,0 +1,60 @@ +"""Constants for the BME280 component.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS + +# Common +DOMAIN = "bme280" +CONF_OVERSAMPLING_TEMP = "oversampling_temperature" +CONF_OVERSAMPLING_PRES = "oversampling_pressure" +CONF_OVERSAMPLING_HUM = "oversampling_humidity" +CONF_T_STANDBY = "time_standby" +CONF_FILTER_MODE = "filter_mode" +DEFAULT_NAME = "BME280 Sensor" +DEFAULT_OVERSAMPLING_TEMP = 1 +DEFAULT_OVERSAMPLING_PRES = 1 +DEFAULT_OVERSAMPLING_HUM = 1 +DEFAULT_T_STANDBY = 5 +DEFAULT_FILTER_MODE = 0 +DEFAULT_SCAN_INTERVAL = 300 +SENSOR_TEMP = "temperature" +SENSOR_HUMID = "humidity" +SENSOR_PRESS = "pressure" +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMID, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESS, + name="Pressure", + native_unit_of_measurement="mb", + device_class=SensorDeviceClass.PRESSURE, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] +DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) +# SPI +CONF_SPI_DEV = "spi_dev" +CONF_SPI_BUS = "spi_bus" +# I2C +CONF_I2C_ADDRESS = "i2c_address" +CONF_I2C_BUS = "i2c_bus" +CONF_DELTA_TEMP = "delta_temperature" +CONF_OPERATION_MODE = "operation_mode" +DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2) +DEFAULT_I2C_ADDRESS = "0x76" +DEFAULT_I2C_BUS = 1 +DEFAULT_DELTA_TEMP = 0.0 diff --git a/homeassistant/components/bme280/manifest.json b/homeassistant/components/bme280/manifest.json index 515e9e460d3b6..4c997152b5a87 100644 --- a/homeassistant/components/bme280/manifest.json +++ b/homeassistant/components/bme280/manifest.json @@ -2,7 +2,11 @@ "domain": "bme280", "name": "Bosch BME280 Environmental Sensor", "documentation": "https://www.home-assistant.io/integrations/bme280", - "requirements": ["i2csense==0.0.4", "smbus-cffi==0.5.1"], + "requirements": [ + "i2csense==0.0.4", + "smbus-cffi==0.5.1", + "bme280spi==0.2.0" + ], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index 2c3ab0303b05b..f49d89590763b 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -1,179 +1,139 @@ """Support for BME280 temperature, humidity and pressure sensor.""" -from contextlib import suppress -from datetime import timedelta from functools import partial import logging -from i2csense.bme280 import BME280 # pylint: disable=import-error +from bme280spi import BME280 as BME280_spi # pylint: disable=import-error +from i2csense.bme280 import BME280 as BME280_i2c # pylint: disable=import-error import smbus -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_NAME, - PERCENTAGE, - TEMP_FAHRENHEIT, +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit - -_LOGGER = logging.getLogger(__name__) - -CONF_I2C_ADDRESS = "i2c_address" -CONF_I2C_BUS = "i2c_bus" -CONF_OVERSAMPLING_TEMP = "oversampling_temperature" -CONF_OVERSAMPLING_PRES = "oversampling_pressure" -CONF_OVERSAMPLING_HUM = "oversampling_humidity" -CONF_OPERATION_MODE = "operation_mode" -CONF_T_STANDBY = "time_standby" -CONF_FILTER_MODE = "filter_mode" -CONF_DELTA_TEMP = "delta_temperature" - -DEFAULT_NAME = "BME280 Sensor" -DEFAULT_I2C_ADDRESS = "0x76" -DEFAULT_I2C_BUS = 1 -DEFAULT_OVERSAMPLING_TEMP = 1 # Temperature oversampling x 1 -DEFAULT_OVERSAMPLING_PRES = 1 # Pressure oversampling x 1 -DEFAULT_OVERSAMPLING_HUM = 1 # Humidity oversampling x 1 -DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2) -DEFAULT_T_STANDBY = 5 # Tstandby 5ms -DEFAULT_FILTER_MODE = 0 # Filter off -DEFAULT_DELTA_TEMP = 0.0 - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) - -SENSOR_TEMP = "temperature" -SENSOR_HUMID = "humidity" -SENSOR_PRESS = "pressure" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None], - SENSOR_HUMID: ["Humidity", PERCENTAGE], - SENSOR_PRESS: ["Pressure", "mb"], -} -DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), - vol.Optional( - CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP - ): vol.Coerce(int), - vol.Optional( - CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES - ): vol.Coerce(int), - vol.Optional( - CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM - ): vol.Coerce(int), - vol.Optional(CONF_OPERATION_MODE, default=DEFAULT_OPERATION_MODE): vol.Coerce( - int - ), - vol.Optional(CONF_T_STANDBY, default=DEFAULT_T_STANDBY): vol.Coerce(int), - vol.Optional(CONF_FILTER_MODE, default=DEFAULT_FILTER_MODE): vol.Coerce(int), - vol.Optional(CONF_DELTA_TEMP, default=DEFAULT_DELTA_TEMP): vol.Coerce(float), - } +from .const import ( + CONF_DELTA_TEMP, + CONF_FILTER_MODE, + CONF_I2C_ADDRESS, + CONF_I2C_BUS, + CONF_OPERATION_MODE, + CONF_OVERSAMPLING_HUM, + CONF_OVERSAMPLING_PRES, + CONF_OVERSAMPLING_TEMP, + CONF_SPI_BUS, + CONF_SPI_DEV, + CONF_T_STANDBY, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, + SENSOR_HUMID, + SENSOR_PRESS, + SENSOR_TEMP, + SENSOR_TYPES, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BME280 sensor.""" - - SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit - name = config[CONF_NAME] - i2c_address = config[CONF_I2C_ADDRESS] - - bus = smbus.SMBus(config[CONF_I2C_BUS]) - sensor = await hass.async_add_executor_job( - partial( - BME280, - bus, - i2c_address, - osrs_t=config[CONF_OVERSAMPLING_TEMP], - osrs_p=config[CONF_OVERSAMPLING_PRES], - osrs_h=config[CONF_OVERSAMPLING_HUM], - mode=config[CONF_OPERATION_MODE], - t_sb=config[CONF_T_STANDBY], - filter_mode=config[CONF_FILTER_MODE], - delta_temp=config[CONF_DELTA_TEMP], - logger=_LOGGER, + if discovery_info is None: + return + sensor_conf = discovery_info[SENSOR_DOMAIN] + name = sensor_conf[CONF_NAME] + scan_interval = max(sensor_conf[CONF_SCAN_INTERVAL], MIN_TIME_BETWEEN_UPDATES) + if CONF_SPI_BUS in sensor_conf and CONF_SPI_DEV in sensor_conf: + spi_dev = sensor_conf[CONF_SPI_DEV] + spi_bus = sensor_conf[CONF_SPI_BUS] + _LOGGER.debug("BME280 sensor initialize at %s.%s", spi_bus, spi_dev) + sensor = await hass.async_add_executor_job( + partial( + BME280_spi, + t_mode=sensor_conf[CONF_OVERSAMPLING_TEMP], + p_mode=sensor_conf[CONF_OVERSAMPLING_PRES], + h_mode=sensor_conf[CONF_OVERSAMPLING_HUM], + standby=sensor_conf[CONF_T_STANDBY], + filter=sensor_conf[CONF_FILTER_MODE], + spi_bus=sensor_conf[CONF_SPI_BUS], + spi_dev=sensor_conf[CONF_SPI_DEV], + ) ) - ) - if not sensor.sample_ok: - _LOGGER.error("BME280 sensor not detected at %s", i2c_address) - return False - - sensor_handler = await hass.async_add_executor_job(BME280Handler, sensor) - - dev = [] - with suppress(KeyError): - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - BME280Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) + if not sensor.sample_ok: + _LOGGER.error("BME280 sensor not detected at %s.%s", spi_bus, spi_dev) + return + else: + i2c_address = sensor_conf[CONF_I2C_ADDRESS] + bus = smbus.SMBus(sensor_conf[CONF_I2C_BUS]) + sensor = await hass.async_add_executor_job( + partial( + BME280_i2c, + bus, + i2c_address, + osrs_t=sensor_conf[CONF_OVERSAMPLING_TEMP], + osrs_p=sensor_conf[CONF_OVERSAMPLING_PRES], + osrs_h=sensor_conf[CONF_OVERSAMPLING_HUM], + mode=sensor_conf[CONF_OPERATION_MODE], + t_sb=sensor_conf[CONF_T_STANDBY], + filter_mode=sensor_conf[CONF_FILTER_MODE], + delta_temp=sensor_conf[CONF_DELTA_TEMP], ) - - async_add_entities(dev, True) - - -class BME280Handler: - """BME280 sensor working in i2C bus.""" - - def __init__(self, sensor): - """Initialize the sensor handler.""" - self.sensor = sensor - self.update(True) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self, first_reading=False): - """Read sensor data.""" - self.sensor.update(first_reading) + ) + if not sensor.sample_ok: + _LOGGER.error("BME280 sensor not detected at %s", i2c_address) + return + + async def async_update_data(): + await hass.async_add_executor_job(sensor.update) + if not sensor.sample_ok: + raise UpdateFailed(f"Bad update of sensor {name}") + return sensor + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=async_update_data, + update_interval=scan_interval, + ) + await coordinator.async_refresh() + monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS] + entities = [ + BME280Sensor(name, coordinator, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + async_add_entities(entities, True) -class BME280Sensor(SensorEntity): +class BME280Sensor(CoordinatorEntity, SensorEntity): """Implementation of the BME280 sensor.""" - def __init__(self, bme280_client, sensor_type, temp_unit, name): + def __init__(self, name, coordinator, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] - self.bme280_client = bme280_client - self.temp_unit = temp_unit - self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"{name} {description.name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - return self._state + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMP: + temperature = round(self.coordinator.data.temperature, 1) + state = temperature + elif sensor_type == SENSOR_HUMID: + state = round(self.coordinator.data.humidity, 1) + elif sensor_type == SENSOR_PRESS: + state = round(self.coordinator.data.pressure, 1) + return state @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - async def async_update(self): - """Get the latest data from the BME280 and update the states.""" - await self.hass.async_add_executor_job(self.bme280_client.update) - if self.bme280_client.sensor.sample_ok: - if self.type == SENSOR_TEMP: - temperature = round(self.bme280_client.sensor.temperature, 2) - if self.temp_unit == TEMP_FAHRENHEIT: - temperature = round(celsius_to_fahrenheit(temperature), 2) - self._state = temperature - elif self.type == SENSOR_HUMID: - self._state = round(self.bme280_client.sensor.humidity, 1) - elif self.type == SENSOR_PRESS: - self._state = round(self.bme280_client.sensor.pressure, 1) - else: - _LOGGER.warning("Bad update of sensor.%s", self.name) + def should_poll(self) -> bool: + """Return False if entity should not poll.""" + return False diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index f3d6b9428ea35..84a1b613a236d 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -1,4 +1,6 @@ """Support for BME680 Sensor over SMBus.""" +from __future__ import annotations + import logging import threading from time import monotonic, sleep @@ -7,15 +9,19 @@ from smbus import SMBus import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -52,13 +58,37 @@ SENSOR_PRESS = "pressure" SENSOR_GAS = "gas" SENSOR_AQ = "airquality" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None], - SENSOR_HUMID: ["Humidity", PERCENTAGE], - SENSOR_PRESS: ["Pressure", "mb"], - SENSOR_GAS: ["Gas Resistance", "Ohms"], - SENSOR_AQ: ["Air Quality", PERCENTAGE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMID, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESS, + name="Pressure", + native_unit_of_measurement="mb", + device_class=SensorDeviceClass.PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_GAS, + name="Gas Resistance", + native_unit_of_measurement="Ohms", + ), + SensorEntityDescription( + key=SENSOR_AQ, + name="Air Quality", + native_unit_of_measurement=PERCENTAGE, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ] OVERSAMPLING_VALUES = {0, 1, 2, 4, 8, 16} FILTER_VALUES = {0, 1, 3, 7, 15, 31, 63, 127} @@ -68,7 +98,7 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.positive_int, vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): cv.positive_int, vol.Optional( @@ -107,21 +137,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BME680 sensor.""" - SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit name = config[CONF_NAME] sensor_handler = await hass.async_add_executor_job(_setup_bme680, config) if sensor_handler is None: return - dev = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - BME680Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + BME680Sensor(sensor_handler, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - async_add_entities(dev) - return + async_add_entities(entities) def _setup_bme680(config): @@ -318,46 +347,29 @@ def _calculate_aq_score(self): class BME680Sensor(SensorEntity): """Implementation of the BME680 sensor.""" - def __init__(self, bme680_client, sensor_type, temp_unit, name): + def __init__(self, bme680_client, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bme680_client = bme680_client - self.temp_unit = temp_unit - self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement async def async_update(self): """Get the latest data from the BME680 and update the states.""" await self.hass.async_add_executor_job(self.bme680_client.update) - if self.type == SENSOR_TEMP: - temperature = round(self.bme680_client.sensor_data.temperature, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - temperature = round(celsius_to_fahrenheit(temperature), 1) - self._state = temperature - elif self.type == SENSOR_HUMID: - self._state = round(self.bme680_client.sensor_data.humidity, 1) - elif self.type == SENSOR_PRESS: - self._state = round(self.bme680_client.sensor_data.pressure, 1) - elif self.type == SENSOR_GAS: - self._state = int(round(self.bme680_client.sensor_data.gas_resistance, 0)) - elif self.type == SENSOR_AQ: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMP: + self._attr_native_value = round( + self.bme680_client.sensor_data.temperature, 1 + ) + elif sensor_type == SENSOR_HUMID: + self._attr_native_value = round(self.bme680_client.sensor_data.humidity, 1) + elif sensor_type == SENSOR_PRESS: + self._attr_native_value = round(self.bme680_client.sensor_data.pressure, 1) + elif sensor_type == SENSOR_GAS: + self._attr_native_value = int( + round(self.bme680_client.sensor_data.gas_resistance, 0) + ) + elif sensor_type == SENSOR_AQ: aq_score = self.bme680_client.sensor_data.air_quality if aq_score is not None: - self._state = round(aq_score, 1) + self._attr_native_value = round(aq_score, 1) diff --git a/homeassistant/components/bmp280/sensor.py b/homeassistant/components/bmp280/sensor.py index 60cbdb75d41fe..3721bffaf682b 100644 --- a/homeassistant/components/bmp280/sensor.py +++ b/homeassistant/components/bmp280/sensor.py @@ -8,9 +8,8 @@ import voluptuous as vol from homeassistant.components.sensor import ( - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, ) from homeassistant.const import CONF_NAME, PRESSURE_HPA, TEMP_CELSIUS @@ -74,86 +73,58 @@ def __init__( name: str, unit_of_measurement: str, device_class: str, - ): + ) -> None: """Initialize the sensor.""" self._bmp280 = bmp280 - self._name = name - self._unit_of_measurement = unit_of_measurement - self._device_class = device_class - self._state = None - self._errored = False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def available(self) -> bool: - """Return if the device is currently available.""" - return not self._errored + self._attr_name = name + self._attr_native_unit_of_measurement = unit_of_measurement class Bmp280TemperatureSensor(Bmp280Sensor): """Representation of a Bosch BMP280 Temperature Sensor.""" - def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str): + def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str) -> None: """Initialize the entity.""" super().__init__( - bmp280, f"{name} Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE + bmp280, f"{name} Temperature", TEMP_CELSIUS, SensorDeviceClass.TEMPERATURE ) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for the sensor.""" try: - self._state = round(self._bmp280.temperature, 1) - if self._errored: + self._attr_native_value = round(self._bmp280.temperature, 1) + if not self.available: _LOGGER.warning("Communication restored with temperature sensor") - self._errored = False + self._attr_available = True except OSError: # this is thrown when a working sensor is unplugged between two updates _LOGGER.warning( "Unable to read temperature data due to a communication problem" ) - self._errored = True + self._attr_available = False class Bmp280PressureSensor(Bmp280Sensor): """Representation of a Bosch BMP280 Barometric Pressure Sensor.""" - def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str): + def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str) -> None: """Initialize the entity.""" super().__init__( - bmp280, f"{name} Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE + bmp280, f"{name} Pressure", PRESSURE_HPA, SensorDeviceClass.PRESSURE ) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for the sensor.""" try: - self._state = round(self._bmp280.pressure) - if self._errored: + self._attr_native_value = round(self._bmp280.pressure) + if not self.available: _LOGGER.warning("Communication restored with pressure sensor") - self._errored = False + self._attr_available = True except OSError: # this is thrown when a working sensor is unplugged between two updates _LOGGER.warning( "Unable to read pressure data due to a communication problem" ) - self._errored = True + self._attr_available = False diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 79461082acd20..e681cac8223c4 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -1,27 +1,32 @@ """Reads vehicle status from BMW connected drive portal.""" from __future__ import annotations +from collections.abc import Callable import logging +from typing import Any, cast from bimmer_connected.account import ConnectedDriveAccount from bimmer_connected.country_selector import get_region_from_name +from bimmer_connected.vehicle import ConnectedDriveVehicle import voluptuous as vol from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, + CONF_DEVICE_ID, CONF_NAME, CONF_PASSWORD, CONF_REGION, CONF_USERNAME, + Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import device_registry, discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -30,7 +35,6 @@ CONF_ACCOUNT, CONF_ALLOWED_REGIONS, CONF_READ_ONLY, - CONF_USE_LOCATION, DATA_ENTRIES, DATA_HASS_CONFIG, ) @@ -51,14 +55,24 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: ACCOUNT_SCHEMA}}, extra=vol.ALLOW_EXTRA) -SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string}) +SERVICE_SCHEMA = vol.Schema( + vol.Any( + {vol.Required(ATTR_VIN): cv.string}, + {vol.Required(CONF_DEVICE_ID): cv.string}, + ) +) DEFAULT_OPTIONS = { CONF_READ_ONLY: False, - CONF_USE_LOCATION: False, } -PLATFORMS = ["binary_sensor", "device_tracker", "lock", "notify", "sensor"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.LOCK, + Platform.NOTIFY, + Platform.SENSOR, +] UPDATE_INTERVAL = 5 # in minutes SERVICE_UPDATE_STATE = "update_state" @@ -67,13 +81,14 @@ "light_flash": "trigger_remote_light_flash", "sound_horn": "trigger_remote_horn", "activate_air_conditioning": "trigger_remote_air_conditioning", + "deactivate_air_conditioning": "trigger_remote_air_conditioning_stop", "find_vehicle": "trigger_remote_vehicle_finder", } UNDO_UPDATE_LISTENER = "undo_update_listener" -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the BMW Connected Drive component from configuration.yaml.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][DATA_HASS_CONFIG] = config @@ -90,7 +105,9 @@ async def async_setup(hass: HomeAssistant, config: dict): @callback -def _async_migrate_options_from_data_if_missing(hass, entry): +def _async_migrate_options_from_data_if_missing( + hass: HomeAssistant, entry: ConfigEntry +) -> None: data = dict(entry.data) options = dict(entry.options) @@ -101,7 +118,7 @@ def _async_migrate_options_from_data_if_missing(hass, entry): hass.config_entries.async_update_entry(entry, data=data, options=options) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BMW Connected Drive from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(DATA_ENTRIES, {}) @@ -115,7 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except OSError as ex: raise ConfigEntryNotReady from ex - async def _async_update_all(service_call=None): + async def _async_update_all(service_call: ServiceCall | None = None) -> None: """Update all BMW accounts.""" await hass.async_add_executor_job(_update_all) @@ -138,7 +155,7 @@ def _update_all() -> None: await _async_update_all() hass.config_entries.async_setup_platforms( - entry, [platform for platform in PLATFORMS if platform != NOTIFY_DOMAIN] + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) # set up notify platform, no entry support for notify platform yet, @@ -156,10 +173,10 @@ def _update_all() -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != NOTIFY_DOMAIN] + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) # Only remove services if it is the last account and not read only @@ -183,40 +200,51 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def update_listener(hass, config_entry): +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) -def setup_account(entry: ConfigEntry, hass, name: str) -> BMWConnectedDriveAccount: +def setup_account( + entry: ConfigEntry, hass: HomeAssistant, name: str +) -> BMWConnectedDriveAccount: """Set up a new BMWConnectedDriveAccount based on the config.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - region = entry.data[CONF_REGION] - read_only = entry.options[CONF_READ_ONLY] - use_location = entry.options[CONF_USE_LOCATION] + username: str = entry.data[CONF_USERNAME] + password: str = entry.data[CONF_PASSWORD] + region: str = entry.data[CONF_REGION] + read_only: bool = entry.options[CONF_READ_ONLY] _LOGGER.debug("Adding new account %s", name) - pos = ( - (hass.config.latitude, hass.config.longitude) if use_location else (None, None) - ) + pos = (hass.config.latitude, hass.config.longitude) cd_account = BMWConnectedDriveAccount( username, password, region, name, read_only, *pos ) - def execute_service(call): + def execute_service(call: ServiceCall) -> None: """Execute a service for a vehicle.""" - vin = call.data[ATTR_VIN] - vehicle = None + vin: str | None = call.data.get(ATTR_VIN) + device_id: str | None = call.data.get(CONF_DEVICE_ID) + + vehicle: ConnectedDriveVehicle | None = None + + if not vin and device_id: + # If vin is None, device_id must be set (given by SERVICE_SCHEMA) + if not (device := device_registry.async_get(hass).async_get(device_id)): + _LOGGER.error("Could not find a device for id: %s", device_id) + return + vin = next(iter(device.identifiers))[1] + else: + vin = cast(str, vin) + # Double check for read_only accounts as another account could create the services for entry_data in [ e for e in hass.data[DOMAIN][DATA_ENTRIES].values() if not e[CONF_ACCOUNT].read_only ]: - vehicle = entry_data[CONF_ACCOUNT].account.get_vehicle(vin) - if vehicle: + account: ConnectedDriveAccount = entry_data[CONF_ACCOUNT].account + if vehicle := account.get_vehicle(vin): break if not vehicle: _LOGGER.error("Could not find a vehicle for VIN %s", vin) @@ -225,6 +253,13 @@ def execute_service(call): function_call = getattr(vehicle.remote_services, function_name) function_call() + if call.service in [ + "find_vehicle", + "activate_air_conditioning", + "deactivate_air_conditioning", + ]: + cd_account.update() + if not read_only: # register the remote services for service in _SERVICE_MAP: @@ -258,8 +293,8 @@ def __init__( region_str: str, name: str, read_only: bool, - lat=None, - lon=None, + lat: float | None = None, + lon: float | None = None, ) -> None: """Initialize account.""" region = get_region_from_name(region_str) @@ -267,7 +302,7 @@ def __init__( self.read_only = read_only self.account = ConnectedDriveAccount(username, password, region) self.name = name - self._update_listeners = [] + self._update_listeners: list[Callable[[], None]] = [] # Set observer position once for older cars to be in range for # GPS position (pre-7/2014, <2km) and get new data from API @@ -275,7 +310,7 @@ def __init__( self.account.set_observer_position(lat, lon) self.account.update_vehicle_states() - def update(self, *_): + def update(self, *_: Any) -> None: """Update the state of all vehicles. Notify all listeners about the update. @@ -296,7 +331,7 @@ def update(self, *_): ) _LOGGER.exception(exception) - def add_update_listener(self, listener): + def add_update_listener(self, listener: Callable[[], None]) -> None: """Add a listener for update notifications.""" self._update_listeners.append(listener) @@ -304,44 +339,33 @@ def add_update_listener(self, listener): class BMWConnectedDriveBaseEntity(Entity): """Common base for BMW entities.""" - def __init__(self, account, vehicle): + _attr_should_poll = False + _attr_attribution = ATTRIBUTION + + def __init__( + self, + account: BMWConnectedDriveAccount, + vehicle: ConnectedDriveVehicle, + ) -> None: """Initialize sensor.""" self._account = account self._vehicle = vehicle - self._attrs = { + self._attrs: dict[str, Any] = { "car": self._vehicle.name, "vin": self._vehicle.vin, - ATTR_ATTRIBUTION: ATTRIBUTION, } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer=vehicle.brand.name, + model=vehicle.name, + name=f"{vehicle.brand.name} {vehicle.name}", + ) - @property - def device_info(self) -> DeviceInfo: - """Return info for device registry.""" - return { - "identifiers": {(DOMAIN, self._vehicle.vin)}, - "name": f'{self._vehicle.attributes.get("brand")} {self._vehicle.name}', - "model": self._vehicle.name, - "manufacturer": self._vehicle.attributes.get("brand"), - } - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return self._attrs - - @property - def should_poll(self): - """Do not poll this class. - - Updates are triggered from BMWConnectedDriveAccount. - """ - return False - - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add callback after being added to hass. Show latest data after startup. diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index bebb55bbde084..8110b535716b2 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -1,195 +1,261 @@ """Reads vehicle status from BMW connected drive portal.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any, cast -from bimmer_connected.state import ChargingState, LockState +from bimmer_connected.vehicle import ConnectedDriveVehicle +from bimmer_connected.vehicle_status import ( + ChargingState, + ConditionBasedServiceReport, + LockState, + VehicleStatus, +) from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_OPENING, - DEVICE_CLASS_PLUG, - DEVICE_CLASS_PROBLEM, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.const import LENGTH_KILOMETERS +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.unit_system import UnitSystem -from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity -from .const import CONF_ACCOUNT, DATA_ENTRIES +from . import ( + DOMAIN as BMW_DOMAIN, + BMWConnectedDriveAccount, + BMWConnectedDriveBaseEntity, +) +from .const import CONF_ACCOUNT, DATA_ENTRIES, UNIT_MAP _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "lids": ["Doors", DEVICE_CLASS_OPENING, "mdi:car-door-lock"], - "windows": ["Windows", DEVICE_CLASS_OPENING, "mdi:car-door"], - "door_lock_state": ["Door lock state", "lock", "mdi:car-key"], - "lights_parking": ["Parking lights", "light", "mdi:car-parking-lights"], - "condition_based_services": [ - "Condition based services", - DEVICE_CLASS_PROBLEM, - "mdi:wrench", - ], - "check_control_messages": [ - "Control messages", - DEVICE_CLASS_PROBLEM, - "mdi:car-tire-alert", - ], -} - -SENSOR_TYPES_ELEC = { - "charging_status": ["Charging status", "power", "mdi:ev-station"], - "connection_status": ["Connection status", DEVICE_CLASS_PLUG, "mdi:car-electric"], -} - -SENSOR_TYPES_ELEC.update(SENSOR_TYPES) - - -async def async_setup_entry(hass, config_entry, async_add_entities): + +def _are_doors_closed( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class opening: On means open, Off means closed + _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) + for lid in vehicle_state.lids: + extra_attributes[lid.name] = lid.state.value + return not vehicle_state.all_lids_closed + + +def _are_windows_closed( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class opening: On means open, Off means closed + for window in vehicle_state.windows: + extra_attributes[window.name] = window.state.value + return not vehicle_state.all_windows_closed + + +def _are_doors_locked( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class lock: On means unlocked, Off means locked + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + extra_attributes["door_lock_state"] = vehicle_state.door_lock_state.value + extra_attributes["last_update_reason"] = vehicle_state.last_update_reason + return vehicle_state.door_lock_state not in {LockState.LOCKED, LockState.SECURED} + + +def _are_parking_lights_on( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class light: On means light detected, Off means no light + extra_attributes["lights_parking"] = vehicle_state.parking_lights.value + return cast(bool, vehicle_state.are_parking_lights_on) + + +def _are_problems_detected( + vehicle_state: VehicleStatus, + extra_attributes: dict[str, Any], + unit_system: UnitSystem, +) -> bool: + # device class problem: On means problem detected, Off means no problem + for report in vehicle_state.condition_based_services: + extra_attributes.update(_format_cbs_report(report, unit_system)) + return not vehicle_state.are_all_cbs_ok + + +def _check_control_messages( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class problem: On means problem detected, Off means no problem + check_control_messages = vehicle_state.check_control_messages + has_check_control_messages = vehicle_state.has_check_control_messages + if has_check_control_messages: + cbs_list = [message.description_short for message in check_control_messages] + extra_attributes["check_control_messages"] = cbs_list + else: + extra_attributes["check_control_messages"] = "OK" + return cast(bool, vehicle_state.has_check_control_messages) + + +def _is_vehicle_charging( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class power: On means power detected, Off means no power + extra_attributes["charging_status"] = vehicle_state.charging_status.value + extra_attributes[ + "last_charging_end_result" + ] = vehicle_state.last_charging_end_result + return cast(bool, vehicle_state.charging_status == ChargingState.CHARGING) + + +def _is_vehicle_plugged_in( + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any +) -> bool: + # device class plug: On means device is plugged in, + # Off means device is unplugged + extra_attributes["connection_status"] = vehicle_state.connection_status + return cast(str, vehicle_state.connection_status) == "CONNECTED" + + +def _format_cbs_report( + report: ConditionBasedServiceReport, unit_system: UnitSystem +) -> dict[str, Any]: + result: dict[str, Any] = {} + service_type = report.service_type.lower().replace("_", " ") + result[f"{service_type} status"] = report.state.value + if report.due_date is not None: + result[f"{service_type} date"] = report.due_date.strftime("%Y-%m-%d") + if report.due_distance is not None: + distance = round( + unit_system.length( + report.due_distance[0], + UNIT_MAP.get(report.due_distance[1], report.due_distance[1]), + ) + ) + result[f"{service_type} distance"] = f"{distance} {unit_system.length_unit}" + return result + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[VehicleStatus, dict[str, Any], UnitSystem], bool] + + +@dataclass +class BMWBinarySensorEntityDescription( + BinarySensorEntityDescription, BMWRequiredKeysMixin +): + """Describes BMW binary_sensor entity.""" + + +SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( + BMWBinarySensorEntityDescription( + key="lids", + name="Doors", + device_class=BinarySensorDeviceClass.OPENING, + icon="mdi:car-door-lock", + value_fn=_are_doors_closed, + ), + BMWBinarySensorEntityDescription( + key="windows", + name="Windows", + device_class=BinarySensorDeviceClass.OPENING, + icon="mdi:car-door", + value_fn=_are_windows_closed, + ), + BMWBinarySensorEntityDescription( + key="door_lock_state", + name="Door lock state", + device_class=BinarySensorDeviceClass.LOCK, + icon="mdi:car-key", + value_fn=_are_doors_locked, + ), + BMWBinarySensorEntityDescription( + key="lights_parking", + name="Parking lights", + device_class=BinarySensorDeviceClass.LIGHT, + icon="mdi:car-parking-lights", + value_fn=_are_parking_lights_on, + ), + BMWBinarySensorEntityDescription( + key="condition_based_services", + name="Condition based services", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:wrench", + value_fn=_are_problems_detected, + ), + BMWBinarySensorEntityDescription( + key="check_control_messages", + name="Control messages", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:car-tire-alert", + value_fn=_check_control_messages, + ), + # electric + BMWBinarySensorEntityDescription( + key="charging_status", + name="Charging status", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + icon="mdi:ev-station", + value_fn=_is_vehicle_charging, + ), + BMWBinarySensorEntityDescription( + key="connection_status", + name="Connection status", + device_class=BinarySensorDeviceClass.PLUG, + icon="mdi:car-electric", + value_fn=_is_vehicle_plugged_in, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the BMW ConnectedDrive binary sensors from config entry.""" - account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT] - entities = [] - - for vehicle in account.account.vehicles: - if vehicle.has_hv_battery: - _LOGGER.debug("BMW with a high voltage battery") - for key, value in sorted(SENSOR_TYPES_ELEC.items()): - if key in vehicle.available_attributes: - device = BMWConnectedDriveSensor( - account, vehicle, key, value[0], value[1], value[2] - ) - entities.append(device) - elif vehicle.has_internal_combustion_engine: - _LOGGER.debug("BMW with an internal combustion engine") - for key, value in sorted(SENSOR_TYPES.items()): - if key in vehicle.available_attributes: - device = BMWConnectedDriveSensor( - account, vehicle, key, value[0], value[1], value[2] - ) - entities.append(device) + account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ + config_entry.entry_id + ][CONF_ACCOUNT] + + entities = [ + BMWConnectedDriveSensor(account, vehicle, description, hass.config.units) + for vehicle in account.account.vehicles + for description in SENSOR_TYPES + if description.key in vehicle.available_attributes + ] async_add_entities(entities, True) class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): """Representation of a BMW vehicle binary sensor.""" + entity_description: BMWBinarySensorEntityDescription + def __init__( - self, account, vehicle, attribute: str, sensor_name, device_class, icon - ): + self, + account: BMWConnectedDriveAccount, + vehicle: ConnectedDriveVehicle, + description: BMWBinarySensorEntityDescription, + unit_system: UnitSystem, + ) -> None: """Initialize sensor.""" super().__init__(account, vehicle) + self.entity_description = description + self._unit_system = unit_system - self._attribute = attribute - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" - self._sensor_name = sensor_name - self._device_class = device_class - self._icon = icon - self._state = None - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes of the binary sensor.""" - vehicle_state = self._vehicle.state - result = self._attrs.copy() + self._attr_name = f"{vehicle.name} {description.key}" + self._attr_unique_id = f"{vehicle.vin}-{description.key}" - if self._attribute == "lids": - for lid in vehicle_state.lids: - result[lid.name] = lid.state.value - elif self._attribute == "windows": - for window in vehicle_state.windows: - result[window.name] = window.state.value - elif self._attribute == "door_lock_state": - result["door_lock_state"] = vehicle_state.door_lock_state.value - result["last_update_reason"] = vehicle_state.last_update_reason - elif self._attribute == "lights_parking": - result["lights_parking"] = vehicle_state.parking_lights.value - elif self._attribute == "condition_based_services": - for report in vehicle_state.condition_based_services: - result.update(self._format_cbs_report(report)) - elif self._attribute == "check_control_messages": - check_control_messages = vehicle_state.check_control_messages - has_check_control_messages = vehicle_state.has_check_control_messages - if has_check_control_messages: - cbs_list = [] - for message in check_control_messages: - cbs_list.append(message["ccmDescriptionShort"]) - result["check_control_messages"] = cbs_list - else: - result["check_control_messages"] = "OK" - elif self._attribute == "charging_status": - result["charging_status"] = vehicle_state.charging_status.value - result["last_charging_end_result"] = vehicle_state.last_charging_end_result - elif self._attribute == "connection_status": - result["connection_status"] = vehicle_state.connection_status - - return sorted(result.items()) - - def update(self): + def update(self) -> None: """Read new state data from the library.""" - vehicle_state = self._vehicle.state - - # device class opening: On means open, Off means closed - if self._attribute == "lids": - _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) - self._state = not vehicle_state.all_lids_closed - if self._attribute == "windows": - self._state = not vehicle_state.all_windows_closed - # device class lock: On means unlocked, Off means locked - if self._attribute == "door_lock_state": - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - self._state = vehicle_state.door_lock_state not in [ - LockState.LOCKED, - LockState.SECURED, - ] - # device class light: On means light detected, Off means no light - if self._attribute == "lights_parking": - self._state = vehicle_state.are_parking_lights_on - # device class problem: On means problem detected, Off means no problem - if self._attribute == "condition_based_services": - self._state = not vehicle_state.are_all_cbs_ok - if self._attribute == "check_control_messages": - self._state = vehicle_state.has_check_control_messages - # device class power: On means power detected, Off means no power - if self._attribute == "charging_status": - self._state = vehicle_state.charging_status in [ChargingState.CHARGING] - # device class plug: On means device is plugged in, - # Off means device is unplugged - if self._attribute == "connection_status": - self._state = vehicle_state.connection_status == "CONNECTED" - - def _format_cbs_report(self, report): - result = {} - service_type = report.service_type.lower().replace("_", " ") - result[f"{service_type} status"] = report.state.value - if report.due_date is not None: - result[f"{service_type} date"] = report.due_date.strftime("%Y-%m-%d") - if report.due_distance is not None: - distance = round( - self.hass.config.units.length(report.due_distance, LENGTH_KILOMETERS) - ) - result[ - f"{service_type} distance" - ] = f"{distance} {self.hass.config.units.length_unit}" - return result + _LOGGER.debug("Updating binary sensors of %s", self._vehicle.name) + vehicle_state = self._vehicle.status + result = self._attrs.copy() + + self._attr_is_on = self.entity_description.value_fn( + vehicle_state, result, self._unit_system + ) + self._attr_extra_state_attributes = result diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index c0d71c978ec54..3b07830c0778f 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,4 +1,8 @@ """Config flow for BMW ConnectedDrive integration.""" +from __future__ import annotations + +from typing import Any + from bimmer_connected.account import ConnectedDriveAccount from bimmer_connected.country_selector import get_region_from_name import voluptuous as vol @@ -6,9 +10,10 @@ from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_USE_LOCATION +from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY DATA_SCHEMA = vol.Schema( { @@ -19,7 +24,9 @@ ) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -43,9 +50,11 @@ class BMWConnectedDriveConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" @@ -65,13 +74,15 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle import.""" return await self.async_step_user(user_input) @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> BMWConnectedDriveOptionsFlow: """Return a BWM ConnectedDrive option flow.""" return BMWConnectedDriveOptionsFlow(config_entry) @@ -79,16 +90,20 @@ def async_get_options_flow(config_entry): class BMWConnectedDriveOptionsFlow(config_entries.OptionsFlow): """Handle a option flow for BMW ConnectedDrive.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize BMW ConnectedDrive option flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" return await self.async_step_account_options() - async def async_step_account_options(self, user_input=None): + async def async_step_account_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -100,10 +115,6 @@ async def async_step_account_options(self, user_input=None): CONF_READ_ONLY, default=self.config_entry.options.get(CONF_READ_ONLY, False), ): bool, - vol.Optional( - CONF_USE_LOCATION, - default=self.config_entry.options.get(CONF_USE_LOCATION, False), - ): bool, } ), ) diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 7af24496838c5..83609d239c1e5 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -1,4 +1,11 @@ """Const file for the BMW Connected Drive integration.""" +from homeassistant.const import ( + LENGTH_KILOMETERS, + LENGTH_MILES, + VOLUME_GALLONS, + VOLUME_LITERS, +) + ATTRIBUTION = "Data provided by BMW Connected Drive" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] @@ -9,3 +16,10 @@ DATA_HASS_CONFIG = "hass_config" DATA_ENTRIES = "entries" + +UNIT_MAP = { + "KILOMETERS": LENGTH_KILOMETERS, + "MILES": LENGTH_MILES, + "LITERS": VOLUME_LITERS, + "GALLONS": VOLUME_GALLONS, +} diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 25adf6cb09f94..0ba2d5012a19f 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -1,23 +1,41 @@ """Device tracker for BMW Connected Drive vehicles.""" +from __future__ import annotations + import logging +from typing import Literal + +from bimmer_connected.vehicle import ConnectedDriveVehicle from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.config_entry import TrackerEntity - -from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + DOMAIN as BMW_DOMAIN, + BMWConnectedDriveAccount, + BMWConnectedDriveBaseEntity, +) from .const import CONF_ACCOUNT, DATA_ENTRIES _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the BMW ConnectedDrive tracker from config entry.""" - account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT] - entities = [] + account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ + config_entry.entry_id + ][CONF_ACCOUNT] + entities: list[BMWDeviceTracker] = [] for vehicle in account.account.vehicles: entities.append(BMWDeviceTracker(account, vehicle)) - if not vehicle.state.is_vehicle_tracking_enabled: + if not vehicle.is_vehicle_tracking_enabled: _LOGGER.info( "Tracking is (currently) disabled for vehicle %s (%s), defaulting to unknown", vehicle.name, @@ -29,55 +47,42 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): """BMW Connected Drive device tracker.""" - def __init__(self, account, vehicle): + _attr_force_update = False + _attr_icon = "mdi:car" + + def __init__( + self, + account: BMWConnectedDriveAccount, + vehicle: ConnectedDriveVehicle, + ) -> None: """Initialize the Tracker.""" super().__init__(account, vehicle) - self._unique_id = vehicle.vin - self._location = ( - vehicle.state.gps_position if vehicle.state.gps_position else (None, None) - ) - self._name = vehicle.name + self._attr_unique_id = vehicle.vin + self._location = pos if (pos := vehicle.status.gps_position) else None + self._attr_name = vehicle.name @property - def latitude(self): + def latitude(self) -> float | None: """Return latitude value of the device.""" return self._location[0] if self._location else None @property - def longitude(self): + def longitude(self) -> float | None: """Return longitude value of the device.""" return self._location[1] if self._location else None @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - - @property - def source_type(self): + def source_type(self) -> Literal["gps"]: """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return "mdi:car" - - @property - def force_update(self): - """All updates do not need to be written to the state machine.""" - return False - - def update(self): + def update(self) -> None: """Update state of the decvice tracker.""" + _LOGGER.debug("Updating device tracker of %s", self._vehicle.name) + self._attr_extra_state_attributes = self._attrs self._location = ( - self._vehicle.state.gps_position - if self._vehicle.state.is_vehicle_tracking_enabled - else (None, None) + self._vehicle.status.gps_position + if self._vehicle.is_vehicle_tracking_enabled + else None ) diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 97c9be7216b36..71539019f82fa 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -1,102 +1,97 @@ """Support for BMW car locks with BMW ConnectedDrive.""" import logging +from typing import Any -from bimmer_connected.state import LockState +from bimmer_connected.vehicle import ConnectedDriveVehicle +from bimmer_connected.vehicle_status import LockState from homeassistant.components.lock import LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED - -from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + DOMAIN as BMW_DOMAIN, + BMWConnectedDriveAccount, + BMWConnectedDriveBaseEntity, +) from .const import CONF_ACCOUNT, DATA_ENTRIES DOOR_LOCK_STATE = "door_lock_state" _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the BMW ConnectedDrive binary sensors from config entry.""" - account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT] - entities = [] + account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ + config_entry.entry_id + ][CONF_ACCOUNT] if not account.read_only: - for vehicle in account.account.vehicles: - device = BMWLock(account, vehicle, "lock", "BMW lock") - entities.append(device) - async_add_entities(entities, True) + entities = [ + BMWLock(account, vehicle, "lock", "BMW lock") + for vehicle in account.account.vehicles + ] + async_add_entities(entities, True) class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): """Representation of a BMW vehicle lock.""" - def __init__(self, account, vehicle, attribute: str, sensor_name): + def __init__( + self, + account: BMWConnectedDriveAccount, + vehicle: ConnectedDriveVehicle, + attribute: str, + sensor_name: str, + ) -> None: """Initialize the lock.""" super().__init__(account, vehicle) self._attribute = attribute - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._sensor_name = sensor_name - self._state = None - self.door_lock_state_available = ( - DOOR_LOCK_STATE in self._vehicle.available_attributes - ) - - @property - def unique_id(self): - """Return the unique ID of the lock.""" - return self._unique_id - - @property - def name(self): - """Return the name of the lock.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes of the lock.""" - vehicle_state = self._vehicle.state - result = self._attrs.copy() - - if self.door_lock_state_available: - result["door_lock_state"] = vehicle_state.door_lock_state.value - result["last_update_reason"] = vehicle_state.last_update_reason - return result + self.door_lock_state_available = DOOR_LOCK_STATE in vehicle.available_attributes - @property - def is_locked(self): - """Return true if lock is locked.""" - if self.door_lock_state_available: - result = self._state == STATE_LOCKED - else: - result = None - return result - - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the car.""" _LOGGER.debug("%s: locking doors", self._vehicle.name) # Optimistic state set here because it takes some time before the # update callback response - self._state = STATE_LOCKED + self._attr_is_locked = True self.schedule_update_ha_state() self._vehicle.remote_services.trigger_remote_door_lock() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the car.""" _LOGGER.debug("%s: unlocking doors", self._vehicle.name) # Optimistic state set here because it takes some time before the # update callback response - self._state = STATE_UNLOCKED + self._attr_is_locked = False self.schedule_update_ha_state() self._vehicle.remote_services.trigger_remote_door_unlock() - def update(self): + def update(self) -> None: """Update state of the lock.""" - _LOGGER.debug("%s: updating data for %s", self._vehicle.name, self._attribute) - vehicle_state = self._vehicle.state - - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - self._state = ( - STATE_LOCKED - if vehicle_state.door_lock_state in [LockState.LOCKED, LockState.SECURED] - else STATE_UNLOCKED + _LOGGER.debug( + "Updating lock data for '%s' of %s", self._attribute, self._vehicle.name ) + vehicle_state = self._vehicle.status + if not self.door_lock_state_available: + self._attr_is_locked = None + else: + self._attr_is_locked = vehicle_state.door_lock_state in { + LockState.LOCKED, + LockState.SECURED, + } + + result = self._attrs.copy() + if self.door_lock_state_available: + result["door_lock_state"] = vehicle_state.door_lock_state.value + result["last_update_reason"] = vehicle_state.last_update_reason + self._attr_extra_state_attributes = result diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index aff9e4fd647bf..63046d9d4419f 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.15"], + "requirements": ["bimmer_connected==0.8.7"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 3fd40f3801c1d..2db25aaa592bc 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -1,16 +1,21 @@ """Support for BMW notifications.""" +from __future__ import annotations + import logging +from typing import Any, cast + +from bimmer_connected.vehicle import ConnectedDriveVehicle from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, - ATTR_TITLE, - ATTR_TITLE_DEFAULT, BaseNotificationService, ) from homeassistant.const import ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as BMW_DOMAIN +from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveAccount from .const import CONF_ACCOUNT, DATA_ENTRIES ATTR_LAT = "lat" @@ -22,9 +27,15 @@ _LOGGER = logging.getLogger(__name__) -def get_service(hass, config, discovery_info=None): +def get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> BMWNotificationService: """Get the BMW notification service.""" - accounts = [e[CONF_ACCOUNT] for e in hass.data[BMW_DOMAIN][DATA_ENTRIES].values()] + accounts: list[BMWConnectedDriveAccount] = [ + e[CONF_ACCOUNT] for e in hass.data[BMW_DOMAIN][DATA_ENTRIES].values() + ] _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts])) svc = BMWNotificationService() svc.setup(accounts) @@ -34,22 +45,22 @@ def get_service(hass, config, discovery_info=None): class BMWNotificationService(BaseNotificationService): """Send Notifications to BMW.""" - def __init__(self): + def __init__(self) -> None: """Set up the notification service.""" - self.targets = {} + self.targets: dict[str, ConnectedDriveVehicle] = {} - def setup(self, accounts): + def setup(self, accounts: list[BMWConnectedDriveAccount]) -> None: """Get the BMW vehicle(s) for the account(s).""" for account in accounts: self.targets.update({v.name: v for v in account.account.vehicles}) - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message or POI to the car.""" - for _vehicle in kwargs[ATTR_TARGET]: - _LOGGER.debug("Sending message to %s", _vehicle.name) + for vehicle in kwargs[ATTR_TARGET]: + vehicle = cast(ConnectedDriveVehicle, vehicle) + _LOGGER.debug("Sending message to %s", vehicle.name) # Extract params from data dict - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) # Check if message is a POI @@ -68,8 +79,6 @@ def send_message(self, message="", **kwargs): } ) - _vehicle.remote_services.trigger_send_poi(location_dict) + vehicle.remote_services.trigger_send_poi(location_dict) else: - _vehicle.remote_services.trigger_send_message( - {ATTR_TEXT: message, ATTR_SUBJECT: title} - ) + raise ValueError(f"'data.{ATTR_LOCATION}' is required.") diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 48d28e26f8a99..f21c1b851ca2f 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -1,150 +1,152 @@ """Support for reading vehicle status from BMW connected drive portal.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import cast -from bimmer_connected.const import SERVICE_LAST_TRIP, SERVICE_STATUS -from bimmer_connected.state import ChargingState +from bimmer_connected.vehicle import ConnectedDriveVehicle -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, - DEVICE_CLASS_TIMESTAMP, - ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, LENGTH_MILES, PERCENTAGE, - TIME_HOURS, - TIME_MINUTES, VOLUME_GALLONS, VOLUME_LITERS, ) -from homeassistant.helpers.icon import icon_for_battery_level -import homeassistant.util.dt as dt_util - -from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity -from .const import CONF_ACCOUNT, DATA_ENTRIES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_system import UnitSystem + +from . import ( + DOMAIN as BMW_DOMAIN, + BMWConnectedDriveAccount, + BMWConnectedDriveBaseEntity, +) +from .const import CONF_ACCOUNT, DATA_ENTRIES, UNIT_MAP _LOGGER = logging.getLogger(__name__) -ATTR_TO_HA_METRIC = { - # "": [, , , ], - "mileage": ["mdi:speedometer", None, LENGTH_KILOMETERS, True], - "remaining_range_total": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], - "remaining_range_electric": [ - "mdi:map-marker-distance", - None, - LENGTH_KILOMETERS, - True, - ], - "remaining_range_fuel": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], - "max_range_electric": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], - "remaining_fuel": ["mdi:gas-station", None, VOLUME_LITERS, True], - # LastTrip attributes - "average_combined_consumption": [ - "mdi:flash", - None, - f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - True, - ], - "average_electric_consumption": [ - "mdi:power-plug-outline", - None, - f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - True, - ], - "average_recuperation": [ - "mdi:recycle-variant", - None, - f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - True, - ], - "electric_distance": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], - "saved_fuel": ["mdi:fuel", None, VOLUME_LITERS, False], - "total_distance": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], -} - -ATTR_TO_HA_IMPERIAL = { - # "": [, , , ], - "mileage": ["mdi:speedometer", None, LENGTH_MILES, True], - "remaining_range_total": ["mdi:map-marker-distance", None, LENGTH_MILES, True], - "remaining_range_electric": ["mdi:map-marker-distance", None, LENGTH_MILES, True], - "remaining_range_fuel": ["mdi:map-marker-distance", None, LENGTH_MILES, True], - "max_range_electric": ["mdi:map-marker-distance", None, LENGTH_MILES, True], - "remaining_fuel": ["mdi:gas-station", None, VOLUME_GALLONS, True], - # LastTrip attributes - "average_combined_consumption": [ - "mdi:flash", - None, - f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - True, - ], - "average_electric_consumption": [ - "mdi:power-plug-outline", - None, - f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - True, - ], - "average_recuperation": [ - "mdi:recycle-variant", - None, - f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - True, - ], - "electric_distance": ["mdi:map-marker-distance", None, LENGTH_MILES, True], - "saved_fuel": ["mdi:fuel", None, VOLUME_GALLONS, False], - "total_distance": ["mdi:map-marker-distance", None, LENGTH_MILES, True], -} -ATTR_TO_HA_GENERIC = { - # "": [, , , ], - "charging_time_remaining": ["mdi:update", None, TIME_HOURS, True], - "charging_status": ["mdi:battery-charging", None, None, True], - # No icon as this is dealt with directly as a special case in icon() - "charging_level_hv": [None, None, PERCENTAGE, True], - # LastTrip attributes - "date_utc": [None, DEVICE_CLASS_TIMESTAMP, None, True], - "duration": ["mdi:timer-outline", None, TIME_MINUTES, True], - "electric_distance_ratio": ["mdi:percent-outline", None, PERCENTAGE, False], +@dataclass +class BMWSensorEntityDescription(SensorEntityDescription): + """Describes BMW sensor entity.""" + + unit_metric: str | None = None + unit_imperial: str | None = None + value: Callable = lambda x, y: x + + +SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { + # --- Generic --- + "charging_start_time": BMWSensorEntityDescription( + key="charging_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), + "charging_end_time": BMWSensorEntityDescription( + key="charging_end_time", + device_class=SensorDeviceClass.TIMESTAMP, + ), + "charging_time_label": BMWSensorEntityDescription( + key="charging_time_label", + entity_registry_enabled_default=False, + ), + "charging_status": BMWSensorEntityDescription( + key="charging_status", + icon="mdi:ev-station", + value=lambda x, y: x.value, + ), + "charging_level_hv": BMWSensorEntityDescription( + key="charging_level_hv", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + # --- Specific --- + "mileage": BMWSensorEntityDescription( + key="mileage", + icon="mdi:speedometer", + unit_metric=LENGTH_KILOMETERS, + unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])), 2 + ), + ), + "remaining_range_total": BMWSensorEntityDescription( + key="remaining_range_total", + icon="mdi:map-marker-distance", + unit_metric=LENGTH_KILOMETERS, + unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])), 2 + ), + ), + "remaining_range_electric": BMWSensorEntityDescription( + key="remaining_range_electric", + icon="mdi:map-marker-distance", + unit_metric=LENGTH_KILOMETERS, + unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])), 2 + ), + ), + "remaining_range_fuel": BMWSensorEntityDescription( + key="remaining_range_fuel", + icon="mdi:map-marker-distance", + unit_metric=LENGTH_KILOMETERS, + unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])), 2 + ), + ), + "remaining_fuel": BMWSensorEntityDescription( + key="remaining_fuel", + icon="mdi:gas-station", + unit_metric=VOLUME_LITERS, + unit_imperial=VOLUME_GALLONS, + value=lambda x, hass: round( + hass.config.units.volume(x[0], UNIT_MAP.get(x[1], x[1])), 2 + ), + ), + "fuel_percent": BMWSensorEntityDescription( + key="fuel_percent", + icon="mdi:gas-station", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + ), } -ATTR_TO_HA_METRIC.update(ATTR_TO_HA_GENERIC) -ATTR_TO_HA_IMPERIAL.update(ATTR_TO_HA_GENERIC) - -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the BMW ConnectedDrive sensors from config entry.""" - if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - attribute_info = ATTR_TO_HA_IMPERIAL - else: - attribute_info = ATTR_TO_HA_METRIC - - account = hass.data[BMW_DOMAIN][DATA_ENTRIES][config_entry.entry_id][CONF_ACCOUNT] - entities = [] + unit_system = hass.config.units + account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ + config_entry.entry_id + ][CONF_ACCOUNT] + entities: list[BMWConnectedDriveSensor] = [] for vehicle in account.account.vehicles: - for service in vehicle.available_state_services: - if service == SERVICE_STATUS: - for attribute_name in vehicle.drive_train_attributes: - if attribute_name in vehicle.available_attributes: - device = BMWConnectedDriveSensor( - account, vehicle, attribute_name, attribute_info - ) - entities.append(device) - if service == SERVICE_LAST_TRIP: - for attribute_name in vehicle.state.last_trip.available_attributes: - if attribute_name == "date": - device = BMWConnectedDriveSensor( - account, - vehicle, - "date_utc", - attribute_info, - service, - ) - entities.append(device) - else: - device = BMWConnectedDriveSensor( - account, vehicle, attribute_name, attribute_info, service - ) - entities.append(device) + entities.extend( + [ + BMWConnectedDriveSensor(account, vehicle, description, unit_system) + for attribute_name in vehicle.available_attributes + if (description := SENSOR_TYPES.get(attribute_name)) + ] + ) async_add_entities(entities, True) @@ -152,97 +154,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): """Representation of a BMW vehicle sensor.""" - def __init__(self, account, vehicle, attribute: str, attribute_info, service=None): + entity_description: BMWSensorEntityDescription + + def __init__( + self, + account: BMWConnectedDriveAccount, + vehicle: ConnectedDriveVehicle, + description: BMWSensorEntityDescription, + unit_system: UnitSystem, + ) -> None: """Initialize BMW vehicle sensor.""" super().__init__(account, vehicle) + self.entity_description = description - self._attribute = attribute - self._service = service - self._state = None - if self._service: - self._name = ( - f"{self._vehicle.name} {self._service.lower()}_{self._attribute}" - ) - self._unique_id = ( - f"{self._vehicle.vin}-{self._service.lower()}-{self._attribute}" - ) - else: - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" - self._attribute_info = attribute_info - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - vehicle_state = self._vehicle.state - charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] - - if self._attribute == "charging_level_hv": - return icon_for_battery_level( - battery_level=vehicle_state.charging_level_hv, charging=charging_state - ) - icon = self._attribute_info.get(self._attribute, [None, None, None, None])[0] - return icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - enabled_default = self._attribute_info.get( - self._attribute, [None, None, None, True] - )[3] - return enabled_default - - @property - def state(self): - """Return the state of the sensor. + self._attr_name = f"{vehicle.name} {description.key}" + self._attr_unique_id = f"{vehicle.vin}-{description.key}" - The return type of this call depends on the attribute that - is configured. - """ - return self._state - - @property - def device_class(self) -> str: - """Get the device class.""" - clss = self._attribute_info.get(self._attribute, [None, None, None, None])[1] - return clss + if unit_system.name == CONF_UNIT_SYSTEM_IMPERIAL: + self._attr_native_unit_of_measurement = description.unit_imperial + else: + self._attr_native_unit_of_measurement = description.unit_metric @property - def unit_of_measurement(self) -> str: - """Get the unit of measurement.""" - unit = self._attribute_info.get(self._attribute, [None, None, None, None])[2] - return unit - - def update(self) -> None: - """Read new state data from the library.""" - _LOGGER.debug("Updating %s", self._vehicle.name) - vehicle_state = self._vehicle.state - vehicle_last_trip = self._vehicle.state.last_trip - if self._attribute == "charging_status": - self._state = getattr(vehicle_state, self._attribute).value - elif self.unit_of_measurement == VOLUME_GALLONS: - value = getattr(vehicle_state, self._attribute) - value_converted = self.hass.config.units.volume(value, VOLUME_LITERS) - self._state = round(value_converted) - elif self.unit_of_measurement == LENGTH_MILES: - value = getattr(vehicle_state, self._attribute) - value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) - self._state = round(value_converted) - elif self._service is None: - self._state = getattr(vehicle_state, self._attribute) - elif self._service == SERVICE_LAST_TRIP: - if self._attribute == "date_utc": - date_str = getattr(vehicle_last_trip, "date") - self._state = dt_util.parse_datetime(date_str).isoformat() - else: - self._state = getattr(vehicle_last_trip, self._attribute) + def native_value(self) -> StateType: + """Return the state.""" + state = getattr(self._vehicle.status, self.entity_description.key) + return cast(StateType, self.entity_description.value(state, self.hass)) diff --git a/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml index 170289edaea6a..3f5ff76bdd350 100644 --- a/homeassistant/components/bmw_connected_drive/services.yaml +++ b/homeassistant/components/bmw_connected_drive/services.yaml @@ -4,48 +4,115 @@ # component to avoid redundancy. light_flash: + name: Flash lights description: > - Flash the lights of the vehicle. The vehicle is identified via the vin - (see below). + Flash the lights of the vehicle. The vehicle is identified either via its + device entry or the VIN. If a VIN is specified, the device entry will be ignored. fields: + device_id: + name: Car + description: The BMW Connected Drive device + selector: + device: + integration: bmw_connected_drive vin: - description: > - The vehicle identification number (VIN) of the vehicle, 17 characters + name: VIN + description: The vehicle identification number (VIN) of the vehicle, 17 characters + advanced: true + required: false example: WBANXXXXXX1234567 + selector: + text: sound_horn: + name: Sound horn description: > - Sound the horn of the vehicle. The vehicle is identified via the vin - (see below). + Sound the horn of the vehicle. The vehicle is identified either via its + device entry or the VIN. If a VIN is specified, the device entry will be ignored. fields: + device_id: + name: Car + description: The BMW Connected Drive device + selector: + device: + integration: bmw_connected_drive vin: - description: > - The vehicle identification number (VIN) of the vehicle, 17 characters + name: VIN + description: The vehicle identification number (VIN) of the vehicle, 17 characters + advanced: true + required: false example: WBANXXXXXX1234567 + selector: + text: activate_air_conditioning: + name: Activate air conditioning description: > Start the air conditioning of the vehicle. What exactly is started here depends on the type of vehicle. It might range from just ventilation over - auxiliary heating to real air conditioning. The vehicle is identified via - the vin (see below). + auxiliary heating to real air conditioning. The vehicle is identified either via its + device entry or the VIN. If a VIN is specified, the device entry will be ignored. fields: + device_id: + name: Car + description: The BMW Connected Drive device + selector: + device: + integration: bmw_connected_drive vin: - description: > - The vehicle identification number (VIN) of the vehicle, 17 characters + name: VIN + description: The vehicle identification number (VIN) of the vehicle, 17 characters + advanced: true + required: false example: WBANXXXXXX1234567 + selector: + text: + +deactivate_air_conditioning: + name: Deactivate air conditioning + description: > + Stops the air conditioning of the vehicle. This only works on newer vehicles if you also + have the option in the MyBMW app. The vehicle is identified either via its + device entry or the VIN. If a VIN is specified, the device entry will be ignored. + fields: + device_id: + name: Car + description: The BMW Connected Drive device + selector: + device: + integration: bmw_connected_drive + vin: + name: VIN + description: The vehicle identification number (VIN) of the vehicle, 17 characters + advanced: true + required: false + example: WBANXXXXXX1234567 + selector: + text: find_vehicle: + name: Find vehicle description: > - Request vehicle to update the gps location. The vehicle is identified via the vin - (see below). + Request vehicle to update the GPS location. The vehicle is identified either via its + device entry or the VIN. If a VIN is specified, the device entry will be ignored. fields: + device_id: + name: Car + description: The BMW Connected Drive device + selector: + device: + integration: bmw_connected_drive vin: - description: > - The vehicle identification number (VIN) of the vehicle, 17 characters + name: VIN + description: The vehicle identification number (VIN) of the vehicle, 17 characters + advanced: true + required: false example: WBANXXXXXX1234567 + selector: + text: update_state: + name: Update state description: > Fetch the last state of the vehicles of all your accounts from the BMW server. This does *not* trigger an update from the vehicle, it just gets diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index c0c45b814a433..3e93cccb8c6b6 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -21,8 +21,7 @@ "step": { "account_options": { "data": { - "read_only": "Read-only (only sensors and notify, no execution of services, no lock)", - "use_location": "Use Home Assistant location for car location polls (required for non i3/i8 vehicles produced before 7/2014)" + "read_only": "Read-only (only sensors and notify, no execution of services, no lock)" } } } diff --git a/homeassistant/components/bmw_connected_drive/translations/bg.json b/homeassistant/components/bmw_connected_drive/translations/bg.json index 67a484573aa0c..90901301faf6d 100644 --- a/homeassistant/components/bmw_connected_drive/translations/bg.json +++ b/homeassistant/components/bmw_connected_drive/translations/bg.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/bmw_connected_drive/translations/ca.json b/homeassistant/components/bmw_connected_drive/translations/ca.json index d6bd70064c327..eb12ac6fc3b06 100644 --- a/homeassistant/components/bmw_connected_drive/translations/ca.json +++ b/homeassistant/components/bmw_connected_drive/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/bmw_connected_drive/translations/de.json b/homeassistant/components/bmw_connected_drive/translations/de.json index d274719d7d0d0..85e27b5b3e45e 100644 --- a/homeassistant/components/bmw_connected_drive/translations/de.json +++ b/homeassistant/components/bmw_connected_drive/translations/de.json @@ -16,5 +16,15 @@ } } } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Schreibgesch\u00fctzt (nur Sensoren und Notify, keine Ausf\u00fchrung von Diensten, kein Abschlie\u00dfen)", + "use_location": "Standort des Home Assistant f\u00fcr die Abfrage des Fahrzeugstandorts verwenden (erforderlich f\u00fcr nicht i3/i8 Fahrzeuge, die vor 7/2014 produziert wurden)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/es-419.json b/homeassistant/components/bmw_connected_drive/translations/es-419.json new file mode 100644 index 0000000000000..0bce46abd9709 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/es-419.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "region": "Regi\u00f3n de ConnectedDrive" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Solo lectura (solo sensores y notificaci\u00f3n, sin ejecuci\u00f3n de servicios, sin bloqueo)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/fr.json b/homeassistant/components/bmw_connected_drive/translations/fr.json index 900b352ecb6c2..aadce398cdc3f 100644 --- a/homeassistant/components/bmw_connected_drive/translations/fr.json +++ b/homeassistant/components/bmw_connected_drive/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, "step": { diff --git a/homeassistant/components/bmw_connected_drive/translations/he.json b/homeassistant/components/bmw_connected_drive/translations/he.json new file mode 100644 index 0000000000000..49f37a267d0a6 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/hu.json b/homeassistant/components/bmw_connected_drive/translations/hu.json index 8724f525626c7..fa3fcf2df57e6 100644 --- a/homeassistant/components/bmw_connected_drive/translations/hu.json +++ b/homeassistant/components/bmw_connected_drive/translations/hu.json @@ -16,5 +16,15 @@ } } } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Csak olvashat\u00f3 (csak \u00e9rz\u00e9kel\u0151k \u00e9s \u00e9rtes\u00edt\u00e9sek, szolg\u00e1ltat\u00e1sok v\u00e9grehajt\u00e1sa, z\u00e1rol\u00e1s n\u00e9lk\u00fcl)", + "use_location": "Haszn\u00e1lja a Home Assistant hely\u00e9t az aut\u00f3 helymeghat\u00e1roz\u00e1si lek\u00e9rdez\u00e9seihez (a 2014.07.07. el\u0151tt gy\u00e1rtott nem i3/i8 j\u00e1rm\u0171vekhez sz\u00fcks\u00e9ges)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/it.json b/homeassistant/components/bmw_connected_drive/translations/it.json index 277ed189c43ce..eeca1909ea71b 100644 --- a/homeassistant/components/bmw_connected_drive/translations/it.json +++ b/homeassistant/components/bmw_connected_drive/translations/it.json @@ -22,7 +22,7 @@ "account_options": { "data": { "read_only": "Sola lettura (solo sensori e notifica, nessuna esecuzione di servizi, nessun blocco)", - "use_location": "Usa la posizione di Home Assistant per richieste sulla posizione dell'auto (richiesto per veicoli non i3/i8 prodotti prima del 7/2014)" + "use_location": "Usa la posizione di Home Assistant per le richieste di posizione dell'auto (richiesto per veicoli non i3/i8 prodotti prima del 7/2014)" } } } diff --git a/homeassistant/components/bmw_connected_drive/translations/ja.json b/homeassistant/components/bmw_connected_drive/translations/ja.json new file mode 100644 index 0000000000000..a3fa86348fe41 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "region": "ConnectedDrive\u30ea\u30fc\u30b8\u30e7\u30f3", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "\u30ea\u30fc\u30c9\u30aa\u30f3\u30ea\u30fc(\u30bb\u30f3\u30b5\u30fc\u3068\u901a\u77e5\u306e\u307f\u3001\u30b5\u30fc\u30d3\u30b9\u306e\u5b9f\u884c\u306f\u4e0d\u53ef\u3001\u30ed\u30c3\u30af\u4e0d\u53ef)", + "use_location": "Home Assistant\u306e\u5834\u6240\u3092\u3001\u8eca\u306e\u4f4d\u7f6e\u3068\u3057\u3066\u30dd\u30fc\u30ea\u30f3\u30b0\u306b\u4f7f\u7528\u3059\u308b(2014\u5e747\u67087\u65e5\u4ee5\u524d\u306b\u751f\u7523\u3055\u308c\u305f\u3001i3/i8\u4ee5\u5916\u306e\u8eca\u4e21\u3067\u306f\u5fc5\u9808)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/sv.json b/homeassistant/components/bmw_connected_drive/translations/sv.json new file mode 100644 index 0000000000000..93cd828041e7d --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/sv.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "region": "ConnectedDrive Region", + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Endast l\u00e4sbar (endast sensorer och meddelanden, ingen k\u00f6rning av tj\u00e4nster, ingen l\u00e5sning)", + "use_location": "Anv\u00e4nd plats f\u00f6r Home Assistant som plats f\u00f6r bilen (kr\u00e4vs f\u00f6r icke i3/i8-fordon tillverkade f\u00f6re 7/2014)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/tr.json b/homeassistant/components/bmw_connected_drive/translations/tr.json index 153aa4126b066..d38f950392be3 100644 --- a/homeassistant/components/bmw_connected_drive/translations/tr.json +++ b/homeassistant/components/bmw_connected_drive/translations/tr.json @@ -11,9 +11,20 @@ "user": { "data": { "password": "Parola", + "region": "ConnectedDrive B\u00f6lgesi", "username": "Kullan\u0131c\u0131 Ad\u0131" } } } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Salt okunur (yaln\u0131zca sens\u00f6rler ve bildirim, hizmetlerin y\u00fcr\u00fct\u00fclmesi yok, kilit yok)", + "use_location": "Araba konumu anketleri i\u00e7in Home Assistant konumunu kullan\u0131n (7/2014 tarihinden \u00f6nce \u00fcretilmi\u015f i3/i8 olmayan ara\u00e7lar i\u00e7in gereklidir)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/zh-Hant.json b/homeassistant/components/bmw_connected_drive/translations/zh-Hant.json index fde5e1e3c94e0..4f62df586f577 100644 --- a/homeassistant/components/bmw_connected_drive/translations/zh-Hant.json +++ b/homeassistant/components/bmw_connected_drive/translations/zh-Hant.json @@ -21,7 +21,7 @@ "step": { "account_options": { "data": { - "read_only": "\u552f\u8b80\uff08\u50c5\u652f\u63f4\u50b3\u611f\u5668\u8207\u901a\u77e5\uff0c\u4e0d\n\u5305\u542b\u670d\u52d9\u8207\u9396\u5b9a\uff09", + "read_only": "\u552f\u8b80\uff08\u50c5\u652f\u63f4\u611f\u6e2c\u5668\u8207\u901a\u77e5\uff0c\u4e0d\n\u5305\u542b\u670d\u52d9\u8207\u9396\u5b9a\uff09", "use_location": "\u4f7f\u7528 Home Assistant \u4f4d\u7f6e\u53d6\u5f97\u6c7d\u8eca\u4f4d\u7f6e\uff08\u9700\u8981\u70ba2014/7 \u524d\u751f\u7522\u7684\u975ei3/i8 \u8eca\u6b3e\uff09" } } diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 93a927d21f320..bcc8e5d5be179 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,23 +1,37 @@ """The Bond integration.""" from asyncio import TimeoutError as AsyncIOTimeoutError +from http import HTTPStatus +import logging +from typing import Any -from aiohttp import ClientError, ClientTimeout +from aiohttp import ClientError, ClientResponseError, ClientTimeout from bond_api import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import SLOW_UPDATE_WARNING -from .const import BPUP_STOP, BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB +from .const import BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB from .utils import BondHub -PLATFORMS = ["cover", "fan", "light", "switch"] +PLATFORMS = [ + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.SWITCH, +] _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 -_STOP_CANCEL = "stop_cancel" + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -32,9 +46,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: timeout=ClientTimeout(total=_API_TIMEOUT), session=async_get_clientsession(hass), ) - hub = BondHub(bond) + hub = BondHub(bond, host) try: await hub.setup() + except ClientResponseError as ex: + if ex.status == HTTPStatus.UNAUTHORIZED: + _LOGGER.error("Bond token no longer valid: %s", ex) + return False + raise ConfigEntryNotReady from ex except (ClientError, AsyncIOTimeoutError, OSError) as error: raise ConfigEntryNotReady from error @@ -42,18 +61,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: stop_bpup = await start_bpup(host, bpup_subs) @callback - def _async_stop_event(event: Event) -> None: + def _async_stop_event(*_: Any) -> None: stop_bpup() - stop_event_cancel = hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, _async_stop_event + entry.async_on_unload(_async_stop_event) + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_stop_event) ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { HUB: hub, BPUP_SUBS: bpup_subs, - BPUP_STOP: stop_bpup, - _STOP_CANCEL: stop_event_cancel, } if not entry.unique_id: @@ -61,7 +79,7 @@ def _async_stop_event(event: Event) -> None: assert hub.bond_id is not None hub_name = hub.name or hub.bond_id - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry_id, identifiers={(DOMAIN, hub.bond_id)}, @@ -69,7 +87,9 @@ def _async_stop_event(event: Event) -> None: name=hub_name, model=hub.target, sw_version=hub.fw_ver, + hw_version=hub.mcu_ver, suggested_area=hub.location, + configuration_url=f"http://{host}", ) _async_remove_old_device_identifiers(config_entry_id, device_registry, hub) @@ -82,15 +102,8 @@ def _async_stop_event(event: Event) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - data = hass.data[DOMAIN][entry.entry_id] - data[_STOP_CANCEL]() - if BPUP_STOP in data: - data[BPUP_STOP]() - if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6265b5ba942d7..d3a7b4adf7266 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Bond integration.""" from __future__ import annotations +from http import HTTPStatus import logging from typing import Any @@ -9,16 +10,12 @@ import voluptuous as vol from homeassistant import config_entries, exceptions -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_HOST, - CONF_NAME, - HTTP_UNAUTHORIZED, -) +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN from .utils import BondHub @@ -33,6 +30,16 @@ TOKEN_SCHEMA = vol.Schema({}) +async def async_get_token(hass: HomeAssistant, host: str) -> str | None: + """Try to fetch the token from the bond device.""" + bond = Bond(host, "", session=async_get_clientsession(hass)) + try: + response: dict[str, str] = await bond.token() + except ClientConnectionError: + return None + return response.get("token") + + async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, str]: """Validate the user input allows us to connect.""" @@ -40,12 +47,12 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[st data[CONF_HOST], data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass) ) try: - hub = BondHub(bond) + hub = BondHub(bond, data[CONF_HOST]) await hub.setup(max_devices=1) except ClientConnectionError as error: raise InputValidationError("cannot_connect") from error except ClientResponseError as error: - if error.status == HTTP_UNAUTHORIZED: + if error.status == HTTPStatus.UNAUTHORIZED: raise InputValidationError("invalid_auth") from error raise InputValidationError("unknown") from error except Exception as error: @@ -75,31 +82,42 @@ async def _async_try_automatic_configure(self) -> None: online longer then the allowed setup period, and we will instead ask them to manually enter the token. """ - bond = Bond( - self._discovered[CONF_HOST], "", session=async_get_clientsession(self.hass) - ) - try: - response = await bond.token() - except ClientConnectionError: - return - - token = response.get("token") - if token is None: + host = self._discovered[CONF_HOST] + if not (token := await async_get_token(self.hass, host)): return self._discovered[CONF_ACCESS_TOKEN] = token - _, hub_name = await _validate_input(self.hass, self._discovered) + try: + _, hub_name = await _validate_input(self.hass, self._discovered) + except InputValidationError: + return self._discovered[CONF_NAME] = hub_name async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - name: str = discovery_info[CONF_NAME] - host: str = discovery_info[CONF_HOST] + name: str = discovery_info.name + host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured({CONF_HOST: host}) + for entry in self._async_current_entries(): + if entry.unique_id != bond_id: + continue + updates = {CONF_HOST: host} + if entry.state == ConfigEntryState.SETUP_ERROR and ( + token := await async_get_token(self.hass, host) + ): + updates[CONF_ACCESS_TOKEN] = token + new_data = {**entry.data, **updates} + if new_data != dict(entry.data): + self.hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + raise AbortFlow("already_configured") self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} await self._async_try_automatic_configure() @@ -179,7 +197,7 @@ async def async_step_user( class InputValidationError(exceptions.HomeAssistantError): """Error to indicate we cannot proceed due to invalid input.""" - def __init__(self, base: str): + def __init__(self, base: str) -> None: """Initialize with error base.""" super().__init__() self.base = base diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 818288a5764ca..778dcbc1a1f79 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -9,4 +9,9 @@ HUB = "hub" BPUP_SUBS = "bpup_subs" -BPUP_STOP = "bpup_stop" + +SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state" +SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state" +SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state" +SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state" +ATTR_POWER_STATE = "power_state" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index ca8432531e5b8..57be80c0fd705 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -5,7 +5,16 @@ from bond_api import Action, BPUPSubscriptions, DeviceType -from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity +from homeassistant.components.cover import ( + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverDeviceClass, + CoverEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity @@ -38,27 +47,34 @@ async def async_setup_entry( class BondCover(BondEntity, CoverEntity): """Representation of a Bond cover.""" + _attr_device_class = CoverDeviceClass.SHADE + def __init__( self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions ) -> None: """Create HA entity representing Bond cover.""" super().__init__(hub, device, bpup_subs) - - self._closed: bool | None = None + supported_features = 0 + if self._device.supports_open(): + supported_features |= SUPPORT_OPEN + if self._device.supports_close(): + supported_features |= SUPPORT_CLOSE + if self._device.supports_tilt_open(): + supported_features |= SUPPORT_OPEN_TILT + if self._device.supports_tilt_close(): + supported_features |= SUPPORT_CLOSE_TILT + if self._device.supports_hold(): + if self._device.supports_open() or self._device.supports_close(): + supported_features |= SUPPORT_STOP + if self._device.supports_tilt_open() or self._device.supports_tilt_close(): + supported_features |= SUPPORT_STOP_TILT + self._attr_supported_features = supported_features def _apply_state(self, state: dict) -> None: cover_open = state.get("open") - self._closed = True if cover_open == 0 else False if cover_open == 1 else None - - @property - def device_class(self) -> str | None: - """Get device class.""" - return DEVICE_CLASS_SHADE - - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed or not.""" - return self._closed + self._attr_is_closed = ( + True if cover_open == 0 else False if cover_open == 1 else None + ) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" @@ -71,3 +87,15 @@ async def async_close_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None: """Hold cover.""" await self._hub.bond.action(self._device.device_id, Action.hold()) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + await self._hub.bond.action(self._device.device_id, Action.tilt_open()) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + await self._hub.bond.action(self._device.device_id, Action.tilt_close()) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._hub.bond.action(self._device.device_id, Action.hold()) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index d435faf0a7c5e..1beca4895e291 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -10,6 +10,14 @@ from aiohttp import ClientError from bond_api import BPUPSubscriptions +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_MODEL, + ATTR_NAME, + ATTR_SUGGESTED_AREA, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, +) from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval @@ -25,63 +33,54 @@ class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" + _attr_should_poll = False + def __init__( self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions, sub_device: str | None = None, - ): + ) -> None: """Initialize entity with API and device info.""" self._hub = hub self._device = device self._device_id = device.device_id self._sub_device = sub_device - self._available = True + self._attr_available = True self._bpup_subs = bpup_subs self._update_lock: Lock | None = None self._initialized = False - - @property - def unique_id(self) -> str | None: - """Get unique ID for the entity.""" - hub_id = self._hub.bond_id - device_id = self._device_id - sub_device_id: str = f"_{self._sub_device}" if self._sub_device else "" - return f"{hub_id}_{device_id}{sub_device_id}" - - @property - def name(self) -> str | None: - """Get entity name.""" - if self._sub_device: - sub_device_name = self._sub_device.replace("_", " ").title() - return f"{self._device.name} {sub_device_name}" - return self._device.name - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False + sub_device_id: str = f"_{sub_device}" if sub_device else "" + self._attr_unique_id = f"{hub.bond_id}_{device.device_id}{sub_device_id}" + if sub_device: + sub_device_name = sub_device.replace("_", " ").title() + self._attr_name = f"{device.name} {sub_device_name}" + else: + self._attr_name = device.name @property def device_info(self) -> DeviceInfo: """Get a an HA device representing this Bond controlled device.""" - device_info: DeviceInfo = { - "manufacturer": self._hub.make, + device_info = DeviceInfo( + manufacturer=self._hub.make, # type ignore: tuple items should not be Optional - "identifiers": {(DOMAIN, self._hub.bond_id, self._device.device_id)}, # type: ignore[arg-type] - } + identifiers={(DOMAIN, self._hub.bond_id, self._device.device_id)}, # type: ignore[arg-type] + configuration_url=f"http://{self._hub.host}", + ) if self.name is not None: - device_info["name"] = self.name + device_info[ATTR_NAME] = self.name if self._hub.bond_id is not None: - device_info["via_device"] = (DOMAIN, self._hub.bond_id) + device_info[ATTR_VIA_DEVICE] = (DOMAIN, self._hub.bond_id) if self._device.location is not None: - device_info["suggested_area"] = self._device.location + device_info[ATTR_SUGGESTED_AREA] = self._device.location if not self._hub.is_bridge: if self._hub.model is not None: - device_info["model"] = self._hub.model + device_info[ATTR_MODEL] = self._hub.model if self._hub.fw_ver is not None: - device_info["sw_version"] = self._hub.fw_ver + device_info[ATTR_SW_VERSION] = self._hub.fw_ver + if self._hub.mcu_ver is not None: + device_info[ATTR_HW_VERSION] = self._hub.mcu_ver else: model_data = [] if self._device.branding_profile: @@ -89,20 +88,10 @@ def device_info(self) -> DeviceInfo: if self._device.template: model_data.append(self._device.template) if model_data: - device_info["model"] = " ".join(model_data) + device_info[ATTR_MODEL] = " ".join(model_data) return device_info - @property - def assumed_state(self) -> bool: - """Let HA know this entity relies on an assumed state tracked by Bond.""" - return self._hub.is_bridge and not self._device.trust_state - - @property - def available(self) -> bool: - """Report availability of this entity based on last API call results.""" - return self._available - async def async_update(self) -> None: """Fetch assumed state of the cover from the hub using API.""" await self._async_update_from_api() @@ -113,7 +102,7 @@ async def _async_update_if_bpup_not_alive(self, *_: Any) -> None: self.hass.is_stopping or self._bpup_subs.alive and self._initialized - and self._available + and self.available ): return @@ -135,13 +124,14 @@ async def _async_update_from_api(self) -> None: try: state: dict = await self._hub.bond.device_state(self._device_id) except (ClientError, AsyncIOTimeoutError, OSError) as error: - if self._available: + if self.available: _LOGGER.warning( "Entity %s has become unavailable", self.entity_id, exc_info=error ) - self._available = False + self._attr_available = False else: self._async_state_callback(state) + self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state @abstractmethod def _apply_state(self, state: dict) -> None: @@ -151,9 +141,9 @@ def _apply_state(self, state: dict) -> None: def _async_state_callback(self, state: dict) -> None: """Process a state change.""" self._initialized = True - if not self._available: + if not self.available: _LOGGER.info("Entity %s has come back", self.entity_id) - self._available = True + self._attr_available = True _LOGGER.debug( "Device state for %s (%s) is:\n%s", self.name, self.entity_id, state ) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 3d611eb3f8c5f..b5d7059b67e94 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -5,9 +5,12 @@ import math from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType, Direction +import voluptuous as vol from homeassistant.components.fan import ( + ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, SUPPORT_DIRECTION, @@ -16,6 +19,8 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -24,7 +29,7 @@ ranged_value_to_percentage, ) -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity from .utils import BondDevice, BondHub @@ -40,6 +45,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() fans: list[Entity] = [ BondFan(hub, device, bpup_subs) @@ -47,13 +53,21 @@ async def async_setup_entry( if DeviceType.is_fan(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_FAN_SPEED_TRACKED_STATE, + {vol.Required(ATTR_SPEED): vol.All(vol.Number(scale=0), vol.Range(0, 100))}, + "async_set_speed_belief", + ) + async_add_entities(fans, True) class BondFan(BondEntity, FanEntity): """Representation of a Bond fan.""" - def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): + def __init__( + self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions + ) -> None: """Create HA entity representing Bond fan.""" super().__init__(hub, device, bpup_subs) @@ -126,6 +140,41 @@ async def async_set_percentage(self, percentage: int) -> None: self._device.device_id, Action.set_speed(bond_speed) ) + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the believed state to on or off.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_speed_belief(self, speed: int) -> None: + """Set the believed speed for the fan.""" + _LOGGER.debug("async_set_speed_belief called with percentage %s", speed) + if speed == 0: + await self.async_set_power_belief(False) + return + + await self.async_set_power_belief(True) + + bond_speed = math.ceil(percentage_to_ranged_value(self._speed_range, speed)) + _LOGGER.debug( + "async_set_percentage converted percentage %s to bond speed %s", + speed, + bond_speed, + ) + try: + await self._hub.bond.action( + self._device.device_id, Action.set_speed_belief(bond_speed) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_speed_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + async def async_turn_on( self, speed: str | None = None, diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 887e7901d7d42..255f848c16701 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -4,7 +4,9 @@ import logging from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType +import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -12,17 +14,36 @@ LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BondHub -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import ( + ATTR_POWER_STATE, + BPUP_SUBS, + DOMAIN, + HUB, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, +) from .entity import BondEntity from .utils import BondDevice _LOGGER = logging.getLogger(__name__) +SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness" +SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness" +SERVICE_STOP = "stop" + +ENTITY_SERVICES = [ + SERVICE_START_INCREASING_BRIGHTNESS, + SERVICE_START_DECREASING_BRIGHTNESS, + SERVICE_STOP, +] + async def async_setup_entry( hass: HomeAssistant, @@ -33,6 +54,15 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() + + platform = entity_platform.async_get_current_platform() + for service in ENTITY_SERVICES: + platform.async_register_entity_service( + service, + {}, + f"async_{service}", + ) fan_lights: list[Entity] = [ BondLight(hub, device, bpup_subs) @@ -72,6 +102,22 @@ async def async_setup_entry( if DeviceType.is_light(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + { + vol.Required(ATTR_BRIGHTNESS): vol.All( + vol.Number(scale=0), vol.Range(0, 255) + ) + }, + "async_set_brightness_belief", + ) + + platform.async_register_entity_service( + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, + {vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)}, + "async_set_power_belief", + ) + async_add_entities( fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights, True, @@ -81,26 +127,35 @@ async def async_setup_entry( class BondBaseLight(BondEntity, LightEntity): """Representation of a Bond light.""" - def __init__( - self, - hub: BondHub, - device: BondDevice, - bpup_subs: BPUPSubscriptions, - sub_device: str | None = None, - ): - """Create HA entity representing Bond light.""" - super().__init__(hub, device, bpup_subs, sub_device) - self._light: int | None = None - - @property - def is_on(self) -> bool: - """Return if light is currently on.""" - return self._light == 1 + _attr_supported_features = 0 - @property - def supported_features(self) -> int: - """Flag supported features.""" - return 0 + async def async_set_brightness_belief(self, brightness: int) -> None: + """Set the belief state of the light.""" + if not self._device.supports_set_brightness(): + raise HomeAssistantError("This device does not support setting brightness") + if brightness == 0: + await self.async_set_power_belief(False) + return + try: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness_belief(round((brightness * 100) / 255)), + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_brightness_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the belief state of the light.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_light_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_light_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex class BondLight(BondBaseLight, BondEntity, LightEntity): @@ -112,34 +167,20 @@ def __init__( device: BondDevice, bpup_subs: BPUPSubscriptions, sub_device: str | None = None, - ): + ) -> None: """Create HA entity representing Bond light.""" super().__init__(hub, device, bpup_subs, sub_device) - self._brightness: int | None = None + if device.supports_set_brightness(): + self._attr_supported_features = SUPPORT_BRIGHTNESS def _apply_state(self, state: dict) -> None: - self._light = state.get("light") - self._brightness = state.get("brightness") - - @property - def supported_features(self) -> int: - """Flag supported features.""" - if self._device.supports_set_brightness(): - return SUPPORT_BRIGHTNESS - return 0 - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 1..255.""" - brightness_value = ( - round(self._brightness * 255 / 100) if self._brightness else None - ) - return brightness_value + self._attr_is_on = state.get("light") == 1 + brightness = state.get("brightness") + self._attr_brightness = round(brightness * 255 / 100) if brightness else None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness: + if brightness := kwargs.get(ATTR_BRIGHTNESS): await self._hub.bond.action( self._device.device_id, Action.set_brightness(round((brightness * 100) / 255)), @@ -151,12 +192,37 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self._hub.bond.action(self._device.device_id, Action.turn_light_off()) + @callback + def _async_has_action_or_raise(self, action: str) -> None: + """Raise HomeAssistantError if the device does not support an action.""" + if not self._device.has_action(action): + raise HomeAssistantError(f"{self.entity_id} does not support {action}") + + async def async_start_increasing_brightness(self) -> None: + """Start increasing the light brightness.""" + self._async_has_action_or_raise(Action.START_INCREASING_BRIGHTNESS) + await self._hub.bond.action( + self._device.device_id, Action(Action.START_INCREASING_BRIGHTNESS) + ) + + async def async_start_decreasing_brightness(self) -> None: + """Start decreasing the light brightness.""" + self._async_has_action_or_raise(Action.START_DECREASING_BRIGHTNESS) + await self._hub.bond.action( + self._device.device_id, Action(Action.START_DECREASING_BRIGHTNESS) + ) + + async def async_stop(self) -> None: + """Stop all actions and clear the queue.""" + self._async_has_action_or_raise(Action.STOP) + await self._hub.bond.action(self._device.device_id, Action(Action.STOP)) + class BondDownLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" def _apply_state(self, state: dict) -> None: - self._light = state.get("down_light") and state.get("light") + self._attr_is_on = bool(state.get("down_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -175,7 +241,7 @@ class BondUpLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" def _apply_state(self, state: dict) -> None: - self._light = state.get("up_light") and state.get("light") + self._attr_is_on = bool(state.get("up_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -193,34 +259,20 @@ async def async_turn_off(self, **kwargs: Any) -> None: class BondFireplace(BondEntity, LightEntity): """Representation of a Bond-controlled fireplace.""" - def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): - """Create HA entity representing Bond fireplace.""" - super().__init__(hub, device, bpup_subs) - - self._power: bool | None = None - # Bond flame level, 0-100 - self._flame: int | None = None + _attr_supported_features = SUPPORT_BRIGHTNESS def _apply_state(self, state: dict) -> None: - self._power = state.get("power") - self._flame = state.get("flame") - - @property - def supported_features(self) -> int: - """Flag brightness as supported feature to represent flame level.""" - return SUPPORT_BRIGHTNESS - - @property - def is_on(self) -> bool: - """Return True if power is on.""" - return self._power == 1 + power = state.get("power") + flame = state.get("flame") + self._attr_is_on = power == 1 + self._attr_brightness = round(flame * 255 / 100) if flame else None + self._attr_icon = "mdi:fireplace" if power == 1 else "mdi:fireplace-off" async def async_turn_on(self, **kwargs: Any) -> None: """Turn the fireplace on.""" _LOGGER.debug("Fireplace async_turn_on called with: %s", kwargs) - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness: + if brightness := kwargs.get(ATTR_BRIGHTNESS): flame = round((brightness * 100) / 255) await self._hub.bond.action(self._device.device_id, Action.set_flame(flame)) else: @@ -232,12 +284,30 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._hub.bond.action(self._device.device_id, Action.turn_off()) - @property - def brightness(self) -> int | None: - """Return the flame of this fireplace converted to HA brightness between 0..255.""" - return round(self._flame * 255 / 100) if self._flame else None - - @property - def icon(self) -> str | None: - """Show fireplace icon for the entity.""" - return "mdi:fireplace" if self._power == 1 else "mdi:fireplace-off" + async def async_set_brightness_belief(self, brightness: int) -> None: + """Set the belief state of the light.""" + if not self._device.supports_set_brightness(): + raise HomeAssistantError("This device does not support setting brightness") + if brightness == 0: + await self.async_set_power_belief(False) + return + try: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness_belief(round((brightness * 100) / 255)), + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_brightness_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the belief state of the light.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 3995ecf5024c3..cf5255e84a460 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,9 +3,9 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.12"], + "requirements": ["bond-api==0.1.15"], "zeroconf": ["_bond._tcp.local."], - "codeowners": ["@prystupa"], + "codeowners": ["@bdraco", "@prystupa", "@joshs85"], "quality_scale": "platinum", "iot_class": "local_push" } diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml new file mode 100644 index 0000000000000..4ad2b4f9bb357 --- /dev/null +++ b/homeassistant/components/bond/services.yaml @@ -0,0 +1,118 @@ +# Describes the format for available bond services + +set_fan_speed_tracked_state: + name: Set fan speed tracked state + description: Sets the tracked fan speed for a bond fan + fields: + entity_id: + description: Name(s) of entities to set the tracked fan speed. + example: "fan.living_room_fan" + name: Entity + required: true + selector: + entity: + integration: bond + domain: fan + speed: + required: true + name: Fan Speed + description: Fan Speed as %. + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + mode: slider + +set_switch_power_tracked_state: + name: Set switch power tracked state + description: Sets the tracked power state of a bond switch + fields: + entity_id: + description: Name(s) of entities to set the tracked power state of. + example: "switch.whatever" + name: Entity + required: true + selector: + entity: + integration: bond + domain: switch + power_state: + required: true + name: Power state + description: Power state + example: true + selector: + boolean: + +set_light_power_tracked_state: + name: Set light power tracked state + description: Sets the tracked power state of a bond light + fields: + entity_id: + description: Name(s) of entities to set the tracked power state of. + example: "light.living_room_lights" + name: Entity + required: true + selector: + entity: + integration: bond + domain: light + power_state: + required: true + name: Power state + description: Power state + example: true + selector: + boolean: + +set_light_brightness_tracked_state: + name: Set light brightness tracked state + description: Sets the tracked brightness state of a bond light + fields: + entity_id: + description: Name(s) of entities to set the tracked brightness state of. + example: "light.living_room_lights" + name: Entity + required: true + selector: + entity: + integration: bond + domain: light + brightness: + required: true + name: Brightness + description: Brightness + example: 50 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider + +start_increasing_brightness: + name: Start increasing brightness + description: "Start increasing the brightness of the light." + target: + entity: + integration: bond + domain: light + +start_decreasing_brightness: + name: Start decreasing brightness + description: "Start decreasing the brightness of the light." + target: + entity: + integration: bond + domain: light + +stop: + name: Stop + description: "Stop any in-progress action and empty the queue." + target: + entity: + integration: bond + domain: light + diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 36d23547d7ecc..01c224d8307ff 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -3,17 +3,27 @@ from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType +import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import ( + ATTR_POWER_STATE, + BPUP_SUBS, + DOMAIN, + HUB, + SERVICE_SET_POWER_TRACKED_STATE, +) from .entity import BondEntity -from .utils import BondDevice, BondHub +from .utils import BondHub async def async_setup_entry( @@ -25,6 +35,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() switches: list[Entity] = [ BondSwitch(hub, device, bpup_subs) @@ -32,25 +43,20 @@ async def async_setup_entry( if DeviceType.is_generic(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_POWER_TRACKED_STATE, + {vol.Required(ATTR_POWER_STATE): cv.boolean}, + "async_set_power_belief", + ) + async_add_entities(switches, True) class BondSwitch(BondEntity, SwitchEntity): """Representation of a Bond generic device.""" - def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): - """Create HA entity representing Bond generic device (switch).""" - super().__init__(hub, device, bpup_subs) - - self._power: bool | None = None - def _apply_state(self, state: dict) -> None: - self._power = state.get("power") - - @property - def is_on(self) -> bool: - """Return True if power is on.""" - return self._power == 1 + self._attr_is_on = state.get("power") == 1 async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -59,3 +65,14 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._hub.bond.action(self._device.device_id, Action.turn_off()) + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set switch power belief.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex diff --git a/homeassistant/components/bond/translations/bg.json b/homeassistant/components/bond/translations/bg.json new file mode 100644 index 0000000000000..7f67a133aa868 --- /dev/null +++ b/homeassistant/components/bond/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/ca.json b/homeassistant/components/bond/translations/ca.json index 1d1df91563057..f2ef0761e24c2 100644 --- a/homeassistant/components/bond/translations/ca.json +++ b/homeassistant/components/bond/translations/ca.json @@ -9,7 +9,7 @@ "old_firmware": "Hi ha un programari antic i no compatible al dispositiu Bond - actualitza'l abans de continuar", "unknown": "Error inesperat" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/de.json b/homeassistant/components/bond/translations/de.json index 4b7372a452645..51f0bd0bee42b 100644 --- a/homeassistant/components/bond/translations/de.json +++ b/homeassistant/components/bond/translations/de.json @@ -9,7 +9,7 @@ "old_firmware": "Nicht unterst\u00fctzte alte Firmware auf dem Bond-Ger\u00e4t - bitte aktualisiere, bevor du fortf\u00e4hrst", "unknown": "Unerwarteter Fehler" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { @@ -19,7 +19,7 @@ }, "user": { "data": { - "access_token": "Zugriffstoken", + "access_token": "Zugangstoken", "host": "Host" } } diff --git a/homeassistant/components/bond/translations/es.json b/homeassistant/components/bond/translations/es.json index d99182385159b..33d3dbb440887 100644 --- a/homeassistant/components/bond/translations/es.json +++ b/homeassistant/components/bond/translations/es.json @@ -9,13 +9,13 @@ "old_firmware": "Firmware antiguo no compatible en el dispositivo Bond - actual\u00edzalo antes de continuar", "unknown": "Error inesperado" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { "access_token": "Token de acceso" }, - "description": "\u00bfQuieres configurar {bond_id}?" + "description": "\u00bfQuieres configurar {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/et.json b/homeassistant/components/bond/translations/et.json index 5e9a8e4493f44..7ebf173f8a79d 100644 --- a/homeassistant/components/bond/translations/et.json +++ b/homeassistant/components/bond/translations/et.json @@ -9,7 +9,7 @@ "old_firmware": "Bondi seadme ei toeta vana p\u00fcsivara - uuenda enne j\u00e4tkamist", "unknown": "Tundmatu viga" }, - "flow_title": "Bond: {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/fr.json b/homeassistant/components/bond/translations/fr.json index d9eb14b1a620c..f968622e21436 100644 --- a/homeassistant/components/bond/translations/fr.json +++ b/homeassistant/components/bond/translations/fr.json @@ -4,12 +4,12 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "old_firmware": "Ancien micrologiciel non pris en charge sur l'appareil Bond - veuillez mettre \u00e0 niveau avant de continuer", "unknown": "Erreur inattendue" }, - "flow_title": "Lien : {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { @@ -19,7 +19,7 @@ }, "user": { "data": { - "access_token": "Token d'acc\u00e8s", + "access_token": "Jeton d'acc\u00e8s", "host": "H\u00f4te" } } diff --git a/homeassistant/components/bond/translations/he.json b/homeassistant/components/bond/translations/he.json new file mode 100644 index 0000000000000..1cbd27129b2f3 --- /dev/null +++ b/homeassistant/components/bond/translations/he.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name} ({host})", + "step": { + "confirm": { + "data": { + "access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4" + } + }, + "user": { + "data": { + "access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4", + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/hu.json b/homeassistant/components/bond/translations/hu.json index 868ef455f5ddc..179ec599d9fd5 100644 --- a/homeassistant/components/bond/translations/hu.json +++ b/homeassistant/components/bond/translations/hu.json @@ -6,21 +6,21 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "old_firmware": "Nem t\u00e1mogatott r\u00e9gi firmware a Bond eszk\u00f6z\u00f6n - k\u00e9rj\u00fck friss\u00edtsd, miel\u0151tt folytatn\u00e1d", + "old_firmware": "Nem t\u00e1mogatott r\u00e9gi firmware a Bond eszk\u00f6z\u00f6n - k\u00e9rj\u00fck friss\u00edtse, miel\u0151tt folytatn\u00e1", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "Szeretn\u00e9d be\u00e1ll\u00edtani a(z) {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}-t?" }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/bond/translations/id.json b/homeassistant/components/bond/translations/id.json index 56c633cf31c7d..00a9dbac45d9f 100644 --- a/homeassistant/components/bond/translations/id.json +++ b/homeassistant/components/bond/translations/id.json @@ -9,7 +9,7 @@ "old_firmware": "Firmware lama yang tidak didukung pada perangkat Bond - tingkatkan versi sebelum melanjutkan", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/it.json b/homeassistant/components/bond/translations/it.json index e22ad82e1fd1a..843aa577654f2 100644 --- a/homeassistant/components/bond/translations/it.json +++ b/homeassistant/components/bond/translations/it.json @@ -6,10 +6,10 @@ "error": { "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida", - "old_firmware": "Firmware precedente non supportato sul dispositivo Bond - si prega di aggiornare prima di continuare", + "old_firmware": "Firmware precedente non supportato sul dispositivo Bond - aggiorna prima di continuare", "unknown": "Errore imprevisto" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/ja.json b/homeassistant/components/bond/translations/ja.json new file mode 100644 index 0000000000000..c5bf98f674074 --- /dev/null +++ b/homeassistant/components/bond/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "old_firmware": "Bond device\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u53e4\u3044\u30d5\u30a1\u30fc\u30e0\u30a6\u30a7\u30a2 - \u7d9a\u884c\u3059\u308b\u524d\u306b\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9\u3057\u3066\u304f\u3060\u3055\u3044", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({host})", + "step": { + "confirm": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/nl.json b/homeassistant/components/bond/translations/nl.json index 67812678082a5..fcf519d681daf 100644 --- a/homeassistant/components/bond/translations/nl.json +++ b/homeassistant/components/bond/translations/nl.json @@ -9,7 +9,7 @@ "old_firmware": "Niet-ondersteunde oude firmware op het Bond-apparaat - voer een upgrade uit voordat u doorgaat", "unknown": "Onverwachte fout" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/no.json b/homeassistant/components/bond/translations/no.json index c09b7a1763533..da09fe246eb7d 100644 --- a/homeassistant/components/bond/translations/no.json +++ b/homeassistant/components/bond/translations/no.json @@ -9,7 +9,7 @@ "old_firmware": "Gammel fastvare som ikke st\u00f8ttes p\u00e5 Bond-enheten \u2013 vennligst oppgrader f\u00f8r du fortsetter", "unknown": "Uventet feil" }, - "flow_title": "Obligasjon: {name} ({host})", + "flow_title": "{name} ( {host} )", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/pl.json b/homeassistant/components/bond/translations/pl.json index 6f5f2d276ff39..a4026879703eb 100644 --- a/homeassistant/components/bond/translations/pl.json +++ b/homeassistant/components/bond/translations/pl.json @@ -9,7 +9,7 @@ "old_firmware": "Stare, nieobs\u0142ugiwane oprogramowanie na urz\u0105dzeniu Bond - zaktualizuj przed kontynuowaniem", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/ru.json b/homeassistant/components/bond/translations/ru.json index cdc37fc27f702..2fd0886d42092 100644 --- a/homeassistant/components/bond/translations/ru.json +++ b/homeassistant/components/bond/translations/ru.json @@ -9,7 +9,7 @@ "old_firmware": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u0440\u043e\u0448\u0438\u0432\u043a\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f \u0443\u0441\u0442\u0430\u0440\u0435\u043b\u0430 \u0438 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/translations/tr.json b/homeassistant/components/bond/translations/tr.json index 3488480a21845..84df4b6da5c4a 100644 --- a/homeassistant/components/bond/translations/tr.json +++ b/homeassistant/components/bond/translations/tr.json @@ -6,18 +6,21 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "old_firmware": "Bond cihaz\u0131nda desteklenmeyen eski s\u00fcr\u00fcm - l\u00fctfen devam etmeden \u00f6nce y\u00fckseltin", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { - "access_token": "Eri\u015fim Belirteci" - } + "access_token": "Eri\u015fim Anahtar\u0131" + }, + "description": "{name} kurmak istiyor musunuz?" }, "user": { "data": { - "access_token": "Eri\u015fim Belirteci", - "host": "Ana Bilgisayar" + "access_token": "Eri\u015fim Anahtar\u0131", + "host": "Sunucu" } } } diff --git a/homeassistant/components/bond/translations/zh-Hant.json b/homeassistant/components/bond/translations/zh-Hant.json index de54be7fff3cb..1c2a03a7bbe88 100644 --- a/homeassistant/components/bond/translations/zh-Hant.json +++ b/homeassistant/components/bond/translations/zh-Hant.json @@ -9,7 +9,7 @@ "old_firmware": "Bond \u88dd\u7f6e\u4f7f\u7528\u4e0d\u652f\u63f4\u7684\u820a\u7248\u672c\u97cc\u9ad4 - \u8acb\u66f4\u65b0\u5f8c\u518d\u7e7c\u7e8c", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "Bond\uff1a{name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 916da69a06cad..785d7dfbd0045 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -25,7 +25,8 @@ def __init__( """Create a helper device from ID and attributes returned by API.""" self.device_id = device_id self.props = props - self._attrs = attrs + self._attrs = attrs or {} + self._supported_actions: set[str] = set(self._attrs.get("actions", [])) def __repr__(self) -> str: """Return readable representation of a bond device.""" @@ -65,13 +66,13 @@ def trust_state(self) -> bool: """Check if Trust State is turned on.""" return self.props.get("trust_state", False) + def has_action(self, action: str) -> bool: + """Check to see if the device supports an actions.""" + return action in self._supported_actions + def _has_any_action(self, actions: set[str]) -> bool: """Check to see if the device supports any of the actions.""" - supported_actions: list[str] = self._attrs["actions"] - for action in supported_actions: - if action in actions: - return True - return False + return bool(self._supported_actions.intersection(actions)) def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" @@ -81,6 +82,26 @@ def supports_direction(self) -> bool: """Return True if this device supports any of the direction related commands.""" return self._has_any_action({Action.SET_DIRECTION}) + def supports_open(self) -> bool: + """Return True if this device supports opening.""" + return self._has_any_action({Action.OPEN}) + + def supports_close(self) -> bool: + """Return True if this device supports closing.""" + return self._has_any_action({Action.CLOSE}) + + def supports_tilt_open(self) -> bool: + """Return True if this device supports tilt opening.""" + return self._has_any_action({Action.TILT_OPEN}) + + def supports_tilt_close(self) -> bool: + """Return True if this device supports tilt closing.""" + return self._has_any_action({Action.TILT_CLOSE}) + + def supports_hold(self) -> bool: + """Return True if this device supports hold aka stop.""" + return self._has_any_action({Action.HOLD}) + def supports_light(self) -> bool: """Return True if this device supports any of the light related commands.""" return self._has_any_action({Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF}) @@ -103,9 +124,10 @@ def supports_set_brightness(self) -> bool: class BondHub: """Hub device representing Bond Bridge.""" - def __init__(self, bond: Bond): + def __init__(self, bond: Bond, host: str) -> None: """Initialize Bond Hub.""" self.bond: Bond = bond + self.host = host self._bridge: dict[str, Any] = {} self._version: dict[str, Any] = {} self._devices: list[BondDevice] = [] @@ -185,6 +207,11 @@ def fw_ver(self) -> str | None: """Return this hub firmware version.""" return self._version.get("fw_ver") + @property + def mcu_ver(self) -> str | None: + """Return this hub hardware version.""" + return self._version.get("mcu_ver") + @property def devices(self) -> list[BondDevice]: """Return a list of all devices controlled by this hub.""" diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py new file mode 100644 index 0000000000000..afcf2571c31a2 --- /dev/null +++ b/homeassistant/components/bosch_shc/__init__.py @@ -0,0 +1,92 @@ +"""The Bosch Smart Home Controller integration.""" +import logging + +from boschshcpy import SHCSession +from boschshcpy.exceptions import SHCAuthenticationError, SHCConnectionError + +from homeassistant.components.zeroconf import async_get_instance +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import ( + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + DATA_POLLING_HANDLER, + DATA_SESSION, + DOMAIN, +) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Bosch SHC from a config entry.""" + data = entry.data + + zeroconf = await async_get_instance(hass) + try: + session = await hass.async_add_executor_job( + SHCSession, + data[CONF_HOST], + data[CONF_SSL_CERTIFICATE], + data[CONF_SSL_KEY], + False, + zeroconf, + ) + except SHCAuthenticationError as err: + raise ConfigEntryAuthFailed from err + except SHCConnectionError as err: + raise ConfigEntryNotReady from err + + shc_info = session.information + if shc_info.updateState.name == "UPDATE_AVAILABLE": + _LOGGER.warning("Please check for software updates in the Bosch Smart Home App") + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_SESSION: session, + } + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))}, + identifiers={(DOMAIN, shc_info.unique_id)}, + manufacturer="Bosch", + name=entry.title, + model="SmartHomeController", + sw_version=shc_info.version, + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + async def stop_polling(event): + """Stop polling service.""" + await hass.async_add_executor_job(session.stop_polling) + + await hass.async_add_executor_job(session.start_polling) + hass.data[DOMAIN][entry.entry_id][ + DATA_POLLING_HANDLER + ] = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_polling) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + session: SHCSession = hass.data[DOMAIN][entry.entry_id][DATA_SESSION] + + hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER]() + hass.data[DOMAIN][entry.entry_id].pop(DATA_POLLING_HANDLER) + await hass.async_add_executor_job(session.stop_polling) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py new file mode 100644 index 0000000000000..341be87a9391c --- /dev/null +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -0,0 +1,89 @@ +"""Platform for binarysensor integration.""" +from boschshcpy import SHCBatteryDevice, SHCSession, SHCShutterContact +from boschshcpy.device import SHCDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) + +from .const import DATA_SESSION, DOMAIN +from .entity import SHCEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the SHC binary sensor platform.""" + entities = [] + session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + + for binary_sensor in session.device_helper.shutter_contacts: + entities.append( + ShutterContactSensor( + device=binary_sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + for binary_sensor in ( + session.device_helper.motion_detectors + + session.device_helper.shutter_contacts + + session.device_helper.smoke_detectors + + session.device_helper.thermostats + + session.device_helper.twinguards + + session.device_helper.universal_switches + + session.device_helper.wallthermostats + + session.device_helper.water_leakage_detectors + ): + if binary_sensor.supports_batterylevel: + entities.append( + BatterySensor( + device=binary_sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + if entities: + async_add_entities(entities) + + +class ShutterContactSensor(SHCEntity, BinarySensorEntity): + """Representation of an SHC shutter contact sensor.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC shutter contact sensor..""" + super().__init__(device, parent_id, entry_id) + switcher = { + "ENTRANCE_DOOR": BinarySensorDeviceClass.DOOR, + "REGULAR_WINDOW": BinarySensorDeviceClass.WINDOW, + "FRENCH_WINDOW": BinarySensorDeviceClass.DOOR, + "GENERIC": BinarySensorDeviceClass.WINDOW, + } + self._attr_device_class = switcher.get( + self._device.device_class, BinarySensorDeviceClass.WINDOW + ) + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN + + +class BatterySensor(SHCEntity, BinarySensorEntity): + """Representation of an SHC battery reporting sensor.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC battery reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Battery" + self._attr_unique_id = f"{device.serial}_battery" + + @property + def is_on(self): + """Return the state of the sensor.""" + return ( + self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK + ) diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py new file mode 100644 index 0000000000000..6ad1a374a5ab7 --- /dev/null +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -0,0 +1,230 @@ +"""Config flow for Bosch Smart Home Controller integration.""" +import logging +from os import makedirs + +from boschshcpy import SHCRegisterClient, SHCSession +from boschshcpy.exceptions import ( + SHCAuthenticationError, + SHCConnectionError, + SHCRegistrationError, + SHCSessionError, +) +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_HOSTNAME, + CONF_SHC_CERT, + CONF_SHC_KEY, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +HOST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +def write_tls_asset(hass: core.HomeAssistant, filename: str, asset: bytes) -> None: + """Write the tls assets to disk.""" + makedirs(hass.config.path(DOMAIN), exist_ok=True) + with open(hass.config.path(DOMAIN, filename), "w", encoding="utf8") as file_handle: + file_handle.write(asset.decode("utf-8")) + + +def create_credentials_and_validate(hass, host, user_input, zeroconf_instance): + """Create and store credentials and validate session.""" + helper = SHCRegisterClient(host, user_input[CONF_PASSWORD]) + result = helper.register(host, "HomeAssistant") + + if result is not None: + write_tls_asset(hass, CONF_SHC_CERT, result["cert"]) + write_tls_asset(hass, CONF_SHC_KEY, result["key"]) + + session = SHCSession( + host, + hass.config.path(DOMAIN, CONF_SHC_CERT), + hass.config.path(DOMAIN, CONF_SHC_KEY), + True, + zeroconf_instance, + ) + session.authenticate() + + return result + + +def get_info_from_host(hass, host, zeroconf_instance): + """Get information from host.""" + session = SHCSession( + host, + "", + "", + True, + zeroconf_instance, + ) + information = session.mdns_info() + return {"title": information.name, "unique_id": information.unique_id} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bosch SHC.""" + + VERSION = 1 + info = None + host = None + hostname = None + + async def async_step_reauth(self, user_input=None): + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=HOST_SCHEMA, + ) + self.host = host = user_input[CONF_HOST] + self.info = await self._get_info(host) + return await self.async_step_credentials() + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + host = user_input[CONF_HOST] + try: + self.info = info = await self._get_info(host) + except SHCConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["unique_id"]) + self._abort_if_unique_id_configured({CONF_HOST: host}) + self.host = host + return await self.async_step_credentials() + + return self.async_show_form( + step_id="user", data_schema=HOST_SCHEMA, errors=errors + ) + + async def async_step_credentials(self, user_input=None): + """Handle the credentials step.""" + errors = {} + if user_input is not None: + zeroconf_instance = await zeroconf.async_get_instance(self.hass) + try: + result = await self.hass.async_add_executor_job( + create_credentials_and_validate, + self.hass, + self.host, + user_input, + zeroconf_instance, + ) + except SHCAuthenticationError: + errors["base"] = "invalid_auth" + except SHCConnectionError: + errors["base"] = "cannot_connect" + except SHCSessionError as err: + _LOGGER.warning("Session error: %s", err.message) + errors["base"] = "session_error" + except SHCRegistrationError as err: + _LOGGER.warning("Registration error: %s", err.message) + errors["base"] = "pairing_failed" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + entry_data = { + CONF_SSL_CERTIFICATE: self.hass.config.path(DOMAIN, CONF_SHC_CERT), + CONF_SSL_KEY: self.hass.config.path(DOMAIN, CONF_SHC_KEY), + CONF_HOST: self.host, + CONF_TOKEN: result["token"], + CONF_HOSTNAME: result["token"].split(":", 1)[1], + } + existing_entry = await self.async_set_unique_id(self.info["unique_id"]) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, + data=entry_data, + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=self.info["title"], + data=entry_data, + ) + else: + user_input = {} + + schema = vol.Schema( + { + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ) + + return self.async_show_form( + step_id="credentials", data_schema=schema, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + if not discovery_info.name.startswith("Bosch SHC"): + return self.async_abort(reason="not_bosch_shc") + + try: + self.info = await self._get_info(discovery_info.host) + except SHCConnectionError: + return self.async_abort(reason="cannot_connect") + self.host = discovery_info.host + + local_name = discovery_info.hostname[:-1] + node_name = local_name[: -len(".local")] + + await self.async_set_unique_id(self.info["unique_id"]) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + self.context["title_placeholders"] = {"name": node_name} + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery(self, user_input=None): + """Handle discovery confirm.""" + errors = {} + if user_input is not None: + return await self.async_step_credentials() + + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + "model": "Bosch SHC", + "host": self.host, + }, + errors=errors, + ) + + async def _get_info(self, host): + """Get additional information.""" + zeroconf_instance = await zeroconf.async_get_instance(self.hass) + + return await self.hass.async_add_executor_job( + get_info_from_host, + self.hass, + host, + zeroconf_instance, + ) diff --git a/homeassistant/components/bosch_shc/const.py b/homeassistant/components/bosch_shc/const.py new file mode 100644 index 0000000000000..ccb1f2094cbd8 --- /dev/null +++ b/homeassistant/components/bosch_shc/const.py @@ -0,0 +1,12 @@ +"""Constants for the Bosch SHC integration.""" + +CONF_HOSTNAME = "hostname" +CONF_SHC_CERT = "bosch_shc-cert.pem" +CONF_SHC_KEY = "bosch_shc-key.pem" +CONF_SSL_CERTIFICATE = "ssl_certificate" +CONF_SSL_KEY = "ssl_key" + +DATA_SESSION = "session" +DATA_POLLING_HANDLER = "polling_handler" + +DOMAIN = "bosch_shc" diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py new file mode 100644 index 0000000000000..c08984ca0c222 --- /dev/null +++ b/homeassistant/components/bosch_shc/cover.py @@ -0,0 +1,86 @@ +"""Platform for cover integration.""" +from boschshcpy import SHCSession, SHCShutterControl + +from homeassistant.components.cover import ( + ATTR_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverDeviceClass, + CoverEntity, +) + +from .const import DATA_SESSION, DOMAIN +from .entity import SHCEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the SHC cover platform.""" + + entities = [] + session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + + for cover in session.device_helper.shutter_controls: + entities.append( + ShutterControlCover( + device=cover, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + if entities: + async_add_entities(entities) + + +class ShutterControlCover(SHCEntity, CoverEntity): + """Representation of a SHC shutter control device.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + + @property + def current_cover_position(self): + """Return the current cover position.""" + return round(self._device.level * 100.0) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._device.stop() + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return self.current_cover_position == 0 + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return ( + self._device.operation_state + == SHCShutterControl.ShutterControlService.State.OPENING + ) + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return ( + self._device.operation_state + == SHCShutterControl.ShutterControlService.State.CLOSING + ) + + def open_cover(self, **kwargs): + """Open the cover.""" + self._device.level = 1.0 + + def close_cover(self, **kwargs): + """Close cover.""" + self._device.level = 0.0 + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + self._device.level = position / 100.0 diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py new file mode 100644 index 0000000000000..c3a981aa65860 --- /dev/null +++ b/homeassistant/components/bosch_shc/entity.py @@ -0,0 +1,71 @@ +"""Bosch Smart Home Controller base entity.""" +from boschshcpy.device import SHCDevice + +from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +async def async_remove_devices(hass, entity, entry_id): + """Get item that is removed from session.""" + dev_registry = get_dev_reg(hass) + device = dev_registry.async_get_device( + identifiers={(DOMAIN, entity.device_id)}, connections=set() + ) + if device is not None: + dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id) + + +class SHCEntity(Entity): + """Representation of a SHC base entity.""" + + _attr_should_poll = False + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize the generic SHC device.""" + self._device = device + self._entry_id = entry_id + self._attr_name = device.name + self._attr_unique_id = device.serial + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + manufacturer=device.manufacturer, + model=device.device_model, + name=device.name, + via_device=( + DOMAIN, + device.parent_device_id + if device.parent_device_id is not None + else parent_id, + ), + ) + + async def async_added_to_hass(self): + """Subscribe to SHC events.""" + await super().async_added_to_hass() + + def on_state_changed(): + self.schedule_update_ha_state() + + def update_entity_information(): + if self._device.deleted: + self.hass.add_job(async_remove_devices(self.hass, self, self._entry_id)) + else: + self.schedule_update_ha_state() + + for service in self._device.device_services: + service.subscribe_callback(self.entity_id, on_state_changed) + self._device.subscribe_callback(self.entity_id, update_entity_information) + + async def async_will_remove_from_hass(self): + """Unsubscribe from SHC events.""" + await super().async_will_remove_from_hass() + for service in self._device.device_services: + service.unsubscribe_callback(self.entity_id) + self._device.unsubscribe_callback(self.entity_id) + + @property + def available(self): + """Return false if status is unavailable.""" + return self._device.status == "AVAILABLE" diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json new file mode 100644 index 0000000000000..43d9290659280 --- /dev/null +++ b/homeassistant/components/bosch_shc/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bosch_shc", + "name": "Bosch SHC", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bosch_shc", + "requirements": ["boschshcpy==0.2.27"], + "zeroconf": [{ "type": "_http._tcp.local.", "name": "bosch shc*" }], + "iot_class": "local_push", + "codeowners": ["@tschamm"], + "after_dependencies": ["zeroconf"] +} diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py new file mode 100644 index 0000000000000..33d18ebbfd441 --- /dev/null +++ b/homeassistant/components/bosch_shc/sensor.py @@ -0,0 +1,321 @@ +"""Platform for sensor integration.""" +from boschshcpy import SHCSession +from boschshcpy.device import SHCDevice + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, +) + +from .const import DATA_SESSION, DOMAIN +from .entity import SHCEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the SHC sensor platform.""" + entities = [] + session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + + for sensor in session.device_helper.thermostats: + entities.append( + TemperatureSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + ValveTappetSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + for sensor in session.device_helper.wallthermostats: + entities.append( + TemperatureSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + HumiditySensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + for sensor in session.device_helper.twinguards: + entities.append( + TemperatureSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + HumiditySensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + PuritySensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + AirQualitySensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + TemperatureRatingSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + HumidityRatingSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + PurityRatingSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + for sensor in session.device_helper.smart_plugs: + entities.append( + PowerSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + EnergySensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + for sensor in session.device_helper.smart_plugs_compact: + entities.append( + PowerSensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + entities.append( + EnergySensor( + device=sensor, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + if entities: + async_add_entities(entities) + + +class TemperatureSensor(SHCEntity, SensorEntity): + """Representation of an SHC temperature reporting sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC temperature reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Temperature" + self._attr_unique_id = f"{device.serial}_temperature" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.temperature + + +class HumiditySensor(SHCEntity, SensorEntity): + """Representation of an SHC humidity reporting sensor.""" + + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC humidity reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Humidity" + self._attr_unique_id = f"{device.serial}_humidity" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.humidity + + +class PuritySensor(SHCEntity, SensorEntity): + """Representation of an SHC purity reporting sensor.""" + + _attr_icon = "mdi:molecule-co2" + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC purity reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Purity" + self._attr_unique_id = f"{device.serial}_purity" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.purity + + +class AirQualitySensor(SHCEntity, SensorEntity): + """Representation of an SHC airquality reporting sensor.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC airquality reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Air Quality" + self._attr_unique_id = f"{device.serial}_airquality" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.combined_rating.name + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + "rating_description": self._device.description, + } + + +class TemperatureRatingSensor(SHCEntity, SensorEntity): + """Representation of an SHC temperature rating sensor.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC temperature rating sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Temperature Rating" + self._attr_unique_id = f"{device.serial}_temperature_rating" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.temperature_rating.name + + +class HumidityRatingSensor(SHCEntity, SensorEntity): + """Representation of an SHC humidity rating sensor.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC humidity rating sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Humidity Rating" + self._attr_unique_id = f"{device.serial}_humidity_rating" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.humidity_rating.name + + +class PurityRatingSensor(SHCEntity, SensorEntity): + """Representation of an SHC purity rating sensor.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC purity rating sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Purity Rating" + self._attr_unique_id = f"{device.serial}_purity_rating" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.purity_rating.name + + +class PowerSensor(SHCEntity, SensorEntity): + """Representation of an SHC power reporting sensor.""" + + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = POWER_WATT + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC power reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Power" + self._attr_unique_id = f"{device.serial}_power" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.powerconsumption + + +class EnergySensor(SHCEntity, SensorEntity): + """Representation of an SHC energy reporting sensor.""" + + _attr_device_class = SensorDeviceClass.ENERGY + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC energy reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{self._device.name} Energy" + self._attr_unique_id = f"{self._device.serial}_energy" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.energyconsumption / 1000.0 + + +class ValveTappetSensor(SHCEntity, SensorEntity): + """Representation of an SHC valve tappet reporting sensor.""" + + _attr_icon = "mdi:gauge" + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC valve tappet reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Valvetappet" + self._attr_unique_id = f"{device.serial}_valvetappet" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._device.position + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + "valve_tappet_state": self._device.valvestate.name, + } diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json new file mode 100644 index 0000000000000..15fb061ef2b99 --- /dev/null +++ b/homeassistant/components/bosch_shc/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Bosch Smart Home Controller to allow monitoring and control with Home Assistant.", + "title": "SHC authentication parameters", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "credentials": { + "data": { + "password": "Password of the Smart Home Controller" + } + }, + "confirm_discovery": { + "description": "Please press the Bosch Smart Home Controller's front-side button until LED starts flashing.\nReady to continue to set up {model} @ {host} with Home Assistant?" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The bosch_shc integration needs to re-authenticate your account" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "pairing_failed": "Pairing failed; please check the Bosch Smart Home Controller is in pairing mode (LED flashing) as well as your password is correct.", + "session_error": "Session error: API return Non-OK result.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "flow_title": "Bosch SHC: {name}" + } +} diff --git a/homeassistant/components/bosch_shc/translations/bg.json b/homeassistant/components/bosch_shc/translations/bg.json new file mode 100644 index 0000000000000..759dd6b21fb1c --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "reauth_confirm": { + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/ca.json b/homeassistant/components/bosch_shc/translations/ca.json new file mode 100644 index 0000000000000..6db49cf7103b2 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/ca.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "pairing_failed": "La vinculaci\u00f3 ha fallat; comprova que el controlador Bosch Smart Home est\u00e0 en mode vinculaci\u00f3 (LED parpellejant) i que la contrasenya \u00e9s correcta.", + "session_error": "Error de sessi\u00f3: l'API ha retornat No OK.", + "unknown": "Error inesperat" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Prem el bot\u00f3 de la part frontal/lateral del controlador Bosh Smart Home fins que el LED parpellegi.\nPreparat per continuar la configuraci\u00f3 de {model} @ {host} amb Home Assistant?" + }, + "credentials": { + "data": { + "password": "Contrasenya de Smart Home Controller" + } + }, + "reauth_confirm": { + "description": "La integraci\u00f3 bosch_shc ha de tornar a autenticar el teu compte", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Configura Bosch Smart Controller per poder controlar i monitoritzar-lo des de Home Assistant.", + "title": "Par\u00e0metres d'autenticaci\u00f3 SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/de.json b/homeassistant/components/bosch_shc/translations/de.json new file mode 100644 index 0000000000000..46b6469afe6a6 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/de.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "pairing_failed": "Pairing fehlgeschlagen; bitte pr\u00fcfe, ob sich der Bosch Smart Home Controller im Pairing-Modus befindet (LED blinkt) und ob dein Passwort korrekt ist.", + "session_error": "Sitzungsfehler: API gab Non-OK-Ergebnis zur\u00fcck.", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Bitte dr\u00fccke die frontseitige Taste des Bosch Smart Home Controllers, bis die LED zu blinken beginnt.\nBist du bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?" + }, + "credentials": { + "data": { + "password": "Passwort des Smart Home Controllers" + } + }, + "reauth_confirm": { + "description": "Die bosch_shc-Integration muss dein Konto neu authentifizieren", + "title": "Integration erneut authentifizieren" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Richte deinen Bosch Smart Home Controller ein, um die \u00dcberwachung und Steuerung mit Home Assistant zu erm\u00f6glichen.", + "title": "SHC Authentifizierungsparameter" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/en.json b/homeassistant/components/bosch_shc/translations/en.json new file mode 100644 index 0000000000000..65f675e2f4d6a --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/en.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "pairing_failed": "Pairing failed; please check the Bosch Smart Home Controller is in pairing mode (LED flashing) as well as your password is correct.", + "session_error": "Session error: API return Non-OK result.", + "unknown": "Unexpected error" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Please press the Bosch Smart Home Controller's front-side button until LED starts flashing.\nReady to continue to set up {model} @ {host} with Home Assistant?" + }, + "credentials": { + "data": { + "password": "Password of the Smart Home Controller" + } + }, + "reauth_confirm": { + "description": "The bosch_shc integration needs to re-authenticate your account", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Set up your Bosch Smart Home Controller to allow monitoring and control with Home Assistant.", + "title": "SHC authentication parameters" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/es-419.json b/homeassistant/components/bosch_shc/translations/es-419.json new file mode 100644 index 0000000000000..fdb8903a31885 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "Presione el bot\u00f3n frontal del Controlador de Hogar Inteligente de Bosch hasta que el LED comience a parpadear.\n\u00bfListo para continuar configurando {model} @ {host} con Home Assistant?" + }, + "credentials": { + "data": { + "password": "Contrase\u00f1a del controlador Smart Home" + } + }, + "reauth_confirm": { + "description": "La integraci\u00f3n bosch_shc necesita volver a autenticar su cuenta" + }, + "user": { + "description": "Configure su controlador de hogar inteligente de Bosch para permitir la supervisi\u00f3n y el control con Home Assistant.", + "title": "Par\u00e1metros de autenticaci\u00f3n SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/es.json b/homeassistant/components/bosch_shc/translations/es.json new file mode 100644 index 0000000000000..df180029c55fd --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/es.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "pairing_failed": "El emparejamiento ha fallado; compruebe que el Bosch Smart Home Controller est\u00e1 en modo de emparejamiento (el LED parpadea) y que su contrase\u00f1a es correcta.", + "session_error": "Error de sesi\u00f3n: La API devuelve un resultado no correcto.", + "unknown": "Error inesperado" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Pulse el bot\u00f3n frontal del Smart Home Controller de Bosch hasta que el LED empiece a parpadear.\n\u00bfPreparado para seguir configurando {model} @ {host} con Home Assistant?" + }, + "credentials": { + "data": { + "password": "Contrase\u00f1a del controlador smart home" + } + }, + "reauth_confirm": { + "description": "La integraci\u00f3n bosch_shc necesita volver a autentificar su cuenta", + "title": "Volver a autenticar la integraci\u00f3n" + }, + "user": { + "data": { + "host": "Anfitri\u00f3n" + }, + "description": "Configura tu Bosch Smart Home Controller para permitir la supervisi\u00f3n y el control con Home Assistant.", + "title": "Par\u00e1metros de autenticaci\u00f3n SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/et.json b/homeassistant/components/bosch_shc/translations/et.json new file mode 100644 index 0000000000000..cb095bb9de7d9 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/et.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise viga", + "pairing_failed": "Sidumine nurjus; palun kontrolli kas Bosch Smart Home Controller on sidumisre\u017eiimis (LED vilgub) ning kas salas\u00f5na on \u00f5ige.", + "session_error": "Seansi viga: API tagastas veateate.", + "unknown": "Tundmatu viga" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Vajuta palun Bosch Smart Home Controlleri esik\u00fclje nuppu kuni LED hakkab vilkuma.\n Kas oled valmis j\u00e4tkama {model} @ {host} seadistamist Home Assistanti abil?" + }, + "credentials": { + "data": { + "password": "Smart Home kontrolleri salas\u00f5na" + } + }, + "reauth_confirm": { + "description": "Bosch_shc sidumine peab konto uuesti autentima.", + "title": "Taastuvastamine" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Seadista oma Bosch Smart Home Controller, et v\u00f5imaldada j\u00e4lgimist ja juhtimist Home Assistantiga.", + "title": "SHC autentimisparameetrid" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/fr.json b/homeassistant/components/bosch_shc/translations/fr.json new file mode 100644 index 0000000000000..43eeb04490d57 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/fr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "pairing_failed": "L'appairage a \u00e9chou\u00e9\u00a0; veuillez v\u00e9rifier que le Bosch Smart Home Controller est en mode d'appairage (voyant clignotant) et que votre mot de passe est correct.", + "session_error": "Erreur de session\u00a0: l'API renvoie un r\u00e9sultat non-OK.", + "unknown": "Erreur inattendue" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Veuillez appuyer sur le bouton situ\u00e9 \u00e0 l'avant du Bosch Smart Home Controller jusqu'\u00e0 ce que le voyant commence \u00e0 clignoter.\n Pr\u00eat \u00e0 continuer \u00e0 configurer {model} @ {host} avec Home Assistant\u00a0?" + }, + "credentials": { + "data": { + "password": "Mot de passe du contr\u00f4leur Smart Home" + } + }, + "reauth_confirm": { + "description": "L'int\u00e9gration bosch_shc doit r\u00e9-authentifier votre compte", + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurez votre Bosch Smart Home Controller pour permettre la surveillance et le contr\u00f4le avec Home Assistant.", + "title": "Param\u00e8tres d'authentification SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/he.json b/homeassistant/components/bosch_shc/translations/he.json new file mode 100644 index 0000000000000..f7b240ce079a1 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/he.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "pairing_failed": "\u05d4\u05e9\u05d9\u05d5\u05da \u05e0\u05db\u05e9\u05dc; \u05e0\u05d0 \u05d1\u05d3\u05d5\u05e7 \u05e9\u05d1\u05e7\u05e8 \u05d4\u05d1\u05d9\u05ea \u05d4\u05d7\u05db\u05dd \u05e9\u05dc Bosch \u05e0\u05de\u05e6\u05d0 \u05d1\u05de\u05e6\u05d1 \u05e9\u05d9\u05d5\u05da (\u05de\u05d4\u05d1\u05d4\u05d1 LED) \u05db\u05de\u05d5 \u05d2\u05dd \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05e0\u05db\u05d5\u05e0\u05d4.", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "confirm_discovery": { + "description": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05e6\u05d3 \u05d4\u05e7\u05d3\u05de\u05d9 \u05e9\u05dc \u05d1\u05e7\u05e8\u05ea \u05d4\u05d1\u05d9\u05ea \u05d4\u05d7\u05db\u05dd \u05e9\u05dc Bosch \u05e2\u05d3 \u05e9\u05d4\u05e0\u05d5\u05e8\u05d9\u05ea \u05ea\u05ea\u05d7\u05d9\u05dc \u05dc\u05d4\u05d1\u05d4\u05d1.\n \u05de\u05d5\u05db\u05df \u05dc\u05d4\u05de\u05e9\u05d9\u05da \u05d5\u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} @ {host} \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea Home Assistant?" + }, + "credentials": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05ea \u05d1\u05e7\u05e8 \u05d4\u05d1\u05d9\u05ea \u05d4\u05d7\u05db\u05dd" + } + }, + "reauth_confirm": { + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/hu.json b/homeassistant/components/bosch_shc/translations/hu.json new file mode 100644 index 0000000000000..b3e0ed778150b --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/hu.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "pairing_failed": "A p\u00e1ros\u00edt\u00e1s nem siker\u00fclt; K\u00e9rj\u00fck, ellen\u0151rizze, hogy a Bosch Smart Home Controller p\u00e1ros\u00edt\u00e1si m\u00f3dban van-e (villog a LED), \u00e9s hogy a jelszava helyes-e.", + "session_error": "Munkamenet hiba: Az API nem OK eredm\u00e9nyt ad vissza.", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "K\u00e9rj\u00fck, addig nyomja a Bosch Smart Home Controller el\u00fcls\u0151 gombj\u00e1t, am\u00edg a LED villogni nem kezd.\nK\u00e9szen \u00e1ll {model} @ {host} be\u00e1ll\u00edt\u00e1s\u00e1nak folytat\u00e1s\u00e1ra Home Assistant seg\u00edts\u00e9g\u00e9vel?" + }, + "credentials": { + "data": { + "password": "A Smart Home Controller jelszava" + } + }, + "reauth_confirm": { + "description": "A bosch_shc integr\u00e1ci\u00f3nak \u00fajra hiteles\u00edtenie kell a fi\u00f3kj\u00e1t", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "\u00c1ll\u00edtsa be a Bosch intelligens otthoni vez\u00e9rl\u0151t, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s a vez\u00e9rl\u00e9st Home Assistant seg\u00edts\u00e9g\u00e9vel.", + "title": "SHC hiteles\u00edt\u00e9si param\u00e9terek" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/id.json b/homeassistant/components/bosch_shc/translations/id.json new file mode 100644 index 0000000000000..723c6bdabbe0d --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/id.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "pairing_failed": "Pemasangan gagal; periksa apakah Bosch Smart Home Controller dalam mode pemasangan (LED berkedip) dan kata sandi Anda benar.", + "session_error": "Kesalahan sesi: API mengembalikan hasil Non-OK.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Tekan tombol sisi depan Bosch Smart Home Controller hingga LED mulai berkedip.\nSiap melanjutkan penyiapan {model} @ {host} dengan Home Assistant?" + }, + "credentials": { + "data": { + "password": "Kata Sandi dari Smart Home Controller" + } + }, + "reauth_confirm": { + "description": "Integrasi bosch_shc perlu mengautentikasi ulang akun Anda", + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Siapkan Bosch Smart Home Controller Anda untuk memungkinkan pemantauan dan kontrol dengan Home Assistant.", + "title": "Parameter autentikasi SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/it.json b/homeassistant/components/bosch_shc/translations/it.json new file mode 100644 index 0000000000000..2c64beee59cd0 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/it.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "pairing_failed": "Associazione non riuscita; verifica che il controller Bosch Smart Home sia in modalit\u00e0 di associazione (LED lampeggiante) e che la password sia corretta.", + "session_error": "Errore di sessione: l'API restituisce il risultato Non-OK.", + "unknown": "Errore imprevisto" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Premi il pulsante sul lato anteriore del controller Bosch Smart Home finch\u00e9 il LED non inizia a lampeggiare.\nSei pronto per continuare a configurare {model} @ {host} con Home Assistant?" + }, + "credentials": { + "data": { + "password": "Password del controller Smart Home" + } + }, + "reauth_confirm": { + "description": "L'integrazione bosch_shc deve autenticare nuovamente il tuo account", + "title": "Autentica nuovamente l'integrazione" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Configura il tuo Bosch Smart Home Controller per consentire il monitoraggio e il controllo con Home Assistant.", + "title": "Parametri di autenticazione SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/ja.json b/homeassistant/components/bosch_shc/translations/ja.json new file mode 100644 index 0000000000000..5146f81301c22 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/ja.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "pairing_failed": "\u30da\u30a2\u30ea\u30f3\u30b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002Bosch Smart Home Controller\u304c\u30da\u30a2\u30ea\u30f3\u30b0\u30e2\u30fc\u30c9\u306b\u306a\u3063\u3066\u3044\u308b(LED\u304c\u70b9\u6ec5)\u3053\u3068\u3068\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u3044\u304b\u3069\u3046\u304b\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "session_error": "\u30bb\u30c3\u30b7\u30e7\u30f3\u30a8\u30e9\u30fc: API\u304c\u3001OK\u4ee5\u5916\u306e\u7d50\u679c\u3092\u8fd4\u3057\u307e\u3059\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "LED\u304c\u70b9\u6ec5\u3057\u59cb\u3081\u308b\u307e\u3067\u3001Bosch Smart Home Controller\u306e\u524d\u9762\u306b\u3042\u308b\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002\nHome Assistant\u3067\u3001{model} @ {host} \u3092\u8a2d\u5b9a\u3059\u308b\u6e96\u5099\u306f\u3067\u304d\u307e\u3057\u305f\u304b\uff1f" + }, + "credentials": { + "data": { + "password": "Smart Home Controller\u306e\u30d1\u30b9\u30ef\u30fc\u30c9" + } + }, + "reauth_confirm": { + "description": "bosch_shc\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Bosch Smart Home Controller\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3067\u76e3\u8996\u304a\u3088\u3073\u5236\u5fa1\u3067\u304d\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "SHC\u8a8d\u8a3c\u30d1\u30e9\u30e1\u30fc\u30bf\u30fc" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/nl.json b/homeassistant/components/bosch_shc/translations/nl.json new file mode 100644 index 0000000000000..8a6cbd6cfddae --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/nl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "pairing_failed": "Koppelen mislukt; Controleer of de Bosch Smart Home Controller zich in de koppelingsmodus bevindt (LED knippert) en of uw wachtwoord correct is.", + "session_error": "Sessiefout: API retourneert niet-OK resultaat.", + "unknown": "Onverwachte fout" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Druk op de knop aan de voorzijde van de Bosch Smart Home Controller totdat de LED begint te knipperen.\nKlaar om verder te gaan met het instellen van {model} @ {host} met Home Assistant?" + }, + "credentials": { + "data": { + "password": "Wachtwoord van de Smart Home Controller" + } + }, + "reauth_confirm": { + "description": "De bosch_shc integratie moet uw account herauthenticeren", + "title": "Verifieer de integratie opnieuw" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Stel uw Bosch Smart Home Controller in om monitoring en bediening met Home Assistant mogelijk te maken.", + "title": "SHC-authenticatieparameters" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/no.json b/homeassistant/components/bosch_shc/translations/no.json new file mode 100644 index 0000000000000..53d64519fd5fd --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/no.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "pairing_failed": "Paringen mislyktes. Kontroller at Bosch Smart Home Controller er i sammenkoblingsmodus (LED blinker) s\u00e5 vel som passordet ditt er riktig.", + "session_error": "\u00d8ktfeil: API returnerer ikke OK-resultat.", + "unknown": "Uventet feil" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Trykk p\u00e5 Bosch Smart Home Controller-frontknappen til LED-lampen begynner \u00e5 blinke.\n Klar til \u00e5 fortsette \u00e5 konfigurere {model} @ {host} med Home Assistant?" + }, + "credentials": { + "data": { + "password": "Passordet til Smart Home Controller" + } + }, + "reauth_confirm": { + "description": "Bosch_shc-integrasjonen m\u00e5 godkjenne kontoen din p\u00e5 nytt", + "title": "Godkjenne integrering p\u00e5 nytt" + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Sett opp din Bosch Smart Home Controller for \u00e5 tillate overv\u00e5king og kontroll med Home Assistant.", + "title": "SHC-autentiseringsparametere" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/pl.json b/homeassistant/components/bosch_shc/translations/pl.json new file mode 100644 index 0000000000000..6b21fd87b7c3f --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/pl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "pairing_failed": "Parowanie nie powiod\u0142o si\u0119. Sprawd\u017a, czy kontroler Bosch Smart Home jest w trybie parowania (miga dioda LED) i czy Twoje has\u0142o jest prawid\u0142owe.", + "session_error": "B\u0142\u0105d sesji: API zwr\u00f3ci\u0142o niepoprawny wynik.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Naci\u015bnij przycisk z przodu kontrolera Bosch Smart Home, a\u017c dioda LED zacznie miga\u0107. Chcesz kontynuowa\u0107 konfiguracj\u0119 {model} @ {host} z Home Assistantem?" + }, + "credentials": { + "data": { + "password": "Has\u0142o" + } + }, + "reauth_confirm": { + "description": "Integracja Bosch_SHC wymaga ponownego uwierzytelnienia Twojego konta", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "description": "Skonfiguruj kontroler Bosch Smart Home, aby umo\u017cliwi\u0107 monitorowanie i sterowanie za pomoc\u0105 Home Assistanta.", + "title": "Parametry uwierzytelniania SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/ru.json b/homeassistant/components/bosch_shc/translations/ru.json new file mode 100644 index 0000000000000..498003b2501d4 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/ru.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "pairing_failed": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440 Bosch Smart Home Controller \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f (\u0441\u0432\u0435\u0442\u043e\u0434\u0438\u043e\u0434\u043d\u044b\u0439 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440 \u043c\u0438\u0433\u0430\u0435\u0442) \u0438 \u0447\u0442\u043e \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439.", + "session_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0430\u043d\u0441\u0430: API \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u0442 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 Non-OK.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "\u0423\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u043f\u0435\u0440\u0435\u0434\u043d\u0435\u0439 \u043f\u0430\u043d\u0435\u043b\u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430, \u043f\u043e\u043a\u0430 \u0441\u0432\u0435\u0442\u043e\u0434\u0438\u043e\u0434\u043d\u044b\u0435 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u043d\u0435 \u043d\u0430\u0447\u043d\u0443\u0442 \u043c\u0438\u0433\u0430\u0442\u044c.\n\u0413\u043e\u0442\u043e\u0432\u044b \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 {model} @ {host}?" + }, + "credentials": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c Smart Home Controller" + } + }, + "reauth_confirm": { + "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Bosch SHC", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Bosch Smart Home Controller.", + "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/tr.json b/homeassistant/components/bosch_shc/translations/tr.json new file mode 100644 index 0000000000000..f48286cf9cdaf --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/tr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "pairing_failed": "E\u015fle\u015ftirme ba\u015far\u0131s\u0131z; l\u00fctfen Bosch Ak\u0131ll\u0131 Ev Kumandas\u0131n\u0131n e\u015fle\u015ftirme modunda (LED yan\u0131p s\u00f6n\u00fcyor) ve \u015fifrenizin do\u011fru oldu\u011funu kontrol edin.", + "session_error": "Oturum hatas\u0131: API, Tamam Olmayan sonucu d\u00f6nd\u00fcr\u00fcr.", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "L\u00fctfen LED yan\u0131p s\u00f6nmeye ba\u015flayana kadar Bosch Ak\u0131ll\u0131 Ev Denetleyicisinin \u00f6n taraf\u0131ndaki d\u00fc\u011fmeye bas\u0131n.\n Home Assistant ile {model} @ {host} kurulumuna devam etmeye haz\u0131r m\u0131s\u0131n\u0131z?" + }, + "credentials": { + "data": { + "password": "Ak\u0131ll\u0131 Ev Denetleyicisinin \u015eifresi" + } + }, + "reauth_confirm": { + "description": "bosch_shc entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "host": "Sunucu" + }, + "description": "Ev Asistan\u0131 ile izleme ve kontrole izin vermek i\u00e7in Bosch Ak\u0131ll\u0131 Ev Denetleyicinizi kurun.", + "title": "SHC kimlik do\u011frulama parametreleri" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/zh-Hans.json b/homeassistant/components/bosch_shc/translations/zh-Hans.json new file mode 100644 index 0000000000000..46682f56114f2 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "pairing_failed": "\u914d\u5bf9\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u535a\u4e16 Smart Home Controller \u662f\u5426\u6b63\u5728\u5904\u4e8e\u914d\u5bf9\u6a21\u5f0f(LED \u706f\u95ea\u70c1)\uff0c\u4ee5\u53ca\u952e\u5165\u7684\u5bc6\u7801\u662f\u5426\u6b63\u786e" + }, + "step": { + "credentials": { + "data": { + "password": "Smart Home Controller \u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/zh-Hant.json b/homeassistant/components/bosch_shc/translations/zh-Hant.json new file mode 100644 index 0000000000000..fd667210d3e15 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/zh-Hant.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "pairing_failed": "\u914d\u5c0d\u5931\u6557\uff1a\u8acb\u78ba\u8a8d Bosch Smart Home Controller \u5df2\u7d93\u8655\u65bc\u914d\u5c0d\u6a21\u5f0f\uff08LED \u9583\u720d\uff09\u3001\u4e26\u4e14\u5bc6\u78bc\u8f38\u5165\u6b63\u78ba\u3002", + "session_error": "Session \u932f\u8aa4\uff1aAPI \u56de\u8986\u975e\u6b63\u5e38\u7d50\u679c\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "\u8acb\u6309\u4e0b Bosch Smart Home Controller \u524d\u65b9\u7684\u6309\u9215\u76f4\u5230 LED \u9583\u720d\u3002\n\u662f\u5426\u8981\u7e7c\u7e8c\u8a2d\u5b9a\u4f4d\u65bc {host} \u7684 {model} \u9023\u63a5\u81f3 Home Assistant\uff1f" + }, + "credentials": { + "data": { + "password": "Smart Home Controller \u5bc6\u78bc" + } + }, + "reauth_confirm": { + "description": "bosch_shc \u6574\u5408\u9700\u8981\u91cd\u65b0\u8a8d\u8b49\u60a8\u7684\u5e33\u865f", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8a2d\u5b9a Bosch Smart Home Controller \u4ee5\u5141\u8a31 Home Assistant \u9032\u884c\u76e3\u63a7\u3002", + "title": "SHC \u8a8d\u8b49\u53c3\u6578" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 0097964e29875..38dbc4f0ebc77 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -1,46 +1,287 @@ """The Bravia TV component.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from datetime import timedelta +import logging +from typing import Final from bravia_tv import BraviaRC +from bravia_tv.braviarc import NoIPControl + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.const import CONF_HOST, CONF_MAC +from .const import CLIENTID_PREFIX, CONF_IGNORED_SOURCES, DOMAIN, NICKNAME -from .const import BRAVIARC, DOMAIN, UNDO_UPDATE_LISTENER +_LOGGER = logging.getLogger(__name__) -PLATFORMS = ["media_player"] +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER, Platform.REMOTE] +SCAN_INTERVAL: Final = timedelta(seconds=10) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a config entry.""" host = config_entry.data[CONF_HOST] mac = config_entry.data[CONF_MAC] + pin = config_entry.data[CONF_PIN] + ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES, []) + + coordinator = BraviaTVCoordinator(hass, host, mac, pin, ignored_sources) + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - undo_listener = config_entry.add_update_listener(update_listener) + await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = { - BRAVIARC: BraviaRC(host, mac), - UNDO_UPDATE_LISTENER: undo_listener, - } + hass.data[DOMAIN][config_entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() - if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok -async def update_listener(hass, config_entry): +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) + + +class BraviaTVCoordinator(DataUpdateCoordinator[None]): + """Representation of a Bravia TV Coordinator. + + An instance is used per device to share the same power state between + several platforms. + """ + + def __init__( + self, + hass: HomeAssistant, + host: str, + mac: str, + pin: str, + ignored_sources: list[str], + ) -> None: + """Initialize Bravia TV Client.""" + + self.braviarc = BraviaRC(host, mac) + self.pin = pin + self.ignored_sources = ignored_sources + self.muted: bool = False + self.channel_name: str | None = None + self.media_title: str | None = None + self.source: str | None = None + self.source_list: list[str] = [] + self.original_content_list: list[str] = [] + self.content_mapping: dict[str, str] = {} + self.duration: int | None = None + self.content_uri: str | None = None + self.program_media_type: str | None = None + self.audio_output: str | None = None + self.min_volume: int | None = None + self.max_volume: int | None = None + self.volume_level: float | None = None + self.is_on = False + # Assume that the TV is in Play mode + self.playing = True + self.state_lock = asyncio.Lock() + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=1.0, immediate=False + ), + ) + + def _send_command(self, command: str, repeats: int = 1) -> None: + """Send a command to the TV.""" + for _ in range(repeats): + for cmd in command: + self.braviarc.send_command(cmd) + + def _get_source(self) -> str | None: + """Return the name of the source.""" + for key, value in self.content_mapping.items(): + if value == self.content_uri: + return key + return None + + def _refresh_volume(self) -> bool: + """Refresh volume information.""" + volume_info = self.braviarc.get_volume_info(self.audio_output) + if volume_info is not None: + volume = volume_info.get("volume") + self.volume_level = volume / 100 if volume is not None else None + self.audio_output = volume_info.get("target") + self.min_volume = volume_info.get("minVolume") + self.max_volume = volume_info.get("maxVolume") + self.muted = volume_info.get("mute", False) + return True + return False + + def _refresh_channels(self) -> bool: + """Refresh source and channels list.""" + if not self.source_list: + self.content_mapping = self.braviarc.load_source_list() + self.source_list = [] + if not self.content_mapping: + return False + for key in self.content_mapping: + if key not in self.ignored_sources: + self.source_list.append(key) + return True + + def _refresh_playing_info(self) -> None: + """Refresh playing information.""" + playing_info = self.braviarc.get_playing_info() + program_name = playing_info.get("programTitle") + self.channel_name = playing_info.get("title") + self.program_media_type = playing_info.get("programMediaType") + self.content_uri = playing_info.get("uri") + self.source = self._get_source() + self.duration = playing_info.get("durationSec") + if not playing_info: + self.channel_name = "App" + if self.channel_name is not None: + self.media_title = self.channel_name + if program_name is not None: + self.media_title = f"{self.media_title}: {program_name}" + else: + self.media_title = None + + def _update_tv_data(self) -> None: + """Connect and update TV info.""" + power_status = self.braviarc.get_power_status() + + if power_status != "off": + connected = self.braviarc.is_connected() + if not connected: + try: + connected = self.braviarc.connect( + self.pin, CLIENTID_PREFIX, NICKNAME + ) + except NoIPControl: + _LOGGER.error("IP Control is disabled in the TV settings") + if not connected: + power_status = "off" + + if power_status == "active": + self.is_on = True + if self._refresh_volume() and self._refresh_channels(): + self._refresh_playing_info() + return + + self.is_on = False + + async def _async_update_data(self) -> None: + """Fetch the latest data.""" + if self.state_lock.locked(): + return + + await self.hass.async_add_executor_job(self._update_tv_data) + + async def async_turn_on(self) -> None: + """Turn the device on.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.turn_on) + await self.async_request_refresh() + + async def async_turn_off(self) -> None: + """Turn off device.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.turn_off) + await self.async_request_refresh() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + async with self.state_lock: + await self.hass.async_add_executor_job( + self.braviarc.set_volume_level, volume, self.audio_output + ) + await self.async_request_refresh() + + async def async_volume_up(self) -> None: + """Send volume up command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job( + self.braviarc.volume_up, self.audio_output + ) + await self.async_request_refresh() + + async def async_volume_down(self) -> None: + """Send volume down command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job( + self.braviarc.volume_down, self.audio_output + ) + await self.async_request_refresh() + + async def async_volume_mute(self, mute: bool) -> None: + """Send mute command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.mute_volume, mute) + await self.async_request_refresh() + + async def async_media_play(self) -> None: + """Send play command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.media_play) + self.playing = True + await self.async_request_refresh() + + async def async_media_pause(self) -> None: + """Send pause command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.media_pause) + self.playing = False + await self.async_request_refresh() + + async def async_media_stop(self) -> None: + """Send stop command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.media_stop) + self.playing = False + await self.async_request_refresh() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.media_next_track) + await self.async_request_refresh() + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.media_previous_track) + await self.async_request_refresh() + + async def async_select_source(self, source: str) -> None: + """Set the input source.""" + if source in self.content_mapping: + uri = self.content_mapping[source] + async with self.state_lock: + await self.hass.async_add_executor_job(self.braviarc.play_content, uri) + await self.async_request_refresh() + + async def async_send_command(self, command: Iterable[str], repeats: int) -> None: + """Send command to device.""" + async with self.state_lock: + await self.hass.async_add_executor_job(self._send_command, command, repeats) + await self.async_request_refresh() diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 02856887d1739..8e59033ffc827 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -1,39 +1,40 @@ """Adds config flow for Bravia TV integration.""" +from __future__ import annotations + +from contextlib import suppress import ipaddress -import logging import re +from typing import Any from bravia_tv import BraviaRC from bravia_tv.braviarc import NoIPControl import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from .const import ( ATTR_CID, ATTR_MAC, ATTR_MODEL, - BRAVIARC, CLIENTID_PREFIX, CONF_IGNORED_SOURCES, DOMAIN, NICKNAME, ) -_LOGGER = logging.getLogger(__name__) - -def host_valid(host): +def host_valid(host: str) -> bool: """Return True if hostname or IP address is valid.""" - try: + with suppress(ValueError): if ipaddress.ip_address(host).version in [4, 6]: return True - except ValueError: - disallowed = re.compile(r"[^a-zA-Z\d\-]") - return all(x and not disallowed.search(x) for x in host.split(".")) + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -41,15 +42,16 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self.braviarc = None - self.host = None - self.title = None - self.mac = None + self.braviarc: BraviaRC | None = None + self.host: str | None = None + self.title = "" + self.mac: str | None = None - async def init_device(self, pin): + async def init_device(self, pin: str) -> None: """Initialize Bravia TV device.""" + assert self.braviarc is not None await self.hass.async_add_executor_job( self.braviarc.connect, pin, CLIENTID_PREFIX, NICKNAME ) @@ -72,34 +74,15 @@ async def init_device(self, pin): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> BraviaTVOptionsFlowHandler: """Bravia TV options callback.""" return BraviaTVOptionsFlowHandler(config_entry) - async def async_step_import(self, user_input=None): - """Handle configuration by yaml file.""" - self.host = user_input[CONF_HOST] - self.braviarc = BraviaRC(self.host) - - try: - await self.init_device(user_input[CONF_PIN]) - except CannotConnect: - _LOGGER.error("Import aborted, cannot connect to %s", self.host) - return self.async_abort(reason="cannot_connect") - except NoIPControl: - _LOGGER.error("IP Control is disabled in the TV settings") - return self.async_abort(reason="no_ip_control") - except ModelNotSupported: - _LOGGER.error("Import aborted, your TV is not supported") - return self.async_abort(reason="unsupported_model") - - user_input[CONF_MAC] = self.mac - - return self.async_create_entry(title=self.title, data=user_input) - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: if host_valid(user_input[CONF_HOST]): @@ -116,9 +99,11 @@ async def async_step_user(self, user_input=None): errors=errors, ) - async def async_step_authorize(self, user_input=None): + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Get PIN from the Bravia TV device.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: @@ -131,9 +116,9 @@ async def async_step_authorize(self, user_input=None): user_input[CONF_HOST] = self.host user_input[CONF_MAC] = self.mac return self.async_create_entry(title=self.title, data=user_input) - # Connecting with th PIN "0000" to start the pairing process on the TV. try: + assert self.braviarc is not None await self.hass.async_add_executor_job( self.braviarc.connect, "0000", CLIENTID_PREFIX, NICKNAME ) @@ -150,30 +135,34 @@ async def async_step_authorize(self, user_input=None): class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for Bravia TV.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Bravia TV options flow.""" - self.braviarc = None self.config_entry = config_entry self.pin = config_entry.data[CONF_PIN] self.ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES) - self.source_list = [] + self.source_list: dict[str, str] = {} - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" - self.braviarc = self.hass.data[DOMAIN][self.config_entry.entry_id][BRAVIARC] - connected = await self.hass.async_add_executor_job(self.braviarc.is_connected) + coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id] + braviarc = coordinator.braviarc + connected = await self.hass.async_add_executor_job(braviarc.is_connected) if not connected: await self.hass.async_add_executor_job( - self.braviarc.connect, self.pin, CLIENTID_PREFIX, NICKNAME + braviarc.connect, self.pin, CLIENTID_PREFIX, NICKNAME ) content_mapping = await self.hass.async_add_executor_job( - self.braviarc.load_source_list + braviarc.load_source_list ) - self.source_list = [*content_mapping] + self.source_list = {item: item for item in content_mapping} return await self.async_step_user() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index a5d7a88d4c3e4..01746cbe963ff 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -1,15 +1,17 @@ """Constants for Bravia TV integration.""" -ATTR_CID = "cid" -ATTR_MAC = "macAddr" -ATTR_MANUFACTURER = "Sony" -ATTR_MODEL = "model" - -CONF_IGNORED_SOURCES = "ignored_sources" - -BRAVIARC = "braviarc" -BRAVIA_CONFIG_FILE = "bravia.conf" -CLIENTID_PREFIX = "HomeAssistant" -DEFAULT_NAME = f"{ATTR_MANUFACTURER} Bravia TV" -DOMAIN = "braviatv" -NICKNAME = "Home Assistant" -UNDO_UPDATE_LISTENER = "undo_update_listener" +from __future__ import annotations + +from typing import Final + +ATTR_CID: Final = "cid" +ATTR_MAC: Final = "macAddr" +ATTR_MANUFACTURER: Final = "Sony" +ATTR_MODEL: Final = "model" + +CONF_IGNORED_SOURCES: Final = "ignored_sources" + +BRAVIA_CONFIG_FILE: Final = "bravia.conf" +CLIENTID_PREFIX: Final = "HomeAssistant" +DEFAULT_NAME: Final = f"{ATTR_MANUFACTURER} Bravia TV" +DOMAIN: Final = "braviatv" +NICKNAME: Final = "Home Assistant" diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index c3fcf218e9a6a..18285ebec0014 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -2,8 +2,8 @@ "domain": "braviatv", "name": "Sony Bravia TV", "documentation": "https://www.home-assistant.io/integrations/braviatv", - "requirements": ["bravia-tv==1.0.8"], - "codeowners": ["@bieniu"], + "requirements": ["bravia-tv==1.0.11"], + "codeowners": ["@bieniu", "@Drafteed"], "config_flow": true, "iot_class": "local_polling" } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 32a051f4e988a..69df0b245b8b2 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -1,13 +1,10 @@ """Support for interface with a Bravia TV.""" -import asyncio -import logging +from __future__ import annotations -from bravia_tv.braviarc import NoIPControl -import voluptuous as vol +from typing import Final from homeassistant.components.media_player import ( - DEVICE_CLASS_TV, - PLATFORM_SCHEMA, + MediaPlayerDeviceClass, MediaPlayerEntity, ) from homeassistant.components.media_player.const import ( @@ -23,25 +20,17 @@ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, STATE_OFF, STATE_ON -import homeassistant.helpers.config_validation as cv -from homeassistant.util.json import load_json - -from .const import ( - ATTR_MANUFACTURER, - BRAVIA_CONFIG_FILE, - BRAVIARC, - CLIENTID_PREFIX, - CONF_IGNORED_SOURCES, - DEFAULT_NAME, - DOMAIN, - NICKNAME, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) +from . import BraviaTVCoordinator +from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN -SUPPORT_BRAVIA = ( +SUPPORT_BRAVIA: Final = ( SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE @@ -55,312 +44,137 @@ | SUPPORT_STOP ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Bravia TV platform.""" - host = config[CONF_HOST] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Bravia TV Media Player from a config_entry.""" - bravia_config_file_path = hass.config.path(BRAVIA_CONFIG_FILE) - bravia_config = await hass.async_add_executor_job( - load_json, bravia_config_file_path - ) - if not bravia_config: - _LOGGER.error( - "Configuration import failed, there is no bravia.conf file in the configuration folder" - ) - return - - while bravia_config: - # Import a configured TV - host_ip, host_config = bravia_config.popitem() - if host_ip == host: - pin = host_config[CONF_PIN] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: host, CONF_PIN: pin}, - ) - ) - return - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Add BraviaTV entities from a config_entry.""" - ignored_sources = [] - pin = config_entry.data[CONF_PIN] + coordinator = hass.data[DOMAIN][config_entry.entry_id] unique_id = config_entry.unique_id - device_info = { - "identifiers": {(DOMAIN, unique_id)}, - "name": DEFAULT_NAME, - "manufacturer": ATTR_MANUFACTURER, - "model": config_entry.title, - } - - braviarc = hass.data[DOMAIN][config_entry.entry_id][BRAVIARC] - - ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES, []) + assert unique_id is not None + device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + model=config_entry.title, + name=DEFAULT_NAME, + ) async_add_entities( - [ - BraviaTVDevice( - braviarc, DEFAULT_NAME, pin, unique_id, device_info, ignored_sources - ) - ] + [BraviaTVMediaPlayer(coordinator, DEFAULT_NAME, unique_id, device_info)] ) -class BraviaTVDevice(MediaPlayerEntity): - """Representation of a Bravia TV.""" - - def __init__(self, client, name, pin, unique_id, device_info, ignored_sources): - """Initialize the Bravia TV device.""" - - self._pin = pin - self._braviarc = client - self._name = name - self._state = STATE_OFF - self._muted = False - self._program_name = None - self._channel_name = None - self._channel_number = None - self._source = None - self._source_list = [] - self._original_content_list = [] - self._content_mapping = {} - self._duration = None - self._content_uri = None - self._playing = False - self._start_date_time = None - self._program_media_type = None - self._min_volume = None - self._max_volume = None - self._volume = None - self._unique_id = unique_id - self._device_info = device_info - self._ignored_sources = ignored_sources - self._state_lock = asyncio.Lock() - - async def async_update(self): - """Update TV info.""" - if self._state_lock.locked(): - return - - power_status = await self.hass.async_add_executor_job( - self._braviarc.get_power_status - ) - - if power_status != "off": - connected = await self.hass.async_add_executor_job( - self._braviarc.is_connected - ) - if not connected: - try: - connected = await self.hass.async_add_executor_job( - self._braviarc.connect, self._pin, CLIENTID_PREFIX, NICKNAME - ) - except NoIPControl: - _LOGGER.error("IP Control is disabled in the TV settings") - if not connected: - power_status = "off" - - if power_status == "active": - self._state = STATE_ON - if ( - await self._async_refresh_volume() - and await self._async_refresh_channels() - ): - await self._async_refresh_playing_info() - return - self._state = STATE_OFF - - def _get_source(self): - """Return the name of the source.""" - for key, value in self._content_mapping.items(): - if value == self._content_uri: - return key - - async def _async_refresh_volume(self): - """Refresh volume information.""" - volume_info = await self.hass.async_add_executor_job( - self._braviarc.get_volume_info - ) - if volume_info is not None: - self._volume = volume_info.get("volume") - self._min_volume = volume_info.get("minVolume") - self._max_volume = volume_info.get("maxVolume") - self._muted = volume_info.get("mute") - return True - return False - - async def _async_refresh_channels(self): - """Refresh source and channels list.""" - if not self._source_list: - self._content_mapping = await self.hass.async_add_executor_job( - self._braviarc.load_source_list - ) - self._source_list = [] - if not self._content_mapping: - return False - for key in self._content_mapping: - if key not in self._ignored_sources: - self._source_list.append(key) - return True - - async def _async_refresh_playing_info(self): - """Refresh Playing information.""" - playing_info = await self.hass.async_add_executor_job( - self._braviarc.get_playing_info - ) - self._program_name = playing_info.get("programTitle") - self._channel_name = playing_info.get("title") - self._program_media_type = playing_info.get("programMediaType") - self._channel_number = playing_info.get("dispNum") - self._content_uri = playing_info.get("uri") - self._source = self._get_source() - self._duration = playing_info.get("durationSec") - self._start_date_time = playing_info.get("startDateTime") - if not playing_info: - self._channel_name = "App" +class BraviaTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): + """Representation of a Bravia TV Media Player.""" - @property - def name(self): - """Return the name of the device.""" - return self._name + coordinator: BraviaTVCoordinator + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = SUPPORT_BRAVIA - @property - def device_class(self): - """Set the device class to TV.""" - return DEVICE_CLASS_TV + def __init__( + self, + coordinator: BraviaTVCoordinator, + name: str, + unique_id: str, + device_info: DeviceInfo, + ) -> None: + """Initialize the entity.""" - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id + self._attr_device_info = device_info + self._attr_name = name + self._attr_unique_id = unique_id - @property - def device_info(self): - """Return the device info.""" - return self._device_info + super().__init__(coordinator) @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" - return self._state + if self.coordinator.is_on: + return STATE_PLAYING if self.coordinator.playing else STATE_PAUSED + return STATE_OFF @property - def source(self): + def source(self) -> str | None: """Return the current input source.""" - return self._source + return self.coordinator.source @property - def source_list(self): + def source_list(self) -> list[str]: """List of available input sources.""" - return self._source_list + return self.coordinator.source_list @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._volume is not None: - return self._volume / 100 - return None + return self.coordinator.volume_level @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" - return self._muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_BRAVIA + return self.coordinator.muted @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" - return_value = None - if self._channel_name is not None: - return_value = self._channel_name - if self._program_name is not None: - return_value = f"{return_value}: {self._program_name}" - return return_value + return self.coordinator.media_title @property - def media_content_id(self): + def media_content_id(self) -> str | None: """Content ID of current playing media.""" - return self._channel_name + return self.coordinator.channel_name @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - return self._duration + return self.coordinator.duration - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self._braviarc.set_volume_level(volume) + async def async_turn_on(self) -> None: + """Turn the device on.""" + await self.coordinator.async_turn_on() - async def async_turn_on(self): - """Turn the media player on.""" - async with self._state_lock: - await self.hass.async_add_executor_job(self._braviarc.turn_on) + async def async_turn_off(self) -> None: + """Turn the device off.""" + await self.coordinator.async_turn_off() - async def async_turn_off(self): - """Turn off media player.""" - async with self._state_lock: - await self.hass.async_add_executor_job(self._braviarc.turn_off) + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.async_set_volume_level(volume) - def volume_up(self): - """Volume up the media player.""" - self._braviarc.volume_up() + async def async_volume_up(self) -> None: + """Send volume up command.""" + await self.coordinator.async_volume_up() - def volume_down(self): - """Volume down media player.""" - self._braviarc.volume_down() + async def async_volume_down(self) -> None: + """Send volume down command.""" + await self.coordinator.async_volume_down() - def mute_volume(self, mute): + async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" - self._braviarc.mute_volume(mute) + await self.coordinator.async_volume_mute(mute) - def select_source(self, source): + async def async_select_source(self, source: str) -> None: """Set the input source.""" - if source in self._content_mapping: - uri = self._content_mapping[source] - self._braviarc.play_content(uri) - - def media_play_pause(self): - """Simulate play pause media player.""" - if self._playing: - self.media_pause() - else: - self.media_play() - - def media_play(self): + await self.coordinator.async_select_source(source) + + async def async_media_play(self) -> None: """Send play command.""" - self._playing = True - self._braviarc.media_play() + await self.coordinator.async_media_play() - def media_pause(self): - """Send media pause command to media player.""" - self._playing = False - self._braviarc.media_pause() + async def async_media_pause(self) -> None: + """Send pause command.""" + await self.coordinator.async_media_pause() - def media_stop(self): + async def async_media_stop(self) -> None: """Send media stop command to media player.""" - self._playing = False - self._braviarc.media_stop() + await self.coordinator.async_media_stop() - def media_next_track(self): + async def async_media_next_track(self) -> None: """Send next track command.""" - self._braviarc.media_next_track() + await self.coordinator.async_media_next_track() - def media_previous_track(self): - """Send the previous track command.""" - self._braviarc.media_previous_track() + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.coordinator.async_media_previous_track() diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py new file mode 100644 index 0000000000000..7e01f26d0a519 --- /dev/null +++ b/homeassistant/components/braviatv/remote.py @@ -0,0 +1,76 @@ +"""Remote control support for Bravia TV.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import BraviaTVCoordinator +from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Bravia TV Remote from a config entry.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + unique_id = config_entry.unique_id + assert unique_id is not None + device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + model=config_entry.title, + name=DEFAULT_NAME, + ) + + async_add_entities( + [BraviaTVRemote(coordinator, DEFAULT_NAME, unique_id, device_info)] + ) + + +class BraviaTVRemote(CoordinatorEntity, RemoteEntity): + """Representation of a Bravia TV Remote.""" + + coordinator: BraviaTVCoordinator + + def __init__( + self, + coordinator: BraviaTVCoordinator, + name: str, + unique_id: str, + device_info: DeviceInfo, + ) -> None: + """Initialize the entity.""" + + self._attr_device_info = device_info + self._attr_name = name + self._attr_unique_id = unique_id + + super().__init__(coordinator) + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self.coordinator.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.coordinator.async_turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.coordinator.async_turn_off() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to device.""" + repeats = kwargs[ATTR_NUM_REPEATS] + await self.coordinator.async_send_command(command, repeats) diff --git a/homeassistant/components/braviatv/translations/bg.json b/homeassistant/components/braviatv/translations/bg.json new file mode 100644 index 0000000000000..d05511b8d29e9 --- /dev/null +++ b/homeassistant/components/braviatv/translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441", + "unsupported_model": "\u041c\u043e\u0434\u0435\u043b\u044a\u0442 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430." + }, + "step": { + "authorize": { + "data": { + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434" + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/da.json b/homeassistant/components/braviatv/translations/da.json new file mode 100644 index 0000000000000..006d6b708e59d --- /dev/null +++ b/homeassistant/components/braviatv/translations/da.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "authorize": { + "title": "Godkend Sony Bravia TV" + }, + "user": { + "title": "Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/de.json b/homeassistant/components/braviatv/translations/de.json index 7dfff8a1b440a..cca5c5aa47f34 100644 --- a/homeassistant/components/braviatv/translations/de.json +++ b/homeassistant/components/braviatv/translations/de.json @@ -7,21 +7,21 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", - "unsupported_model": "Ihr TV-Modell wird nicht unterst\u00fctzt." + "unsupported_model": "Dein TV-Modell wird nicht unterst\u00fctzt." }, "step": { "authorize": { "data": { "pin": "PIN-Code" }, - "description": "Geben Sie den auf dem Sony Bravia-Fernseher angezeigten PIN-Code ein. \n\nWenn der PIN-Code nicht angezeigt wird, m\u00fcssen Sie die Registrierung von Home Assistant auf Ihrem Fernseher aufheben, gehen Sie daf\u00fcr zu: Einstellungen -> Netzwerk -> Remote - Ger\u00e4teeinstellungen -> Registrierung des entfernten Ger\u00e4ts aufheben.", - "title": "Autorisieren Sie Sony Bravia TV" + "description": "Gib den auf dem Sony Bravia-Fernseher angezeigten PIN-Code ein. \n\nWenn der PIN-Code nicht angezeigt wird, musst du die Registrierung von Home Assistant auf deinem Fernseher aufheben, gehe daf\u00fcr zu: Einstellungen -> Netzwerk -> Remote - Ger\u00e4teeinstellungen -> Registrierung des entfernten Ger\u00e4ts aufheben.", + "title": "Autorisiere Sony Bravia TV" }, "user": { "data": { "host": "Host" }, - "description": "Richten Sie die Sony Bravia TV-Integration ein. Wenn Sie Probleme mit der Konfiguration haben, gehen Sie zu: https://www.home-assistant.io/integrations/braviatv \n\n Stellen Sie sicher, dass Ihr Fernseher eingeschaltet ist.", + "description": "Richte die Sony Bravia TV-Integration ein. Wenn du Probleme mit der Konfiguration hast, gehe zu: https://www.home-assistant.io/integrations/braviatv \n\nStelle sicher, dass dein Fernseher eingeschaltet ist.", "title": "Sony Bravia TV" } } diff --git a/homeassistant/components/braviatv/translations/en_GB.json b/homeassistant/components/braviatv/translations/en_GB.json new file mode 100644 index 0000000000000..af063f30a8706 --- /dev/null +++ b/homeassistant/components/braviatv/translations/en_GB.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "authorize": { + "title": "Authorise Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/es-419.json b/homeassistant/components/braviatv/translations/es-419.json index 6a2a0da982e07..319eff13b98f7 100644 --- a/homeassistant/components/braviatv/translations/es-419.json +++ b/homeassistant/components/braviatv/translations/es-419.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada." + "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada.", + "no_ip_control": "El control de IP est\u00e1 desactivado en su televisor o el televisor no es compatible." }, "error": { "cannot_connect": "No se pudo conectar, host inv\u00e1lido o c\u00f3digo PIN.", diff --git a/homeassistant/components/braviatv/translations/fr.json b/homeassistant/components/braviatv/translations/fr.json index 68988b1fbfeb9..d609f1a2fa11a 100644 --- a/homeassistant/components/braviatv/translations/fr.json +++ b/homeassistant/components/braviatv/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "no_ip_control": "Le contr\u00f4le IP est d\u00e9sactiv\u00e9 sur votre t\u00e9l\u00e9viseur ou le t\u00e9l\u00e9viseur n'est pas pris en charge." }, "error": { - "cannot_connect": "\u00c9chec de connexion, h\u00f4te ou code PIN non valide.", - "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide.", + "cannot_connect": "\u00c9chec de connexion", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", "unsupported_model": "Votre mod\u00e8le de t\u00e9l\u00e9viseur n'est pas pris en charge." }, "step": { @@ -19,7 +19,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration du t\u00e9l\u00e9viseur Sony Bravia. Si vous rencontrez des probl\u00e8mes de configuration, rendez-vous sur: https://www.home-assistant.io/integrations/braviatv \n\n Assurez-vous que votre t\u00e9l\u00e9viseur est allum\u00e9.", "title": "Sony Bravia TV" diff --git a/homeassistant/components/braviatv/translations/he.json b/homeassistant/components/braviatv/translations/he.json new file mode 100644 index 0000000000000..ab9d638a8acb9 --- /dev/null +++ b/homeassistant/components/braviatv/translations/he.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "step": { + "authorize": { + "data": { + "pin": "\u05e7\u05d5\u05d3 PIN" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/hu.json b/homeassistant/components/braviatv/translations/hu.json index fbb23fdee0432..00e88955c8102 100644 --- a/homeassistant/components/braviatv/translations/hu.json +++ b/homeassistant/components/braviatv/translations/hu.json @@ -14,12 +14,14 @@ "data": { "pin": "PIN-k\u00f3d" }, + "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\nHa a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, l\u00e9pjen a k\u00f6vetkez\u0151re: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.", "title": "Sony Bravia TV enged\u00e9lyez\u00e9se" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, + "description": "\u00c1ll\u00edtsa be a Sony Bravia TV integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/braviatv \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a TV be van kapcsolva.", "title": "Sony Bravia TV" } } diff --git a/homeassistant/components/braviatv/translations/it.json b/homeassistant/components/braviatv/translations/it.json index 95fcfc7622b03..bbd0215749672 100644 --- a/homeassistant/components/braviatv/translations/it.json +++ b/homeassistant/components/braviatv/translations/it.json @@ -14,14 +14,14 @@ "data": { "pin": "Codice PIN" }, - "description": "Immettere il codice PIN visualizzato sul Sony Bravia TV. \n\nSe il codice PIN non viene visualizzato, \u00e8 necessario annullare la registrazione di Home Assistant sul televisore, andare su: Impostazioni - > Rete - > Impostazioni dispositivo remoto - > Annulla registrazione dispositivo remoto.", - "title": "Autorizzare Sony Bravia TV" + "description": "Immetti il codice PIN visualizzato sul Sony Bravia TV. \n\nSe il codice PIN non viene visualizzato, devi annullare la registrazione di Home Assistant sul televisore, vai su: Impostazioni - > Rete - > Impostazioni dispositivo remoto - > Annulla registrazione dispositivo remoto.", + "title": "Autorizza Sony Bravia TV" }, "user": { "data": { "host": "Host" }, - "description": "Configurare l'integrazione TV di Sony Bravia. In caso di problemi con la configurazione visitare: https://www.home-assistant.io/integrations/braviatv\n\nAssicurarsi che il televisore sia acceso.", + "description": "Configura l'integrazione TV di Sony Bravia. In caso di problemi con la configurazione visita: https://www.home-assistant.io/integrations/braviatv\n\nAssicurati che il televisore sia acceso.", "title": "Sony Bravia TV" } } diff --git a/homeassistant/components/braviatv/translations/ja.json b/homeassistant/components/braviatv/translations/ja.json new file mode 100644 index 0000000000000..87f903f0640c3 --- /dev/null +++ b/homeassistant/components/braviatv/translations/ja.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_ip_control": "\u30c6\u30ec\u30d3\u3067IP\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u304c\u7121\u52b9\u306b\u306a\u3063\u3066\u3044\u308b\u304b\u3001\u30c6\u30ec\u30d3\u304c\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unsupported_model": "\u304a\u4f7f\u3044\u306e\u30c6\u30ec\u30d3\u306e\u30e2\u30c7\u30eb\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002" + }, + "step": { + "authorize": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "\u30bd\u30cb\u30fc Bravia TV\u306b\u8868\u793a\u3055\u308c\u3066\u3044\u308bPIN\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\nPIN\u30b3\u30fc\u30c9\u304c\u8868\u793a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u30c6\u30ec\u30d3\u304b\u3089Home Assistant\u306e\u767b\u9332\u3092\u89e3\u9664\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u306e\u3067\u3001\u6b21\u306e\u624b\u9806\u3067\u884c\u3063\u3066\u304f\u3060\u3055\u3044\u3002\u8a2d\u5b9a \u2192 \u30cd\u30c3\u30c8\u30ef\u30fc\u30af \u2192 \u30ea\u30e2\u30fc\u30c8\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a \u2192 \u30ea\u30e2\u30fc\u30c8\u30c7\u30d0\u30a4\u30b9\u306e\u767b\u9332\u89e3\u9664 \u3092\u884c\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30bd\u30cb\u30fc Bravia TV\u3092\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30bd\u30cb\u30fc Bravia TV\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u95a2\u3057\u3066\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/braviatv \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\n\n\u304d\u3061\u3093\u3068\u30c6\u30ec\u30d3\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3082\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30bd\u30cb\u30fc Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "\u7121\u8996\u3055\u308c\u305f\u30bd\u30fc\u30b9\u306e\u30ea\u30b9\u30c8" + }, + "title": "\u30bd\u30cb\u30fc Bravia TV\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/tr.json b/homeassistant/components/braviatv/translations/tr.json index 0853c8028fcb9..6d0f82e29a4c6 100644 --- a/homeassistant/components/braviatv/translations/tr.json +++ b/homeassistant/components/braviatv/translations/tr.json @@ -1,20 +1,27 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_ip_control": "TV'nizde IP Kontrol\u00fc devre d\u0131\u015f\u0131 veya TV desteklenmiyor." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unsupported_model": "TV modeliniz desteklenmiyor." }, "step": { "authorize": { + "data": { + "pin": "PIN Kodu" + }, + "description": "Sony Bravia TV'de g\u00f6sterilen PIN kodunu girin. \n\n PIN kodu g\u00f6r\u00fcnt\u00fclenmiyorsa, TV'nizde Home Assistant kayd\u0131n\u0131 iptal etmeniz gerekir, \u015furaya gidin: Ayarlar - > A\u011f - > Uzak cihaz ayarlar\u0131 - > Uzak cihaz\u0131n kayd\u0131n\u0131 iptal et.Home Assistant", "title": "Sony Bravia TV'yi yetkilendirin" }, "user": { "data": { "host": "Ana Bilgisayar" }, + "description": "Sony Bravia TV entegrasyonunu ayarlay\u0131n. Yap\u0131land\u0131rmayla ilgili sorunlar\u0131n\u0131z varsa \u015fu adrese gidin: https://www.home-assistant.io/integrations/braviatv \n\n TV'nizin a\u00e7\u0131k oldu\u011fundan emin olun.", "title": "Sony Bravia TV" } } @@ -22,6 +29,9 @@ "options": { "step": { "user": { + "data": { + "ignored_sources": "Yok say\u0131lan kaynaklar\u0131n listesi" + }, "title": "Sony Bravia TV i\u00e7in se\u00e7enekler" } } diff --git a/homeassistant/components/braviatv/translations/zh-Hans.json b/homeassistant/components/braviatv/translations/zh-Hans.json index c839a27161471..d02d562d55d64 100644 --- a/homeassistant/components/braviatv/translations/zh-Hans.json +++ b/homeassistant/components/braviatv/translations/zh-Hans.json @@ -4,10 +4,20 @@ "authorize": { "data": { "pin": "PIN \u7801" - } + }, + "description": "\u8f93\u5165\u5728 Sony Bravia \u7535\u89c6\u4e0a\u663e\u793a\u7684 PIN \u7801\u3002 \n\n\u5982\u679c\u672a\u663e\u793a PIN \u7801\uff0c\u60a8\u9700\u8981\u5728\u7535\u89c6\u4e0a\u53d6\u6d88\u6ce8\u518c Home Assistant\uff0c\u8bf7\u8f6c\u5230\uff1a\u8bbe\u7f6e - >\u7f51\u7edc - >\u8fdc\u7a0b\u8bbe\u5907\u8bbe\u7f6e - >\u53d6\u6d88\u6ce8\u518c\u8fdc\u7a0b\u8bbe\u5907\u3002", + "title": "\u6388\u6743 Sony Bravia \u7535\u89c6" }, "user": { - "description": "\u8bbe\u7f6eSony Bravia\u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002" + "description": "\u8bbe\u7f6e Sony Bravia \u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "title": "Sony Bravia \u7535\u89c6\u9009\u9879" } } } diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 501afaac9301a..eb39a05743490 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -1,8 +1,11 @@ """The Broadlink integration.""" +from __future__ import annotations + from dataclasses import dataclass, field from .const import DOMAIN from .device import BroadlinkDevice +from .heartbeat import BroadlinkHeartbeat @dataclass @@ -11,6 +14,7 @@ class BroadlinkData: devices: dict = field(default_factory=dict) platforms: dict = field(default_factory=dict) + heartbeat: BroadlinkHeartbeat | None = None async def async_setup(hass, config): @@ -21,11 +25,25 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up a Broadlink device from a config entry.""" + data = hass.data[DOMAIN] + + if data.heartbeat is None: + data.heartbeat = BroadlinkHeartbeat(hass) + hass.async_create_task(data.heartbeat.async_setup()) + device = BroadlinkDevice(hass, entry) return await device.async_setup() async def async_unload_entry(hass, entry): """Unload a config entry.""" - device = hass.data[DOMAIN].devices.pop(entry.entry_id) - return await device.async_unload() + data = hass.data[DOMAIN] + + device = data.devices.pop(entry.entry_id) + result = await device.async_unload() + + if not data.devices: + await data.heartbeat.async_unload() + data.heartbeat = None + + return result diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index 4457f4d26756a..8a32ba02ee88e 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -13,12 +13,11 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE from homeassistant.helpers import config_validation as cv -from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN, DOMAINS_AND_TYPES +from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN from .helpers import format_mac _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,7 @@ def __init__(self): async def async_set_device(self, device, raise_on_progress=True): """Define a device for the config flow.""" - supported_types = set.union(*DOMAINS_AND_TYPES.values()) - if device.type not in supported_types: + if device.type not in DEVICE_TYPES: _LOGGER.error( "Unsupported device: %s. If it worked before, please open " "an issue at https://github.com/home-assistant/core/issues", @@ -55,10 +53,12 @@ async def async_set_device(self, device, raise_on_progress=True): "host": device.host[0], } - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle dhcp discovery.""" - host = discovery_info[IP_ADDRESS] - unique_id = discovery_info[MAC_ADDRESS].lower().replace(":", "") + host = discovery_info.ip + unique_id = discovery_info.macaddress.lower().replace(":", "") await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) @@ -73,8 +73,7 @@ async def async_step_dhcp(self, discovery_info): return self.async_abort(reason="cannot_connect") return self.async_abort(reason="unknown") - supported_types = set.union(*DOMAINS_AND_TYPES.values()) - if device.type not in supported_types: + if device.type not in DEVICE_TYPES: return self.async_abort(reason="not_supported") await self.async_set_device(device) @@ -110,7 +109,7 @@ async def async_step_user(self, user_input=None): else: device.timeout = timeout - if self.source != SOURCE_REAUTH: + if self.source != config_entries.SOURCE_REAUTH: await self.async_set_device(device) self._abort_if_unique_id_configured( updates={CONF_HOST: device.host[0], CONF_TIMEOUT: timeout} @@ -298,11 +297,7 @@ async def async_step_finish(self, user_input=None): async def async_step_import(self, import_info): """Import a device.""" - if any( - import_info[CONF_HOST] == entry.data[CONF_HOST] - for entry in self._async_current_entries() - ): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) return await self.async_step_user(import_info) async def async_step_reauth(self, data): diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index fd060d23b35cb..3f7744ecbb445 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -1,14 +1,21 @@ -"""Constants for the Broadlink integration.""" -from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +"""Constants.""" +from homeassistant.const import Platform DOMAIN = "broadlink" DOMAINS_AND_TYPES = { - REMOTE_DOMAIN: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, - SENSOR_DOMAIN: {"A1", "RM4MINI", "RM4PRO", "RMPRO"}, - SWITCH_DOMAIN: { + Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, + Platform.SENSOR: { + "A1", + "RM4MINI", + "RM4PRO", + "RMPRO", + "SP2S", + "SP3S", + "SP4", + "SP4B", + }, + Platform.SWITCH: { "BG1", "MP1", "RM4MINI", @@ -24,7 +31,9 @@ "SP4", "SP4B", }, + Platform.LIGHT: {"LB1"}, } +DEVICE_TYPES = set.union(*DOMAINS_AND_TYPES.values()) DEFAULT_PORT = 80 DEFAULT_TIMEOUT = 5 diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index b18d64c327fcd..951be9b26bb7a 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -23,9 +23,9 @@ _LOGGER = logging.getLogger(__name__) -def get_domains(dev_type): +def get_domains(device_type): """Return the domains available for a device type.""" - return {d for d, t in DOMAINS_AND_TYPES.items() if dev_type in t} + return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} class BroadlinkDevice: @@ -51,19 +51,31 @@ def unique_id(self): """Return the unique id of the device.""" return self.config.unique_id + @property + def mac_address(self): + """Return the mac address of the device.""" + return self.config.data[CONF_MAC] + + @property + def available(self): + """Return True if the device is available.""" + if self.update_manager is None: # pragma: no cover + return False + return self.update_manager.available + @staticmethod async def async_update(hass, entry): """Update the device and related entities. Triggered when the device is renamed on the frontend. """ - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) device_registry.async_update_device(device_entry.id, name=entry.title) await hass.config_entries.async_reload(entry.entry_id) - def _auth_fetch_firmware(self): - """Auth and fetch firmware.""" + def _get_firmware_version(self): + """Get firmware version.""" self.api.auth() with suppress(BroadlinkException, OSError): return self.api.get_fwversion() @@ -84,7 +96,7 @@ async def async_setup(self): try: self.fw_version = await self.hass.async_add_executor_job( - self._auth_fetch_firmware + self._get_firmware_version ) except AuthenticationError: diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py new file mode 100644 index 0000000000000..2c7a05a7e70f5 --- /dev/null +++ b/homeassistant/components/broadlink/entity.py @@ -0,0 +1,65 @@ +"""Broadlink entities.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class BroadlinkEntity(Entity): + """Representation of a Broadlink entity.""" + + _attr_should_poll = False + + def __init__(self, device): + """Initialize the entity.""" + self._device = device + self._coordinator = device.update_manager.coordinator + + async def async_added_to_hass(self): + """Call when the entity is added to hass.""" + self.async_on_remove(self._coordinator.async_add_listener(self._recv_data)) + + async def async_update(self): + """Update the state of the entity.""" + await self._coordinator.async_request_refresh() + + def _recv_data(self): + """Receive data from the update coordinator. + + This event listener should be called by the coordinator whenever + there is an update available. + + It works as a template for the _update_state() method, which should + be overridden by child classes in order to update the state of the + entities, when applicable. + """ + if self._coordinator.last_update_success: + self._update_state(self._coordinator.data) + self.async_write_ha_state() + + def _update_state(self, data): + """Update the state of the entity. + + This method should be overridden by child classes in order to + internalize state and attributes received from the coordinator. + """ + + @property + def available(self): + """Return True if the entity is available.""" + return self._device.available + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + device = self._device + + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, + identifiers={(DOMAIN, device.unique_id)}, + manufacturer=device.api.manufacturer, + model=device.api.model, + name=device.name, + sw_version=device.fw_version, + ) diff --git a/homeassistant/components/broadlink/heartbeat.py b/homeassistant/components/broadlink/heartbeat.py new file mode 100644 index 0000000000000..b4deffa5b8162 --- /dev/null +++ b/homeassistant/components/broadlink/heartbeat.py @@ -0,0 +1,59 @@ +"""Heartbeats for Broadlink devices.""" +import datetime as dt +import logging + +import broadlink as blk + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import event + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BroadlinkHeartbeat: + """Manages heartbeats in the Broadlink integration. + + Some devices reboot when they cannot reach the cloud. This mechanism + feeds their watchdog timers so they can be used offline. + """ + + HEARTBEAT_INTERVAL = dt.timedelta(minutes=2) + + def __init__(self, hass): + """Initialize the heartbeat.""" + self._hass = hass + self._unsubscribe = None + + async def async_setup(self): + """Set up the heartbeat.""" + if self._unsubscribe is None: + await self.async_heartbeat(dt.datetime.now()) + self._unsubscribe = event.async_track_time_interval( + self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL + ) + + async def async_unload(self): + """Unload the heartbeat.""" + if self._unsubscribe is not None: + self._unsubscribe() + self._unsubscribe = None + + async def async_heartbeat(self, now): + """Send packets to feed watchdog timers.""" + hass = self._hass + config_entries = hass.config_entries.async_entries(DOMAIN) + hosts = {entry.data[CONF_HOST] for entry in config_entries} + await hass.async_add_executor_job(self.heartbeat, hosts) + + @staticmethod + def heartbeat(hosts): + """Send packets to feed watchdog timers.""" + for host in hosts: + try: + blk.ping(host) + except OSError as err: + _LOGGER.debug("Failed to send heartbeat to %s: %s", host, err) + else: + _LOGGER.debug("Heartbeat sent to %s", host) diff --git a/homeassistant/components/broadlink/helpers.py b/homeassistant/components/broadlink/helpers.py index 6d81b98d5d1c5..bec61ba5bbd42 100644 --- a/homeassistant/components/broadlink/helpers.py +++ b/homeassistant/components/broadlink/helpers.py @@ -3,7 +3,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py new file mode 100644 index 0000000000000..698401f3e2eed --- /dev/null +++ b/homeassistant/components/broadlink/light.py @@ -0,0 +1,136 @@ +"""Support for Broadlink lights.""" +import logging + +from broadlink.exceptions import BroadlinkException + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_UNKNOWN, + LightEntity, +) + +from .const import DOMAIN +from .entity import BroadlinkEntity + +_LOGGER = logging.getLogger(__name__) + +BROADLINK_COLOR_MODE_RGB = 0 +BROADLINK_COLOR_MODE_WHITE = 1 +BROADLINK_COLOR_MODE_SCENES = 2 + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Broadlink light.""" + device = hass.data[DOMAIN].devices[config_entry.entry_id] + lights = [] + + if device.api.type == "LB1": + lights.append(BroadlinkLight(device)) + + async_add_entities(lights) + + +class BroadlinkLight(BroadlinkEntity, LightEntity): + """Representation of a Broadlink light.""" + + def __init__(self, device): + """Initialize the light.""" + super().__init__(device) + self._attr_name = f"{device.name} Light" + self._attr_unique_id = device.unique_id + self._attr_supported_color_modes = set() + + data = self._coordinator.data + + if {"hue", "saturation"}.issubset(data): + self._attr_supported_color_modes.add(COLOR_MODE_HS) + if "colortemp" in data: + self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + if not self.supported_color_modes: + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + + self._update_state(data) + + def _update_state(self, data): + """Update the state of the entity.""" + if "pwr" in data: + self._attr_is_on = bool(data["pwr"]) + + if "brightness" in data: + self._attr_brightness = round(data["brightness"] * 2.55) + + if self.supported_color_modes == {COLOR_MODE_BRIGHTNESS}: + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + return + + if {"hue", "saturation"}.issubset(data): + self._attr_hs_color = [data["hue"], data["saturation"]] + + if "colortemp" in data: + self._attr_color_temp = round((data["colortemp"] - 2700) / 100 + 153) + + if "bulb_colormode" in data: + if data["bulb_colormode"] == BROADLINK_COLOR_MODE_RGB: + self._attr_color_mode = COLOR_MODE_HS + elif data["bulb_colormode"] == BROADLINK_COLOR_MODE_WHITE: + self._attr_color_mode = COLOR_MODE_COLOR_TEMP + else: + # Scenes are not yet supported. + self._attr_color_mode = COLOR_MODE_UNKNOWN + + async def async_turn_on(self, **kwargs): + """Turn on the light.""" + state = {"pwr": 1} + + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + state["brightness"] = round(brightness / 2.55) + + if self.supported_color_modes == {COLOR_MODE_BRIGHTNESS}: + state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE + + elif ATTR_HS_COLOR in kwargs: + hs_color = kwargs[ATTR_HS_COLOR] + state["hue"] = int(hs_color[0]) + state["saturation"] = int(hs_color[1]) + state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB + + elif ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + state["colortemp"] = (color_temp - 153) * 100 + 2700 + state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE + + elif ATTR_COLOR_MODE in kwargs: + color_mode = kwargs[ATTR_COLOR_MODE] + if color_mode == COLOR_MODE_HS: + state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB + elif color_mode == COLOR_MODE_COLOR_TEMP: + state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE + else: + # Scenes are not yet supported. + state["bulb_colormode"] = BROADLINK_COLOR_MODE_SCENES + + await self._async_set_state(state) + + async def async_turn_off(self, **kwargs): + """Turn off the light.""" + await self._async_set_state({"pwr": 0}) + + async def _async_set_state(self, state): + """Set the state of the light.""" + device = self._device + + try: + state = await device.async_request(device.api.set_state, **state) + except (BroadlinkException, OSError) as err: + _LOGGER.error("Failed to set state: %s", err) + return + + self._update_state(state) + self.async_write_ha_state() diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index c27b9276ec408..1a6e94003ca24 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -2,8 +2,8 @@ "domain": "broadlink", "name": "Broadlink", "documentation": "https://www.home-assistant.io/integrations/broadlink", - "requirements": ["broadlink==0.17.0"], - "codeowners": ["@danielhiversen", "@felipediel"], + "requirements": ["broadlink==0.18.0"], + "codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 291bf6a3d8bb6..ad597d80d20e2 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -24,7 +24,6 @@ ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, DOMAIN as RM_DOMAIN, - PLATFORM_SCHEMA, SERVICE_DELETE_COMMAND, SERVICE_LEARN_COMMAND, SERVICE_SEND_COMMAND, @@ -32,15 +31,16 @@ SUPPORT_LEARN_COMMAND, RemoteEntity, ) -from homeassistant.const import CONF_HOST, STATE_OFF +from homeassistant.const import STATE_OFF from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store -from homeassistant.util.dt import utcnow +from homeassistant.util import dt from .const import DOMAIN -from .helpers import data_packet, import_device +from .entity import BroadlinkEntity +from .helpers import data_packet _LOGGER = logging.getLogger(__name__) @@ -84,22 +84,6 @@ {vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1))} ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import the device and discontinue platform. - - This is for backward compatibility. - Do not use this method. - """ - import_device(hass, config[CONF_HOST]) - _LOGGER.warning( - "The remote platform is deprecated, please remove it from your configuration" - ) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a Broadlink remote.""" @@ -112,61 +96,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities([remote], False) -class BroadlinkRemote(RemoteEntity, RestoreEntity): +class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): """Representation of a Broadlink remote.""" def __init__(self, device, codes, flags): """Initialize the remote.""" - self._device = device - self._coordinator = device.update_manager.coordinator + super().__init__(device) self._code_storage = codes self._flag_storage = flags self._storage_loaded = False self._codes = {} self._flags = defaultdict(int) - self._state = True self._lock = asyncio.Lock() - @property - def name(self): - """Return the name of the remote.""" - return f"{self._device.name} Remote" - - @property - def unique_id(self): - """Return the unique id of the remote.""" - return self._device.unique_id - - @property - def is_on(self): - """Return True if the remote is on.""" - return self._state - - @property - def available(self): - """Return True if the remote is available.""" - return self._device.update_manager.available - - @property - def should_poll(self): - """Return True if the remote has to be polled for state.""" - return False - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LEARN_COMMAND | SUPPORT_DELETE_COMMAND - - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.unique_id)}, - "manufacturer": self._device.api.manufacturer, - "model": self._device.api.model, - "name": self._device.name, - "sw_version": self._device.fw_version, - } + self._attr_name = f"{device.name} Remote" + self._attr_is_on = True + self._attr_supported_features = SUPPORT_LEARN_COMMAND | SUPPORT_DELETE_COMMAND + self._attr_unique_id = device.unique_id def _extract_codes(self, commands, device=None): """Extract a list of codes. @@ -224,24 +170,17 @@ def _get_flags(self): async def async_added_to_hass(self): """Call when the remote is added to hass.""" state = await self.async_get_last_state() - self._state = state is None or state.state != STATE_OFF - - self.async_on_remove( - self._coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self): - """Update the remote.""" - await self._coordinator.async_request_refresh() + self._attr_is_on = state is None or state.state != STATE_OFF + await super().async_added_to_hass() async def async_turn_on(self, **kwargs): """Turn on the remote.""" - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn off the remote.""" - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def _async_load_storage(self): @@ -257,12 +196,13 @@ async def async_send_command(self, command, **kwargs): kwargs[ATTR_COMMAND] = command kwargs = SERVICE_SEND_SCHEMA(kwargs) commands = kwargs[ATTR_COMMAND] - device = kwargs.get(ATTR_DEVICE) + subdevice = kwargs.get(ATTR_DEVICE) repeat = kwargs[ATTR_NUM_REPEATS] delay = kwargs[ATTR_DELAY_SECS] service = f"{RM_DOMAIN}.{SERVICE_SEND_COMMAND}" + device = self._device - if not self._state: + if not self._attr_is_on: _LOGGER.warning( "%s canceled: %s entity is turned off", service, self.entity_id ) @@ -272,13 +212,13 @@ async def async_send_command(self, command, **kwargs): await self._async_load_storage() try: - code_list = self._extract_codes(commands, device) + code_list = self._extract_codes(commands, subdevice) except ValueError as err: _LOGGER.error("Failed to call %s: %s", service, err) raise rf_flags = {0xB2, 0xD7} - if not hasattr(self._device.api, "sweep_frequency") and any( + if not hasattr(device.api, "sweep_frequency") and any( c[0] in rf_flags for codes in code_list for c in codes ): err_msg = f"{self.entity_id} doesn't support sending RF commands" @@ -291,18 +231,18 @@ async def async_send_command(self, command, **kwargs): await asyncio.sleep(delay) if len(codes) > 1: - code = codes[self._flags[device]] + code = codes[self._flags[subdevice]] else: code = codes[0] try: - await self._device.async_request(self._device.api.send_data, code) + await device.async_request(device.api.send_data, code) except (BroadlinkException, OSError) as err: _LOGGER.error("Error during %s: %s", service, err) break if len(codes) > 1: - self._flags[device] ^= 1 + self._flags[subdevice] ^= 1 at_least_one_sent = True if at_least_one_sent: @@ -313,11 +253,12 @@ async def async_learn_command(self, **kwargs): kwargs = SERVICE_LEARN_SCHEMA(kwargs) commands = kwargs[ATTR_COMMAND] command_type = kwargs[ATTR_COMMAND_TYPE] - device = kwargs[ATTR_DEVICE] + subdevice = kwargs[ATTR_DEVICE] toggle = kwargs[ATTR_ALTERNATIVE] service = f"{RM_DOMAIN}.{SERVICE_LEARN_COMMAND}" + device = self._device - if not self._state: + if not self._attr_is_on: _LOGGER.warning( "%s canceled: %s entity is turned off", service, self.entity_id ) @@ -330,7 +271,7 @@ async def async_learn_command(self, **kwargs): if command_type == COMMAND_TYPE_IR: learn_command = self._async_learn_ir_command - elif hasattr(self._device.api, "sweep_frequency"): + elif hasattr(device.api, "sweep_frequency"): learn_command = self._async_learn_rf_command else: @@ -354,7 +295,7 @@ async def async_learn_command(self, **kwargs): _LOGGER.error("Failed to learn '%s': %s", command, err) continue - self._codes.setdefault(device, {}).update({command: code}) + self._codes.setdefault(subdevice, {}).update({command: code}) should_store = True if should_store: @@ -362,8 +303,10 @@ async def async_learn_command(self, **kwargs): async def _async_learn_ir_command(self, command): """Learn an infrared command.""" + device = self._device + try: - await self._device.async_request(self._device.api.enter_learning) + await device.async_request(device.api.enter_learning) except (BroadlinkException, OSError) as err: _LOGGER.debug("Failed to enter learning mode: %s", err) @@ -376,11 +319,11 @@ async def _async_learn_ir_command(self, command): ) try: - start_time = utcnow() - while (utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt.utcnow() + while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: - code = await self._device.async_request(self._device.api.check_data) + code = await device.async_request(device.api.check_data) except (ReadError, StorageError): continue return b64encode(code).decode("utf8") @@ -397,8 +340,10 @@ async def _async_learn_ir_command(self, command): async def _async_learn_rf_command(self, command): """Learn a radiofrequency command.""" + device = self._device + try: - await self._device.async_request(self._device.api.sweep_frequency) + await device.async_request(device.api.sweep_frequency) except (BroadlinkException, OSError) as err: _LOGGER.debug("Failed to sweep frequency: %s", err) @@ -411,18 +356,14 @@ async def _async_learn_rf_command(self, command): ) try: - start_time = utcnow() - while (utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt.utcnow() + while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) - found = await self._device.async_request( - self._device.api.check_frequency - ) + found = await device.async_request(device.api.check_frequency) if found: break else: - await self._device.async_request( - self._device.api.cancel_sweep_frequency - ) + await device.async_request(device.api.cancel_sweep_frequency) raise TimeoutError( "No radiofrequency found within " f"{LEARNING_TIMEOUT.total_seconds()} seconds" @@ -436,7 +377,7 @@ async def _async_learn_rf_command(self, command): await asyncio.sleep(1) try: - await self._device.async_request(self._device.api.find_rf_packet) + await device.async_request(device.api.find_rf_packet) except (BroadlinkException, OSError) as err: _LOGGER.debug("Failed to enter learning mode: %s", err) @@ -449,11 +390,11 @@ async def _async_learn_rf_command(self, command): ) try: - start_time = utcnow() - while (utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt.utcnow() + while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: - code = await self._device.async_request(self._device.api.check_data) + code = await device.async_request(device.api.check_data) except (ReadError, StorageError): continue return b64encode(code).decode("utf8") @@ -472,10 +413,10 @@ async def async_delete_command(self, **kwargs): """Delete a list of commands from a remote.""" kwargs = SERVICE_DELETE_SCHEMA(kwargs) commands = kwargs[ATTR_COMMAND] - device = kwargs[ATTR_DEVICE] + subdevice = kwargs[ATTR_DEVICE] service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}" - if not self._state: + if not self._attr_is_on: _LOGGER.warning( "%s canceled: %s entity is turned off", service, @@ -487,9 +428,9 @@ async def async_delete_command(self, **kwargs): await self._async_load_storage() try: - codes = self._codes[device] + codes = self._codes[subdevice] except KeyError as err: - err_msg = f"Device not found: {repr(device)}" + err_msg = f"Device not found: {repr(subdevice)}" _LOGGER.error("Failed to call %s. %s", service, err_msg) raise ValueError(err_msg) from err @@ -514,8 +455,8 @@ async def async_delete_command(self, **kwargs): # Clean up if not codes: - del self._codes[device] - if self._flags.pop(device, None) is not None: + del self._codes[subdevice] + if self._flags.pop(subdevice, None) is not None: self._flag_storage.async_delay_save(self._get_flags, FLAG_SAVE_DELAY) self._code_storage.async_delay_save(self._get_codes, CODE_SAVE_DELAY) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 3f4a1e861b305..eb2c51d86c3cd 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -1,128 +1,120 @@ """Support for Broadlink sensors.""" -import logging - -import voluptuous as vol +from __future__ import annotations from homeassistant.components.sensor import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, - PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, ) -from homeassistant.const import CONF_HOST, PERCENTAGE, TEMP_CELSIUS -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv from .const import DOMAIN -from .helpers import import_device - -_LOGGER = logging.getLogger(__name__) - -SENSOR_TYPES = { - "temperature": ("Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE), - "air_quality": ("Air Quality", None, None), - "humidity": ("Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY), - "light": ("Light", None, DEVICE_CLASS_ILLUMINANCE), - "noise": ("Noise", None, None), -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA +from .entity import BroadlinkEntity + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="air_quality", + name="Air Quality", + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="light", + name="Light", + device_class=SensorDeviceClass.ILLUMINANCE, + ), + SensorEntityDescription( + key="noise", + name="Noise", + ), + SensorEntityDescription( + key="power", + name="Current power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="volt", + name="Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current", + name="Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="overload", + name="Overload", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="totalconsum", + name="Total consumption", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import the device and discontinue platform. - - This is for backward compatibility. - Do not use this method. - """ - import_device(hass, config[CONF_HOST]) - _LOGGER.warning( - "The sensor platform is deprecated, please remove it from your configuration" - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Broadlink sensor.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] sensor_data = device.update_manager.coordinator.data sensors = [ - BroadlinkSensor(device, monitored_condition) - for monitored_condition in sensor_data - if sensor_data[monitored_condition] != 0 or device.api.type == "A1" + BroadlinkSensor(device, description) + for description in SENSOR_TYPES + if description.key in sensor_data + and ( + # These devices have optional sensors. + # We don't create entities if the value is 0. + sensor_data[description.key] != 0 + or device.api.type not in {"RM4PRO", "RM4MINI"} + ) ] async_add_entities(sensors) -class BroadlinkSensor(SensorEntity): +class BroadlinkSensor(BroadlinkEntity, SensorEntity): """Representation of a Broadlink sensor.""" - def __init__(self, device, monitored_condition): + def __init__(self, device, description: SensorEntityDescription): """Initialize the sensor.""" - self._device = device - self._coordinator = device.update_manager.coordinator - self._monitored_condition = monitored_condition - self._state = self._coordinator.data[monitored_condition] - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"{self._device.unique_id}-{self._monitored_condition}" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._device.name} {SENSOR_TYPES[self._monitored_condition][0]}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return True if the sensor is available.""" - return self._device.update_manager.available - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return SENSOR_TYPES[self._monitored_condition][1] - - @property - def should_poll(self): - """Return True if the sensor has to be polled for state.""" - return False - - @property - def device_class(self): - """Return device class.""" - return SENSOR_TYPES[self._monitored_condition][2] - - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.unique_id)}, - "manufacturer": self._device.api.manufacturer, - "model": self._device.api.model, - "name": self._device.name, - "sw_version": self._device.fw_version, - } - - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data[self._monitored_condition] - self.async_write_ha_state() + super().__init__(device) + self.entity_description = description - async def async_added_to_hass(self): - """Call when the sensor is added to hass.""" - self.async_on_remove(self._coordinator.async_add_listener(self.update_data)) + self._attr_name = f"{device.name} {description.name}" + self._attr_native_value = self._coordinator.data[description.key] + self._attr_unique_id = f"{device.unique_id}-{description.key}" - async def async_update(self): - """Update the sensor.""" - await self._coordinator.async_request_refresh() + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_native_value = data[self.entity_description.key] diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 0a98530c8069d..36f3b4db759fe 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -1,21 +1,18 @@ """Support for Broadlink switches.""" from abc import ABC, abstractmethod -from functools import partial import logging from broadlink.exceptions import BroadlinkException import voluptuous as vol from homeassistant.components.switch import ( - DEVICE_CLASS_OUTLET, - DEVICE_CLASS_SWITCH, PLATFORM_SCHEMA, + SwitchDeviceClass, SwitchEntity, ) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, - CONF_FRIENDLY_NAME, CONF_HOST, CONF_MAC, CONF_NAME, @@ -23,12 +20,13 @@ CONF_TIMEOUT, CONF_TYPE, STATE_ON, + Platform, ) -from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, SWITCH_DOMAIN +from .const import DOMAIN +from .entity import BroadlinkEntity from .helpers import data_packet, import_device, mac_address _LOGGER = logging.getLogger(__name__) @@ -43,14 +41,6 @@ } ) -OLD_SWITCH_SCHEMA = vol.Schema( - { - vol.Optional(CONF_COMMAND_OFF): data_packet, - vol.Optional(CONF_COMMAND_ON): data_packet, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - } -) - PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_HOST), cv.deprecated(CONF_SLOTS), @@ -60,9 +50,9 @@ { vol.Required(CONF_MAC): mac_address, vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_SWITCHES, default=[]): vol.Any( - cv.schema_with_slug_keys(OLD_SWITCH_SCHEMA), - vol.All(cv.ensure_list, [SWITCH_SCHEMA]), + vol.Optional(CONF_SWITCHES, default=[]): vol.All( + cv.ensure_list, + [SWITCH_SCHEMA], ), } ), @@ -79,19 +69,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= host = config.get(CONF_HOST) switches = config.get(CONF_SWITCHES) - if not isinstance(switches, list): - switches = [ - {CONF_NAME: switch.pop(CONF_FRIENDLY_NAME, name), **switch} - for name, switch in switches.items() - ] - - _LOGGER.warning( - "Your configuration for the switch platform is deprecated. " - "Please refer to the Broadlink documentation to catch up" - ) - if switches: - platform_data = hass.data[DOMAIN].platforms.setdefault(SWITCH_DOMAIN, {}) + platform_data = hass.data[DOMAIN].platforms.setdefault(Platform.SWITCH, {}) platform_data.setdefault(mac_addr, []).extend(switches) else: @@ -108,107 +87,59 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Broadlink switch.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] + switches = [] if device.api.type in {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}: - platform_data = hass.data[DOMAIN].platforms.get(SWITCH_DOMAIN, {}) + platform_data = hass.data[DOMAIN].platforms.get(Platform.SWITCH, {}) user_defined_switches = platform_data.get(device.api.mac, {}) - switches = [ + switches.extend( BroadlinkRMSwitch(device, config) for config in user_defined_switches - ] + ) elif device.api.type == "SP1": - switches = [BroadlinkSP1Switch(device)] + switches.append(BroadlinkSP1Switch(device)) elif device.api.type in {"SP2", "SP2S", "SP3", "SP3S", "SP4", "SP4B"}: - switches = [BroadlinkSP2Switch(device)] + switches.append(BroadlinkSP2Switch(device)) elif device.api.type == "BG1": - switches = [BroadlinkBG1Slot(device, slot) for slot in range(1, 3)] + switches.extend(BroadlinkBG1Slot(device, slot) for slot in range(1, 3)) elif device.api.type == "MP1": - switches = [BroadlinkMP1Slot(device, slot) for slot in range(1, 5)] + switches.extend(BroadlinkMP1Slot(device, slot) for slot in range(1, 5)) async_add_entities(switches) -class BroadlinkSwitch(SwitchEntity, RestoreEntity, ABC): +class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): """Representation of a Broadlink switch.""" + _attr_assumed_state = True + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__(self, device, command_on, command_off): """Initialize the switch.""" - self._device = device + super().__init__(device) self._command_on = command_on self._command_off = command_off - self._coordinator = device.update_manager.coordinator - self._state = None - - @property - def name(self): - """Return the name of the switch.""" - return f"{self._device.name} Switch" - - @property - def assumed_state(self): - """Return True if unable to access real state of the switch.""" - return True - - @property - def available(self): - """Return True if the switch is available.""" - return self._device.update_manager.available - - @property - def is_on(self): - """Return True if the switch is on.""" - return self._state - - @property - def should_poll(self): - """Return True if the switch has to be polled for state.""" - return False - - @property - def device_class(self): - """Return device class.""" - return DEVICE_CLASS_SWITCH - - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.unique_id)}, - "manufacturer": self._device.api.manufacturer, - "model": self._device.api.model, - "name": self._device.name, - "sw_version": self._device.fw_version, - } - - @callback - def update_data(self): - """Update data.""" - self.async_write_ha_state() + self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self): """Call when the switch is added to hass.""" - if self._state is None: - state = await self.async_get_last_state() - self._state = state is not None and state.state == STATE_ON - self.async_on_remove(self._coordinator.async_add_listener(self.update_data)) - - async def async_update(self): - """Update the switch.""" - await self._coordinator.async_request_refresh() + state = await self.async_get_last_state() + self._attr_is_on = state is not None and state.state == STATE_ON + await super().async_added_to_hass() async def async_turn_on(self, **kwargs): """Turn on the switch.""" if await self._async_send_packet(self._command_on): - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn off the switch.""" if await self._async_send_packet(self._command_off): - self._state = False + self._attr_is_on = False self.async_write_ha_state() @abstractmethod @@ -224,20 +155,17 @@ def __init__(self, device, config): super().__init__( device, config.get(CONF_COMMAND_ON), config.get(CONF_COMMAND_OFF) ) - self._name = config[CONF_NAME] - - @property - def name(self): - """Return the name of the switch.""" - return self._name + self._attr_name = config[CONF_NAME] async def _async_send_packet(self, packet): """Send a packet to the device.""" + device = self._device + if packet is None: return True try: - await self._device.async_request(self._device.api.send_data, packet) + await device.async_request(device.api.send_data, packet) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False @@ -250,16 +178,14 @@ class BroadlinkSP1Switch(BroadlinkSwitch): def __init__(self, device): """Initialize the switch.""" super().__init__(device, 1, 0) - - @property - def unique_id(self): - """Return the unique id of the switch.""" - return self._device.unique_id + self._attr_unique_id = self._device.unique_id async def _async_send_packet(self, packet): """Send a packet to the device.""" + device = self._device + try: - await self._device.async_request(self._device.api.set_power, packet) + await device.async_request(device.api.set_power, packet) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False @@ -269,68 +195,41 @@ async def _async_send_packet(self, packet): class BroadlinkSP2Switch(BroadlinkSP1Switch): """Representation of a Broadlink SP2 switch.""" + _attr_assumed_state = False + def __init__(self, device, *args, **kwargs): """Initialize the switch.""" super().__init__(device, *args, **kwargs) - self._state = self._coordinator.data["pwr"] - self._load_power = self._coordinator.data.get("power") - - @property - def assumed_state(self): - """Return True if unable to access real state of the switch.""" - return False + self._attr_is_on = self._coordinator.data["pwr"] - @property - def current_power_w(self): - """Return the current power usage in Watt.""" - return self._load_power - - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data["pwr"] - self._load_power = self._coordinator.data.get("power") - self.async_write_ha_state() + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_is_on = data["pwr"] class BroadlinkMP1Slot(BroadlinkSwitch): """Representation of a Broadlink MP1 slot.""" + _attr_assumed_state = False + def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot - self._state = self._coordinator.data[f"s{slot}"] - - @property - def unique_id(self): - """Return the unique id of the slot.""" - return f"{self._device.unique_id}-s{self._slot}" - - @property - def name(self): - """Return the name of the switch.""" - return f"{self._device.name} S{self._slot}" - - @property - def assumed_state(self): - """Return True if unable to access real state of the switch.""" - return False - - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data[f"s{self._slot}"] - self.async_write_ha_state() + self._attr_is_on = self._coordinator.data[f"s{slot}"] + self._attr_name = f"{device.name} S{slot}" + self._attr_unique_id = f"{device.unique_id}-s{slot}" + + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_is_on = data[f"s{self._slot}"] async def _async_send_packet(self, packet): """Send a packet to the device.""" + device = self._device + try: - await self._device.async_request( - self._device.api.set_power, self._slot, packet - ) + await device.async_request(device.api.set_power, self._slot, packet) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False @@ -340,44 +239,29 @@ async def _async_send_packet(self, packet): class BroadlinkBG1Slot(BroadlinkSwitch): """Representation of a Broadlink BG1 slot.""" + _attr_assumed_state = False + def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot - self._state = self._coordinator.data[f"pwr{slot}"] - - @property - def unique_id(self): - """Return the unique id of the slot.""" - return f"{self._device.unique_id}-s{self._slot}" - - @property - def name(self): - """Return the name of the switch.""" - return f"{self._device.name} S{self._slot}" - - @property - def assumed_state(self): - """Return True if unable to access real state of the switch.""" - return False - - @property - def device_class(self): - """Return device class.""" - return DEVICE_CLASS_OUTLET - - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data[f"pwr{self._slot}"] - self.async_write_ha_state() + self._attr_is_on = self._coordinator.data[f"pwr{slot}"] + + self._attr_name = f"{device.name} S{slot}" + self._attr_device_class = SwitchDeviceClass.OUTLET + self._attr_unique_id = f"{device.unique_id}-s{slot}" + + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_is_on = data[f"pwr{self._slot}"] async def _async_send_packet(self, packet): """Send a packet to the device.""" - set_state = partial(self._device.api.set_state, **{f"pwr{self._slot}": packet}) + device = self._device + state = {f"pwr{self._slot}": packet} + try: - await self._device.async_request(set_state) + await device.async_request(device.api.set_state, **state) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False diff --git a/homeassistant/components/broadlink/translations/bg.json b/homeassistant/components/broadlink/translations/bg.json new file mode 100644 index 0000000000000..8c5b5347516f4 --- /dev/null +++ b/homeassistant/components/broadlink/translations/bg.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441", + "not_supported": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name} ({model} at {host})", + "step": { + "finish": { + "data": { + "name": "\u0418\u043c\u0435" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0438\u043c\u0435 \u0437\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" + }, + "unlock": { + "data": { + "unlock": "\u0414\u0430, \u043d\u0430\u043f\u0440\u0430\u0432\u0435\u0442\u0435 \u0433\u043e." + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/broadlink/translations/de.json b/homeassistant/components/broadlink/translations/de.json index 7ad3ab95ec9a2..1e5635b31452c 100644 --- a/homeassistant/components/broadlink/translations/de.json +++ b/homeassistant/components/broadlink/translations/de.json @@ -4,15 +4,16 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "not_supported": "Ger\u00e4t nicht unterst\u00fctzt", "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "unknown": "Unerwarteter Fehler" }, + "flow_title": "{name} ({model} unter {host})", "step": { "auth": { "title": "Authentifiziere dich beim Ger\u00e4t" @@ -23,11 +24,23 @@ }, "title": "W\u00e4hle einen Namen f\u00fcr das Ger\u00e4t" }, + "reset": { + "description": "{name} ({model} unter {host}) ist gesperrt. Du musst das Ger\u00e4t entsperren, um dich zu authentifizieren und die Konfiguration abzuschlie\u00dfen. Anweisungen:\n1. \u00d6ffne die Broadlink-App.\n2. Klicke auf auf das Ger\u00e4t.\n3. Klicke oben rechts auf `...`.\n4. Scrolle zum unteren Ende der Seite.\n5. Deaktiviere die Sperre.", + "title": "Entsperren des Ger\u00e4ts" + }, + "unlock": { + "data": { + "unlock": "Ja mach das." + }, + "description": "{name} ({model} unter {host}) ist gesperrt. Dies kann zu Authentifizierungsproblemen im Home Assistant f\u00fchren. M\u00f6chtest du es entsperren?", + "title": "Entsperren des Ger\u00e4ts (optional)" + }, "user": { "data": { "host": "Host", "timeout": "Zeit\u00fcberschreitung" - } + }, + "title": "Verbinden mit dem Ger\u00e4t" } } } diff --git a/homeassistant/components/broadlink/translations/es-419.json b/homeassistant/components/broadlink/translations/es-419.json new file mode 100644 index 0000000000000..9c3129a2c6c0c --- /dev/null +++ b/homeassistant/components/broadlink/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "not_supported": "Dispositivo no compatible" + }, + "flow_title": "{name} ({model} en {host})", + "step": { + "auth": { + "title": "Autenticarse en el dispositivo" + }, + "finish": { + "title": "Elija un nombre para el dispositivo" + }, + "reset": { + "description": "{name} ({model} en {host}) est\u00e1 bloqueado. Debe desbloquear el dispositivo para autenticarse y completar la configuraci\u00f3n. Instrucciones:\n 1. Abra la aplicaci\u00f3n Broadlink.\n 2. Haga clic en el dispositivo.\n 3. Haga clic en `...` en la esquina superior derecha.\n 4. Despl\u00e1cese hasta el final de la p\u00e1gina.\n 5. Desactive el bloqueo.", + "title": "Desbloquear el dispositivo" + }, + "unlock": { + "data": { + "unlock": "S\u00ed, hazlo." + }, + "description": "{name} ({model} en {host}) est\u00e1 bloqueado. Esto puede provocar problemas de autenticaci\u00f3n en Home Assistant. \u00bfQuieres desbloquearlo?", + "title": "Desbloquear el dispositivo (opcional)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/broadlink/translations/es.json b/homeassistant/components/broadlink/translations/es.json index e7a35c2876f22..d0020c55bca51 100644 --- a/homeassistant/components/broadlink/translations/es.json +++ b/homeassistant/components/broadlink/translations/es.json @@ -38,7 +38,7 @@ "user": { "data": { "host": "Host", - "timeout": "Se acab\u00f3 el tiempo" + "timeout": "L\u00edmite de tiempo" }, "title": "Conectarse al dispositivo" } diff --git a/homeassistant/components/broadlink/translations/fr.json b/homeassistant/components/broadlink/translations/fr.json index 1d80059fb7a14..e39b722d8c9a4 100644 --- a/homeassistant/components/broadlink/translations/fr.json +++ b/homeassistant/components/broadlink/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Il y a d\u00e9j\u00e0 un processus de configuration en cours pour cet appareil", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", "not_supported": "Dispositif non pris en charge", diff --git a/homeassistant/components/broadlink/translations/he.json b/homeassistant/components/broadlink/translations/he.json new file mode 100644 index 0000000000000..a99f2f9876131 --- /dev/null +++ b/homeassistant/components/broadlink/translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name} ({model} \u05d1-{host})", + "step": { + "finish": { + "data": { + "name": "\u05e9\u05dd" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/broadlink/translations/hu.json b/homeassistant/components/broadlink/translations/hu.json index 90213e99aecfc..0bab0c1752fd3 100644 --- a/homeassistant/components/broadlink/translations/hu.json +++ b/homeassistant/components/broadlink/translations/hu.json @@ -2,9 +2,10 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", + "not_supported": "Az eszk\u00f6z nem t\u00e1mogatott", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { @@ -21,22 +22,22 @@ "data": { "name": "N\u00e9v" }, - "title": "V\u00e1lassz egy nevet az eszk\u00f6znek" + "title": "V\u00e1lasszonegy nevet az eszk\u00f6znek" }, "reset": { - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. A hiteles\u00edt\u00e9shez \u00e9s a konfigur\u00e1ci\u00f3 befejez\u00e9s\u00e9hez fel kell oldani az eszk\u00f6z z\u00e1rol\u00e1s\u00e1t. Utas\u00edt\u00e1sok:\n 1. Nyisd meg a Broadlink alkalmaz\u00e1st.\n 2. Kattints az eszk\u00f6zre.\n 3. Kattints a jobb fels\u0151 sarokban tal\u00e1lhat\u00f3 `...` gombra.\n 4. G\u00f6rgess az oldal alj\u00e1ra.\n 5. Kapcsold ki a z\u00e1rol\u00e1s\u00e1t.", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. A hiteles\u00edt\u00e9shez \u00e9s a konfigur\u00e1ci\u00f3 befejez\u00e9s\u00e9hez fel kell oldani az eszk\u00f6z z\u00e1rol\u00e1s\u00e1t. Utas\u00edt\u00e1sok:\n 1. Nyissa meg a Broadlink alkalmaz\u00e1st.\n 2. Kattintson az eszk\u00f6zre.\n 3. Kattintson a jobb fels\u0151 sarokban tal\u00e1lhat\u00f3 `...` gombra.\n 4. G\u00f6rgessen az oldal alj\u00e1ra.\n 5. Kapcsolja ki a z\u00e1rol\u00e1s\u00e1t.", "title": "Az eszk\u00f6z felold\u00e1sa" }, "unlock": { "data": { "unlock": "Igen, csin\u00e1ld." }, - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet a Home Assistantban. Szeretn\u00e9d feloldani?", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet Home Assistantban. Szeretn\u00e9 feloldani?", "title": "Az eszk\u00f6z felold\u00e1sa (opcion\u00e1lis)" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s" }, "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" diff --git a/homeassistant/components/broadlink/translations/it.json b/homeassistant/components/broadlink/translations/it.json index 3dcc07db87c12..e0056efceb718 100644 --- a/homeassistant/components/broadlink/translations/it.json +++ b/homeassistant/components/broadlink/translations/it.json @@ -16,17 +16,17 @@ "flow_title": "{name} ({model} presso {host})", "step": { "auth": { - "title": "Eseguire l'autenticazione al dispositivo" + "title": "Esegui l'autenticazione al dispositivo" }, "finish": { "data": { "name": "Nome" }, - "title": "Scegliere un nome per il dispositivo" + "title": "Scegli un nome per il dispositivo" }, "reset": { - "description": "{name} ( {model} su {host} ) \u00e8 bloccato. \u00c8 necessario sbloccare il dispositivo per autenticarsi e completare la configurazione. Istruzioni:\n 1. Apri l'app Broadlink.\n 2. Fare clic sul dispositivo.\n 3. Fare clic su \"...\" in alto a destra.\n 4. Scorri fino in fondo alla pagina.\n 5. Disabilitare il blocco.", - "title": "Sbloccare il dispositivo" + "description": "{name} ( {model} su {host} ) \u00e8 bloccato. Devi sbloccare il dispositivo per autenticarti e completare la configurazione. Istruzioni:\n 1. Apri l'app Broadlink.\n 2. Fai clic sul dispositivo.\n 3. Fai clic su \"...\" in alto a destra.\n 4. Scorri fino in fondo alla pagina.\n 5. Disabilita il blocco.", + "title": "Sblocca il dispositivo" }, "unlock": { "data": { diff --git a/homeassistant/components/broadlink/translations/ja.json b/homeassistant/components/broadlink/translations/ja.json new file mode 100644 index 0000000000000..37c08070d1843 --- /dev/null +++ b/homeassistant/components/broadlink/translations/ja.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "not_supported": "\u30c7\u30d0\u30a4\u30b9\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({model} at {host})", + "step": { + "auth": { + "title": "\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u8a8d\u8a3c" + }, + "finish": { + "data": { + "name": "\u540d\u524d" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u540d\u524d\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "reset": { + "description": "{name} ({model} \u306e {host}) \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u8a8d\u8a3c\u3057\u3066\u8a2d\u5b9a\u3092\u5b8c\u4e86\u3059\u308b\u306b\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u624b\u9806:\n1. Broadlink\u30a2\u30d7\u30ea\u3092\u958b\u304d\u307e\u3059\u3002\n2. \u30c7\u30d0\u30a4\u30b9\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n3. \u53f3\u4e0a\u306e`...`\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n4. \u30da\u30fc\u30b8\u306e\u4e00\u756a\u4e0b\u307e\u3067\u30b9\u30af\u30ed\u30fc\u30eb\u3057\u307e\u3059\u3002\n5. \u30ed\u30c3\u30af\u3092\u7121\u52b9\u306b\u3057\u307e\u3059\u3002", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664" + }, + "unlock": { + "data": { + "unlock": "\u306f\u3044\u3001\u3084\u308a\u307e\u3059\u3002" + }, + "description": "{name} ({model} \u306e {host}) \u304c\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u3053\u308c\u306b\u3088\u308a\u3001Home Assistant\u3067\u306e\u8a8d\u8a3c\u554f\u984c\u306b\u3064\u306a\u304c\u308b\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002\u30ed\u30c3\u30af\u3092\u89e3\u9664\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664(\u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "timeout": "\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/broadlink/translations/tr.json b/homeassistant/components/broadlink/translations/tr.json index d37a320347670..c14901a238d2d 100644 --- a/homeassistant/components/broadlink/translations/tr.json +++ b/homeassistant/components/broadlink/translations/tr.json @@ -4,32 +4,40 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "not_supported": "Cihaz desteklenmiyor", "unknown": "Beklenmeyen hata" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({model} at {host})", "step": { "auth": { "title": "Cihaza kimlik do\u011frulama" }, "finish": { + "data": { + "name": "Ad" + }, "title": "Cihaz i\u00e7in bir isim se\u00e7in" }, "reset": { + "description": "{name} ( {model} at {host} ) kilitli. Yap\u0131land\u0131rmay\u0131 do\u011frulamak ve tamamlamak i\u00e7in cihaz\u0131n kilidini a\u00e7man\u0131z gerekir. Talimatlar:\n 1. Broadlink uygulamas\u0131n\u0131 a\u00e7\u0131n.\n 2. Cihaza t\u0131klay\u0131n.\n 3. Sa\u011f \u00fcstteki `...` se\u00e7ene\u011fine t\u0131klay\u0131n.\n 4. Sayfan\u0131n en alt\u0131na gidin.\n 5. Kilidi devre d\u0131\u015f\u0131 b\u0131rak\u0131n.", "title": "Cihaz\u0131n kilidini a\u00e7\u0131n" }, "unlock": { "data": { "unlock": "Evet, yap." }, + "description": "{name} ( {model} at {host} ) kilitli. Bu, Home Assistant'ta kimlik do\u011frulama sorunlar\u0131na yol a\u00e7abilir. Kilidini a\u00e7mak ister misin?", "title": "Cihaz\u0131n kilidini a\u00e7\u0131n (iste\u011fe ba\u011fl\u0131)" }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "timeout": "Zaman a\u015f\u0131m\u0131" }, "title": "Cihaza ba\u011flan\u0131n" diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index a84eec07d6823..29020b1e90562 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -16,6 +16,7 @@ def get_update_manager(device): update_managers = { "A1": BroadlinkA1UpdateManager, "BG1": BroadlinkBG1UpdateManager, + "LB1": BroadlinkLB1UpdateManager, "MP1": BroadlinkMP1UpdateManager, "RM4MINI": BroadlinkRMUpdateManager, "RM4PRO": BroadlinkRMUpdateManager, @@ -175,3 +176,11 @@ class BroadlinkSP4UpdateManager(BroadlinkUpdateManager): async def async_fetch_data(self): """Fetch data from the device.""" return await self.device.async_request(self.device.api.get_state) + + +class BroadlinkLB1UpdateManager(BroadlinkUpdateManager): + """Manages updates for Broadlink LB1 devices.""" + + async def async_fetch_data(self): + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.get_state) diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 45053c74f03cf..ce715e991b002 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -8,14 +8,14 @@ import pysnmp.hlapi.asyncio as SnmpEngine from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 353c8b05ed547..39a196aa6cb63 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -9,9 +9,9 @@ import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN, PRINTER_TYPES from .utils import get_snmp_engine @@ -30,9 +30,9 @@ def host_valid(host: str) -> bool: if ipaddress.ip_address(host).version in [4, 6]: return True except ValueError: - disallowed = re.compile(r"[^a-zA-Z\d\-]") - return all(x and not disallowed.search(x) for x in host.split(".")) - return False + pass + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -80,18 +80,24 @@ async def async_step_user( ) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: brother.local. - self.host = discovery_info["hostname"].rstrip(".") + self.host = discovery_info.hostname.rstrip(".") + + # Do not probe the device if the host is already configured + self._async_abort_entries_match({CONF_HOST: self.host}) snmp_engine = get_snmp_engine(self.hass) + model = discovery_info.properties.get("product") - self.brother = Brother(self.host, snmp_engine=snmp_engine) try: + self.brother = Brother(self.host, snmp_engine=snmp_engine, model=model) await self.brother.async_update() - except (ConnectionError, SnmpError, UnsupportedModel): + except UnsupportedModel: + return self.async_abort(reason="unsupported_model") + except (ConnectionError, SnmpError): return self.async_abort(reason="cannot_connect") # Check if already configured diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 2a2e872482145..21f535ec1e4ff 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -3,221 +3,10 @@ from typing import Final -from homeassistant.const import PERCENTAGE - -from .model import SensorDescription - -ATTR_BELT_UNIT_REMAINING_LIFE: Final = "belt_unit_remaining_life" -ATTR_BLACK_DRUM_COUNTER: Final = "black_drum_counter" -ATTR_BLACK_DRUM_REMAINING_LIFE: Final = "black_drum_remaining_life" -ATTR_BLACK_DRUM_REMAINING_PAGES: Final = "black_drum_remaining_pages" -ATTR_BLACK_INK_REMAINING: Final = "black_ink_remaining" -ATTR_BLACK_TONER_REMAINING: Final = "black_toner_remaining" -ATTR_BW_COUNTER: Final = "b/w_counter" -ATTR_COLOR_COUNTER: Final = "color_counter" -ATTR_COUNTER: Final = "counter" -ATTR_CYAN_DRUM_COUNTER: Final = "cyan_drum_counter" -ATTR_CYAN_DRUM_REMAINING_LIFE: Final = "cyan_drum_remaining_life" -ATTR_CYAN_DRUM_REMAINING_PAGES: Final = "cyan_drum_remaining_pages" -ATTR_CYAN_INK_REMAINING: Final = "cyan_ink_remaining" -ATTR_CYAN_TONER_REMAINING: Final = "cyan_toner_remaining" -ATTR_DRUM_COUNTER: Final = "drum_counter" -ATTR_DRUM_REMAINING_LIFE: Final = "drum_remaining_life" -ATTR_DRUM_REMAINING_PAGES: Final = "drum_remaining_pages" -ATTR_DUPLEX_COUNTER: Final = "duplex_unit_pages_counter" -ATTR_FUSER_REMAINING_LIFE: Final = "fuser_remaining_life" -ATTR_LASER_REMAINING_LIFE: Final = "laser_remaining_life" -ATTR_MAGENTA_DRUM_COUNTER: Final = "magenta_drum_counter" -ATTR_MAGENTA_DRUM_REMAINING_LIFE: Final = "magenta_drum_remaining_life" -ATTR_MAGENTA_DRUM_REMAINING_PAGES: Final = "magenta_drum_remaining_pages" -ATTR_MAGENTA_INK_REMAINING: Final = "magenta_ink_remaining" -ATTR_MAGENTA_TONER_REMAINING: Final = "magenta_toner_remaining" -ATTR_MANUFACTURER: Final = "Brother" -ATTR_PAGE_COUNTER: Final = "page_counter" -ATTR_PF_KIT_1_REMAINING_LIFE: Final = "pf_kit_1_remaining_life" -ATTR_PF_KIT_MP_REMAINING_LIFE: Final = "pf_kit_mp_remaining_life" -ATTR_REMAINING_PAGES: Final = "remaining_pages" -ATTR_STATUS: Final = "status" -ATTR_UPTIME: Final = "uptime" -ATTR_YELLOW_DRUM_COUNTER: Final = "yellow_drum_counter" -ATTR_YELLOW_DRUM_REMAINING_LIFE: Final = "yellow_drum_remaining_life" -ATTR_YELLOW_DRUM_REMAINING_PAGES: Final = "yellow_drum_remaining_pages" -ATTR_YELLOW_INK_REMAINING: Final = "yellow_ink_remaining" -ATTR_YELLOW_TONER_REMAINING: Final = "yellow_toner_remaining" - DATA_CONFIG_ENTRY: Final = "config_entry" DOMAIN: Final = "brother" -UNIT_PAGES: Final = "p" - PRINTER_TYPES: Final = ["laser", "ink"] SNMP: Final = "snmp" - -ATTRS_MAP: Final[dict[str, tuple[str, str]]] = { - ATTR_DRUM_REMAINING_LIFE: (ATTR_DRUM_REMAINING_PAGES, ATTR_DRUM_COUNTER), - ATTR_BLACK_DRUM_REMAINING_LIFE: ( - ATTR_BLACK_DRUM_REMAINING_PAGES, - ATTR_BLACK_DRUM_COUNTER, - ), - ATTR_CYAN_DRUM_REMAINING_LIFE: ( - ATTR_CYAN_DRUM_REMAINING_PAGES, - ATTR_CYAN_DRUM_COUNTER, - ), - ATTR_MAGENTA_DRUM_REMAINING_LIFE: ( - ATTR_MAGENTA_DRUM_REMAINING_PAGES, - ATTR_MAGENTA_DRUM_COUNTER, - ), - ATTR_YELLOW_DRUM_REMAINING_LIFE: ( - ATTR_YELLOW_DRUM_REMAINING_PAGES, - ATTR_YELLOW_DRUM_COUNTER, - ), -} - -SENSOR_TYPES: Final[dict[str, SensorDescription]] = { - ATTR_STATUS: { - "icon": "mdi:printer", - "label": ATTR_STATUS.title(), - "unit": None, - "enabled": True, - }, - ATTR_PAGE_COUNTER: { - "icon": "mdi:file-document-outline", - "label": ATTR_PAGE_COUNTER.replace("_", " ").title(), - "unit": UNIT_PAGES, - "enabled": True, - }, - ATTR_BW_COUNTER: { - "icon": "mdi:file-document-outline", - "label": ATTR_BW_COUNTER.replace("_", " ").title(), - "unit": UNIT_PAGES, - "enabled": True, - }, - ATTR_COLOR_COUNTER: { - "icon": "mdi:file-document-outline", - "label": ATTR_COLOR_COUNTER.replace("_", " ").title(), - "unit": UNIT_PAGES, - "enabled": True, - }, - ATTR_DUPLEX_COUNTER: { - "icon": "mdi:file-document-outline", - "label": ATTR_DUPLEX_COUNTER.replace("_", " ").title(), - "unit": UNIT_PAGES, - "enabled": True, - }, - ATTR_DRUM_REMAINING_LIFE: { - "icon": "mdi:chart-donut", - "label": ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_BLACK_DRUM_REMAINING_LIFE: { - "icon": "mdi:chart-donut", - "label": ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_CYAN_DRUM_REMAINING_LIFE: { - "icon": "mdi:chart-donut", - "label": ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_MAGENTA_DRUM_REMAINING_LIFE: { - "icon": "mdi:chart-donut", - "label": ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_YELLOW_DRUM_REMAINING_LIFE: { - "icon": "mdi:chart-donut", - "label": ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_BELT_UNIT_REMAINING_LIFE: { - "icon": "mdi:current-ac", - "label": ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_FUSER_REMAINING_LIFE: { - "icon": "mdi:water-outline", - "label": ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_LASER_REMAINING_LIFE: { - "icon": "mdi:spotlight-beam", - "label": ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_PF_KIT_1_REMAINING_LIFE: { - "icon": "mdi:printer-3d", - "label": ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_PF_KIT_MP_REMAINING_LIFE: { - "icon": "mdi:printer-3d", - "label": ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_BLACK_TONER_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_CYAN_TONER_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_MAGENTA_TONER_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_YELLOW_TONER_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_BLACK_INK_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_CYAN_INK_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_MAGENTA_INK_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_YELLOW_INK_REMAINING: { - "icon": "mdi:printer-3d-nozzle", - "label": ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - "unit": PERCENTAGE, - "enabled": True, - }, - ATTR_UPTIME: { - "icon": None, - "label": ATTR_UPTIME.title(), - "unit": None, - "enabled": False, - }, -} diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index e2c1d4e9aff81..77a84c70de84d 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -3,7 +3,7 @@ "name": "Brother Printer", "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], - "requirements": ["brother==1.0.0"], + "requirements": ["brother==1.1.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brother/model.py b/homeassistant/components/brother/model.py deleted file mode 100644 index 22aa95eda508a..0000000000000 --- a/homeassistant/components/brother/model.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Type definitions for Brother integration.""" -from __future__ import annotations - -from typing import TypedDict - - -class SensorDescription(TypedDict): - """Sensor description class.""" - - icon: str | None - label: str - unit: str | None - enabled: bool diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 2f66e1c75d57e..a589ea0bd77f8 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -1,27 +1,86 @@ """Support for the Brother service.""" from __future__ import annotations -from typing import Any +from dataclasses import dataclass +from datetime import datetime +from typing import Any, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import CONF_HOST, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BrotherDataUpdateCoordinator -from .const import ( - ATTR_COUNTER, - ATTR_MANUFACTURER, - ATTR_REMAINING_PAGES, - ATTR_UPTIME, - ATTRS_MAP, - DATA_CONFIG_ENTRY, - DOMAIN, - SENSOR_TYPES, -) +from .const import DATA_CONFIG_ENTRY, DOMAIN + +ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" +ATTR_BLACK_DRUM_COUNTER = "black_drum_counter" +ATTR_BLACK_DRUM_REMAINING_LIFE = "black_drum_remaining_life" +ATTR_BLACK_DRUM_REMAINING_PAGES = "black_drum_remaining_pages" +ATTR_BLACK_INK_REMAINING = "black_ink_remaining" +ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" +ATTR_BW_COUNTER = "b/w_counter" +ATTR_COLOR_COUNTER = "color_counter" +ATTR_COUNTER = "counter" +ATTR_CYAN_DRUM_COUNTER = "cyan_drum_counter" +ATTR_CYAN_DRUM_REMAINING_LIFE = "cyan_drum_remaining_life" +ATTR_CYAN_DRUM_REMAINING_PAGES = "cyan_drum_remaining_pages" +ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining" +ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining" +ATTR_DRUM_COUNTER = "drum_counter" +ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life" +ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages" +ATTR_DUPLEX_COUNTER = "duplex_unit_pages_counter" +ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life" +ATTR_LASER_REMAINING_LIFE = "laser_remaining_life" +ATTR_MAGENTA_DRUM_COUNTER = "magenta_drum_counter" +ATTR_MAGENTA_DRUM_REMAINING_LIFE = "magenta_drum_remaining_life" +ATTR_MAGENTA_DRUM_REMAINING_PAGES = "magenta_drum_remaining_pages" +ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining" +ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining" +ATTR_MANUFACTURER = "Brother" +ATTR_PAGE_COUNTER = "page_counter" +ATTR_PF_KIT_1_REMAINING_LIFE = "pf_kit_1_remaining_life" +ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life" +ATTR_REMAINING_PAGES = "remaining_pages" +ATTR_STATUS = "status" +ATTR_UPTIME = "uptime" +ATTR_YELLOW_DRUM_COUNTER = "yellow_drum_counter" +ATTR_YELLOW_DRUM_REMAINING_LIFE = "yellow_drum_remaining_life" +ATTR_YELLOW_DRUM_REMAINING_PAGES = "yellow_drum_remaining_pages" +ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining" +ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" + +UNIT_PAGES = "p" + +ATTRS_MAP: dict[str, tuple[str, str]] = { + ATTR_DRUM_REMAINING_LIFE: (ATTR_DRUM_REMAINING_PAGES, ATTR_DRUM_COUNTER), + ATTR_BLACK_DRUM_REMAINING_LIFE: ( + ATTR_BLACK_DRUM_REMAINING_PAGES, + ATTR_BLACK_DRUM_COUNTER, + ), + ATTR_CYAN_DRUM_REMAINING_LIFE: ( + ATTR_CYAN_DRUM_REMAINING_PAGES, + ATTR_CYAN_DRUM_COUNTER, + ), + ATTR_MAGENTA_DRUM_REMAINING_LIFE: ( + ATTR_MAGENTA_DRUM_REMAINING_PAGES, + ATTR_MAGENTA_DRUM_COUNTER, + ), + ATTR_YELLOW_DRUM_REMAINING_LIFE: ( + ATTR_YELLOW_DRUM_REMAINING_PAGES, + ATTR_YELLOW_DRUM_COUNTER, + ), +} async def async_setup_entry( @@ -32,17 +91,20 @@ async def async_setup_entry( sensors = [] - device_info: DeviceInfo = { - "identifiers": {(DOMAIN, coordinator.data.serial)}, - "name": coordinator.data.model, - "manufacturer": ATTR_MANUFACTURER, - "model": coordinator.data.model, - "sw_version": getattr(coordinator.data, "firmware", None), - } - - for sensor in SENSOR_TYPES: - if sensor in coordinator.data: - sensors.append(BrotherPrinterSensor(coordinator, sensor, device_info)) + device_info = DeviceInfo( + configuration_url=f"http://{entry.data[CONF_HOST]}/", + identifiers={(DOMAIN, coordinator.data.serial)}, + manufacturer=ATTR_MANUFACTURER, + model=coordinator.data.model, + name=coordinator.data.model, + sw_version=getattr(coordinator.data, "firmware", None), + ) + + for description in SENSOR_TYPES: + if description.key in coordinator.data: + sensors.append( + description.entity_class(coordinator, description, device_info) + ) async_add_entities(sensors, False) @@ -52,41 +114,30 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): def __init__( self, coordinator: BrotherDataUpdateCoordinator, - kind: str, + description: BrotherSensorEntityDescription, device_info: DeviceInfo, ) -> None: """Initialize.""" super().__init__(coordinator) - self._description = SENSOR_TYPES[kind] - self._name = f"{coordinator.data.model} {self._description['label']}" - self._unique_id = f"{coordinator.data.serial.lower()}_{kind}" - self._device_info = device_info - self.kind = kind self._attrs: dict[str, Any] = {} + self._attr_device_info = device_info + self._attr_name = f"{coordinator.data.model} {description.name}" + self._attr_unique_id = f"{coordinator.data.serial.lower()}_{description.key}" + self.entity_description = description @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def state(self) -> Any: + def native_value(self) -> StateType | datetime: """Return the state.""" - if self.kind == ATTR_UPTIME: - return getattr(self.coordinator.data, self.kind).isoformat() - return getattr(self.coordinator.data, self.kind) - - @property - def device_class(self) -> str | None: - """Return the class of this sensor.""" - if self.kind == ATTR_UPTIME: - return DEVICE_CLASS_TIMESTAMP - return None + return cast( + StateType, getattr(self.coordinator.data, self.entity_description.key) + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - remaining_pages, drum_counter = ATTRS_MAP.get(self.kind, (None, None)) + remaining_pages, drum_counter = ATTRS_MAP.get( + self.entity_description.key, (None, None) + ) if remaining_pages and drum_counter: self._attrs[ATTR_REMAINING_PAGES] = getattr( self.coordinator.data, remaining_pages @@ -94,27 +145,214 @@ def extra_state_attributes(self) -> dict[str, Any]: self._attrs[ATTR_COUNTER] = getattr(self.coordinator.data, drum_counter) return self._attrs - @property - def icon(self) -> str | None: - """Return the icon.""" - return self._description["icon"] - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return self._unique_id +class BrotherPrinterUptimeSensor(BrotherPrinterSensor): + """Define an Brother Printer Uptime sensor.""" @property - def unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._description["unit"] + def native_value(self) -> datetime: + """Return the state.""" + return cast( + datetime, getattr(self.coordinator.data, self.entity_description.key) + ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._description["enabled"] +@dataclass +class BrotherSensorEntityDescription(SensorEntityDescription): + """A class that describes sensor entities.""" + + entity_class: type[BrotherPrinterSensor] = BrotherPrinterSensor + + +SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( + BrotherSensorEntityDescription( + key=ATTR_STATUS, + icon="mdi:printer", + name=ATTR_STATUS.title(), + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_PAGE_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_PAGE_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BW_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_BW_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_COLOR_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_COLOR_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_DUPLEX_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BLACK_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_CYAN_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_YELLOW_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BELT_UNIT_REMAINING_LIFE, + icon="mdi:current-ac", + name=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_FUSER_REMAINING_LIFE, + icon="mdi:water-outline", + name=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_LASER_REMAINING_LIFE, + icon="mdi:spotlight-beam", + name=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_PF_KIT_1_REMAINING_LIFE, + icon="mdi:printer-3d", + name=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_PF_KIT_MP_REMAINING_LIFE, + icon="mdi:printer-3d", + name=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BLACK_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_CYAN_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_MAGENTA_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_YELLOW_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BLACK_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_CYAN_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_MAGENTA_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_YELLOW_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_UPTIME, + name=ATTR_UPTIME.title(), + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=BrotherPrinterUptimeSensor, + ), +) diff --git a/homeassistant/components/brother/translations/bg.json b/homeassistant/components/brother/translations/bg.json new file mode 100644 index 0000000000000..4a51b0abde110 --- /dev/null +++ b/homeassistant/components/brother/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "wrong_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441." + }, + "flow_title": "{model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/ca.json b/homeassistant/components/brother/translations/ca.json index 7d90fd8510d8e..689495478bb1a 100644 --- a/homeassistant/components/brother/translations/ca.json +++ b/homeassistant/components/brother/translations/ca.json @@ -9,7 +9,7 @@ "snmp_error": "El servidor SNMP s'ha tancat o la impressora no \u00e9s compatible.", "wrong_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids." }, - "flow_title": "Impressora Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/de.json b/homeassistant/components/brother/translations/de.json index c2a7ae8ec76b7..8126a04f21d57 100644 --- a/homeassistant/components/brother/translations/de.json +++ b/homeassistant/components/brother/translations/de.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_configured": "Dieser Drucker ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "unsupported_model": "Dieses Druckermodell wird nicht unterst\u00fctzt." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", "snmp_error": "SNMP-Server deaktiviert oder Drucker nicht unterst\u00fctzt.", - "wrong_host": " Ung\u00fcltiger Hostname oder IP-Adresse" + "wrong_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, - "flow_title": "Brother-Drucker: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { @@ -22,7 +22,7 @@ "data": { "type": "Typ des Druckers" }, - "description": "M\u00f6chten Sie den Brother Drucker {model} mit der Seriennummer `{serial_number}` zum Home Assistant hinzuf\u00fcgen?", + "description": "M\u00f6chtest du den Brother Drucker {model} mit der Seriennummer `{serial_number}` zum Home Assistant hinzuf\u00fcgen?", "title": "Brother-Drucker entdeckt" } } diff --git a/homeassistant/components/brother/translations/et.json b/homeassistant/components/brother/translations/et.json index 7b2b7c1b4a5ab..1115e5cb3caa9 100644 --- a/homeassistant/components/brother/translations/et.json +++ b/homeassistant/components/brother/translations/et.json @@ -9,7 +9,7 @@ "snmp_error": "SNMP-server on v\u00e4lja l\u00fclitatud v\u00f5i printerit ei toetata.", "wrong_host": "Sobimatu hostinimi v\u00f5i IP-aadress." }, - "flow_title": "Brotheri printer: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/fr.json b/homeassistant/components/brother/translations/fr.json index 5eb00bb44472c..ada9ca7385d36 100644 --- a/homeassistant/components/brother/translations/fr.json +++ b/homeassistant/components/brother/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cette imprimante est d\u00e9j\u00e0 configur\u00e9e.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "unsupported_model": "Ce mod\u00e8le d'imprimante n'est pas pris en charge." }, "error": { @@ -9,11 +9,11 @@ "snmp_error": "Serveur SNMP d\u00e9sactiv\u00e9 ou imprimante non prise en charge.", "wrong_host": "Nom d'h\u00f4te ou adresse IP invalide." }, - "flow_title": "Imprimante Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "type": "Type d'imprimante" }, "description": "Configurez l'int\u00e9gration de l'imprimante Brother. Si vous avez des probl\u00e8mes avec la configuration, allez \u00e0 : https://www.home-assistant.io/integrations/brother" diff --git a/homeassistant/components/brother/translations/he.json b/homeassistant/components/brother/translations/he.json new file mode 100644 index 0000000000000..af3f5750ddb0f --- /dev/null +++ b/homeassistant/components/brother/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "wrong_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd." + }, + "flow_title": "{model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/hu.json b/homeassistant/components/brother/translations/hu.json index ae950f58f7271..f0218dc264791 100644 --- a/homeassistant/components/brother/translations/hu.json +++ b/homeassistant/components/brother/translations/hu.json @@ -9,11 +9,11 @@ "snmp_error": "Az SNMP szerver ki van kapcsolva, vagy a nyomtat\u00f3 nem t\u00e1mogatott.", "wrong_host": "\u00c9rv\u00e9nytelen \u00e1llom\u00e1sn\u00e9v vagy IP-c\u00edm." }, - "flow_title": "Brother nyomtat\u00f3: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "type": "A nyomtat\u00f3 t\u00edpusa" }, "description": "A Brother nyomtat\u00f3 integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha probl\u00e9m\u00e1id vannak a konfigur\u00e1ci\u00f3val, l\u00e1togass el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/brother" @@ -22,7 +22,7 @@ "data": { "type": "A nyomtat\u00f3 t\u00edpusa" }, - "description": "Hozz\u00e1 akarja adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: {serial_number} `, a Home Assistant-hoz?", + "description": "Hozz\u00e1 szeretn\u00e9 adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: `{serial_number}`, Home Assistanthoz?", "title": "Felfedezett Brother nyomtat\u00f3" } } diff --git a/homeassistant/components/brother/translations/id.json b/homeassistant/components/brother/translations/id.json index 5e0b562017ceb..ed02999710ee6 100644 --- a/homeassistant/components/brother/translations/id.json +++ b/homeassistant/components/brother/translations/id.json @@ -9,7 +9,7 @@ "snmp_error": "Server SNMP dimatikan atau printer tidak didukung.", "wrong_host": "Nama host atau alamat IP tidak valid." }, - "flow_title": "Printer Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/it.json b/homeassistant/components/brother/translations/it.json index 06decb261731c..29059723159f9 100644 --- a/homeassistant/components/brother/translations/it.json +++ b/homeassistant/components/brother/translations/it.json @@ -9,14 +9,14 @@ "snmp_error": "Server SNMP spento o stampante non supportata.", "wrong_host": "Nome host o indirizzo IP non valido." }, - "flow_title": "Stampante Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { "host": "Host", "type": "Tipo di stampante" }, - "description": "Configurare l'integrazione della stampante Brother. In caso di problemi con la configurazione, visitare: https://www.home-assistant.io/integrations/brother" + "description": "Configura l'integrazione della stampante Brother. In caso di problemi con la configurazione, visita: https://www.home-assistant.io/integrations/brother" }, "zeroconf_confirm": { "data": { diff --git a/homeassistant/components/brother/translations/ja.json b/homeassistant/components/brother/translations/ja.json new file mode 100644 index 0000000000000..6c0e1e57767bf --- /dev/null +++ b/homeassistant/components/brother/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unsupported_model": "\u3053\u306e\u30d7\u30ea\u30f3\u30bf\u30fc\u30e2\u30c7\u30eb\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "snmp_error": "SNMP\u30b5\u30fc\u30d0\u30fc\u304c\u30aa\u30d5\u306b\u306a\u3063\u3066\u3044\u308b\u304b\u3001\u30d7\u30ea\u30f3\u30bf\u30fc\u304c\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "wrong_host": "\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u304c\u7121\u52b9\u3067\u3059\u3002" + }, + "flow_title": "{model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "type": "\u30d7\u30ea\u30f3\u30bf\u30fc\u306e\u7a2e\u985e" + }, + "description": "\u30d6\u30e9\u30b6\u30fc\u793e\u88fd\u30d7\u30ea\u30f3\u30bf\u30fc\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u95a2\u3057\u3066\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/brother \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "zeroconf_confirm": { + "data": { + "type": "\u30d7\u30ea\u30f3\u30bf\u30fc\u306e\u7a2e\u985e" + }, + "description": "Brother\u793e\u306e\u30d7\u30ea\u30f3\u30bf\u30fc {model} \u3067\u3001\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u304c `{serial_number}` \u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30d6\u30e9\u30b6\u30fc\u30d7\u30ea\u30f3\u30bf\u30fc\u3092\u767a\u898b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/nl.json b/homeassistant/components/brother/translations/nl.json index 531038d827b8c..6440be77e75a5 100644 --- a/homeassistant/components/brother/translations/nl.json +++ b/homeassistant/components/brother/translations/nl.json @@ -9,7 +9,7 @@ "snmp_error": "SNMP-server uitgeschakeld of printer wordt niet ondersteund.", "wrong_host": "Ongeldige hostnaam of IP-adres." }, - "flow_title": "Brother Printer: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/no.json b/homeassistant/components/brother/translations/no.json index 923d1afe68d21..9d3618cad6286 100644 --- a/homeassistant/components/brother/translations/no.json +++ b/homeassistant/components/brother/translations/no.json @@ -9,7 +9,7 @@ "snmp_error": "SNMP verten er skrudd av eller printeren er ikke st\u00f8ttet.", "wrong_host": "Ugyldig vertsnavn eller IP-adresse." }, - "flow_title": "Brother skriver: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/pl.json b/homeassistant/components/brother/translations/pl.json index c4c1c3d7d7a06..20031bf94ad83 100644 --- a/homeassistant/components/brother/translations/pl.json +++ b/homeassistant/components/brother/translations/pl.json @@ -9,7 +9,7 @@ "snmp_error": "Serwer SNMP wy\u0142\u0105czony lub drukarka nie jest obs\u0142ugiwana", "wrong_host": "Niepoprawna nazwa hosta lub adres IP drukarki" }, - "flow_title": "Drukarka Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/ru.json b/homeassistant/components/brother/translations/ru.json index 6c90cd374a845..6fd15e30ac370 100644 --- a/homeassistant/components/brother/translations/ru.json +++ b/homeassistant/components/brother/translations/ru.json @@ -9,7 +9,7 @@ "snmp_error": "\u0421\u0435\u0440\u0432\u0435\u0440 SNMP \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d \u0438\u043b\u0438 \u043f\u0440\u0438\u043d\u0442\u0435\u0440 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", "wrong_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441." }, - "flow_title": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brother/translations/tr.json b/homeassistant/components/brother/translations/tr.json index cd91a4852527a..c989844c6f746 100644 --- a/homeassistant/components/brother/translations/tr.json +++ b/homeassistant/components/brother/translations/tr.json @@ -5,17 +5,24 @@ "unsupported_model": "Bu yaz\u0131c\u0131 modeli desteklenmiyor." }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "snmp_error": "SNMP sunucusu kapal\u0131 veya yaz\u0131c\u0131 desteklenmiyor.", + "wrong_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi." }, "flow_title": "Brother Yaz\u0131c\u0131: {model} {serial_number}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "type": "Yaz\u0131c\u0131n\u0131n t\u00fcr\u00fc" - } + }, + "description": "Brother yaz\u0131c\u0131 entegrasyonunu ayarlay\u0131n. Yap\u0131land\u0131rmayla ilgili sorunlar\u0131n\u0131z varsa \u015fu adrese gidin: https://www.home-assistant.io/integrations/brother" }, "zeroconf_confirm": { + "data": { + "type": "Yaz\u0131c\u0131n\u0131n t\u00fcr\u00fc" + }, + "description": "Seri numaras\u0131 ` {serial_number} ` olan Brother Yaz\u0131c\u0131 {model} }'i Home Assistant'a eklemek ister misiniz?", "title": "Ke\u015ffedilen Brother Yaz\u0131c\u0131" } } diff --git a/homeassistant/components/brother/translations/zh-Hans.json b/homeassistant/components/brother/translations/zh-Hans.json index 8f9e85e54a925..91e0c310dd11d 100644 --- a/homeassistant/components/brother/translations/zh-Hans.json +++ b/homeassistant/components/brother/translations/zh-Hans.json @@ -1,8 +1,23 @@ { "config": { + "abort": { + "unsupported_model": "\u4e0d\u652f\u6301\u6b64\u6253\u5370\u673a\u578b\u53f7\u3002" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "snmp_error": "SNMP\u670d\u52a1\u5668\u5df2\u5173\u95ed\u6216\u4e0d\u652f\u6301\u6253\u5370\u3002" + }, + "step": { + "user": { + "description": "\u8bbe\u7f6e Brother \u6253\u5370\u673a\u96c6\u6210\u3002\u5982\u679c\u60a8\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/brother" + }, + "zeroconf_confirm": { + "data": { + "type": "\u6253\u5370\u673a\u7c7b\u578b" + }, + "description": "\u60a8\u662f\u5426\u8981\u5c06 Brother \u6253\u5370\u673a {model} (\u5e8f\u5217\u53f7:`{serial_number}`) \u6dfb\u52a0\u5230 Home Assistant ?", + "title": "\u5df2\u53d1\u73b0\u7684 Brother \u6253\u5370\u673a" + } } } } \ No newline at end of file diff --git a/homeassistant/components/brother/translations/zh-Hant.json b/homeassistant/components/brother/translations/zh-Hant.json index 80555f52e8d4e..88bd481749fac 100644 --- a/homeassistant/components/brother/translations/zh-Hant.json +++ b/homeassistant/components/brother/translations/zh-Hant.json @@ -9,7 +9,7 @@ "snmp_error": "SNMP \u4f3a\u670d\u5668\u70ba\u95dc\u9589\u72c0\u614b\u6216\u5370\u8868\u6a5f\u4e0d\u652f\u63f4\u3002", "wrong_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740" }, - "flow_title": "Brother \u5370\u8868\u6a5f\uff1a{model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 32af96dfe604a..22d9ea8e5d854 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -82,25 +82,8 @@ class BrottsplatskartanSensor(SensorEntity): def __init__(self, bpk, name): """Initialize the Brottsplatskartan sensor.""" - self._attributes = {} self._brottsplatskartan = bpk - self._name = name - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes + self._attr_name = name def update(self): """Update device state.""" @@ -116,6 +99,8 @@ def update(self): incident_type = incident.get("title_type") incident_counts[incident_type] += 1 - self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION} - self._attributes.update(incident_counts) - self._state = len(incidents) + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION + } + self._attr_extra_state_attributes.update(incident_counts) + self._attr_native_value = len(incidents) diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py index f89d57cdec1ae..988a96ce08e8b 100644 --- a/homeassistant/components/brunt/__init__.py +++ b/homeassistant/components/brunt/__init__.py @@ -1 +1,78 @@ """The brunt component.""" +from __future__ import annotations + +import logging + +from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError +import async_timeout +from brunt import BruntClientAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_BAPI, DATA_COOR, DOMAIN, PLATFORMS, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Brunt using config flow.""" + session = async_get_clientsession(hass) + bapi = BruntClientAsync( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) + try: + await bapi.async_login() + except ServerDisconnectedError as exc: + raise ConfigEntryNotReady("Brunt not ready to connect.") from exc + except ClientResponseError as exc: + raise ConfigEntryAuthFailed( + f"Brunt could not connect with username: {entry.data[CONF_USERNAME]}." + ) from exc + + async def async_update_data(): + """Fetch data from the Brunt endpoint for all Things. + + Error 403 is the API response for any kind of authentication error (failed password or email) + Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account. + """ + try: + async with async_timeout.timeout(10): + things = await bapi.async_get_things(force=True) + return {thing.serial: thing for thing in things} + except ServerDisconnectedError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + except ClientResponseError as err: + if err.status == 403: + raise ConfigEntryAuthFailed() from err + if err.status == 401: + _LOGGER.warning("Device not found, will reload Brunt integration") + await hass.config_entries.async_reload(entry.entry_id) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="brunt", + update_method=async_update_data, + update_interval=REGULAR_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_BAPI: bapi, DATA_COOR: coordinator} + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py new file mode 100644 index 0000000000000..636a9affddd22 --- /dev/null +++ b/homeassistant/components/brunt/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for brunt integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientResponseError +from aiohttp.client_exceptions import ServerDisconnectedError +from brunt import BruntClientAsync +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + + +async def validate_input(user_input: dict[str, Any]) -> dict[str, str] | None: + """Login to the brunt api and return errors if any.""" + errors = None + bapi = BruntClientAsync( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + try: + await bapi.async_login() + except ClientResponseError as exc: + if exc.status == 403: + _LOGGER.warning("Brunt Credentials are incorrect") + errors = {"base": "invalid_auth"} + else: + _LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc) + errors = {"base": "unknown"} + except ServerDisconnectedError: + _LOGGER.warning("Cannot connect to Brunt") + errors = {"base": "cannot_connect"} + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc) + errors = {"base": "unknown"} + finally: + await bapi.async_close() + return errors + + +class BruntConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brunt.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = await validate_input(user_input) + if errors is not None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + username = self._reauth_entry.data[CONF_USERNAME] + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"username": username}, + ) + user_input[CONF_USERNAME] = username + errors = await validate_input(user_input) + if errors is not None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={"username": username}, + ) + + self.hass.config_entries.async_update_entry(self._reauth_entry, data=user_input) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import config from configuration.yaml.""" + await self.async_set_unique_id(import_config[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return await self.async_step_user(import_config) diff --git a/homeassistant/components/brunt/const.py b/homeassistant/components/brunt/const.py new file mode 100644 index 0000000000000..cc85ac9a4154b --- /dev/null +++ b/homeassistant/components/brunt/const.py @@ -0,0 +1,19 @@ +"""Constants for Brunt.""" +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "brunt" +ATTR_REQUEST_POSITION = "request_position" +NOTIFICATION_ID = "brunt_notification" +NOTIFICATION_TITLE = "Brunt Cover Setup" +ATTRIBUTION = "Based on an unofficial Brunt SDK." +PLATFORMS = [Platform.COVER] +DATA_BAPI = "bapi" +DATA_COOR = "coordinator" + +CLOSED_POSITION = 0 +OPEN_POSITION = 100 + +REGULAR_INTERVAL = timedelta(seconds=20) +FAST_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 9c539fe51fe1c..d3efdce0a5b8c 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,105 +1,136 @@ """Support for Brunt Blind Engine covers.""" +from __future__ import annotations +from collections.abc import MutableMapping import logging +from typing import Any -from brunt import BruntAPI -import voluptuous as vol +from aiohttp.client_exceptions import ClientResponseError +from brunt import BruntClientAsync, Thing from homeassistant.components.cover import ( ATTR_POSITION, - DEVICE_CLASS_WINDOW, - PLATFORM_SCHEMA, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, + CoverDeviceClass, CoverEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + ATTR_REQUEST_POSITION, + ATTRIBUTION, + CLOSED_POSITION, + DATA_BAPI, + DATA_COOR, + DOMAIN, + FAST_INTERVAL, + OPEN_POSITION, + REGULAR_INTERVAL, +) _LOGGER = logging.getLogger(__name__) COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION -ATTR_REQUEST_POSITION = "request_position" -NOTIFICATION_ID = "brunt_notification" -NOTIFICATION_TITLE = "Brunt Cover Setup" -ATTRIBUTION = "Based on an unofficial Brunt SDK." - -CLOSED_POSITION = 0 -OPEN_POSITION = 100 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Component setup, run import config flow for each entry in config.""" + _LOGGER.warning( + "Loading brunt via platform config is deprecated; The configuration has been migrated to a config entry and can be safely removed from configuration.yaml" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the brunt platform.""" + bapi: BruntClientAsync = hass.data[DOMAIN][entry.entry_id][DATA_BAPI] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COOR] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - - bapi = BruntAPI(username=username, password=password) - try: - things = bapi.getThings()["things"] - if not things: - _LOGGER.error("No things present in account") - else: - add_entities( - [ - BruntDevice(bapi, thing["NAME"], thing["thingUri"]) - for thing in things - ], - True, - ) - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - hass.components.persistent_notification.create( - "Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) + async_add_entities( + BruntDevice(coordinator, serial, thing, bapi, entry.entry_id) + for serial, thing in coordinator.data.items() + ) -class BruntDevice(CoverEntity): +class BruntDevice(CoordinatorEntity, CoverEntity): """ Representation of a Brunt cover device. Contains the common logic for all Brunt devices. """ - def __init__(self, bapi, name, thing_uri): + def __init__( + self, + coordinator: DataUpdateCoordinator, + serial: str, + thing: Thing, + bapi: BruntClientAsync, + entry_id: str, + ) -> None: """Init the Brunt device.""" + super().__init__(coordinator) + self._attr_unique_id = serial self._bapi = bapi - self._name = name - self._thing_uri = thing_uri - - self._state = {} - self._available = None - - @property - def name(self): - """Return the name of the device as reported by tellcore.""" - return self._name + self._thing = thing + self._entry_id = entry_id + + self._remove_update_listener = None + + self._attr_name = self._thing.name + self._attr_device_class = CoverDeviceClass.BLIND + self._attr_supported_features = COVER_FEATURES + self._attr_attribution = ATTRIBUTION + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=self._attr_name, + via_device=(DOMAIN, self._entry_id), + manufacturer="Brunt", + sw_version=self._thing.fw_version, + model=self._thing.model, + ) - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener(self._brunt_update_listener) + ) @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. None is unknown, 0 is closed, 100 is fully open. """ - pos = self._state.get("currentPosition") - return int(pos) if pos else None + return self.coordinator.data[self.unique_id].current_position @property - def request_cover_position(self): + def request_cover_position(self) -> int | None: """ Return request position of cover. @@ -107,71 +138,72 @@ def request_cover_position(self): to Brunt, at times there is a diff of 1 to current None is unknown, 0 is closed, 100 is fully open. """ - pos = self._state.get("requestPosition") - return int(pos) if pos else None + return self.coordinator.data[self.unique_id].request_position @property - def move_state(self): + def move_state(self) -> int | None: """ Return current moving state of cover. None is unknown, 0 when stopped, 1 when opening, 2 when closing """ - mov = self._state.get("moveState") - return int(mov) if mov else None + return self.coordinator.data[self.unique_id].move_state @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self.move_state == 1 @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self.move_state == 2 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> MutableMapping[str, Any]: """Return the detailed device state attributes.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_REQUEST_POSITION: self.request_cover_position, } @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_WINDOW - - @property - def supported_features(self): - """Flag supported features.""" - return COVER_FEATURES - - @property - def is_closed(self): + def is_closed(self) -> bool: """Return true if cover is closed, else False.""" return self.current_cover_position == CLOSED_POSITION - def update(self): - """Poll the current state of the device.""" - try: - self._state = self._bapi.getState(thingUri=self._thing_uri).get("thing") - self._available = True - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - self._available = False - - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Set the cover to the open position.""" - self._bapi.changeRequestPosition(OPEN_POSITION, thingUri=self._thing_uri) + await self._async_update_cover(OPEN_POSITION) - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Set the cover to the closed position.""" - self._bapi.changeRequestPosition(CLOSED_POSITION, thingUri=self._thing_uri) + await self._async_update_cover(CLOSED_POSITION) - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover to a specific position.""" - self._bapi.changeRequestPosition( - kwargs[ATTR_POSITION], thingUri=self._thing_uri - ) + await self._async_update_cover(int(kwargs[ATTR_POSITION])) + + async def _async_update_cover(self, position: int) -> None: + """Set the cover to the new position and wait for the update to be reflected.""" + try: + await self._bapi.async_change_request_position( + position, thing_uri=self._thing.thing_uri + ) + except ClientResponseError as exc: + raise HomeAssistantError( + f"Unable to reposition {self._thing.name}" + ) from exc + self.coordinator.update_interval = FAST_INTERVAL + await self.coordinator.async_request_refresh() + + @callback + def _brunt_update_listener(self) -> None: + """Update the update interval after each refresh.""" + if ( + self.request_cover_position + == self._bapi.last_requested_positions[self._thing.thing_uri] + and self.move_state == 0 + ): + self.coordinator.update_interval = REGULAR_INTERVAL + else: + self.coordinator.update_interval = FAST_INTERVAL diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json index ba7d1ba117df7..f970419b7871e 100644 --- a/homeassistant/components/brunt/manifest.json +++ b/homeassistant/components/brunt/manifest.json @@ -1,8 +1,9 @@ { "domain": "brunt", "name": "Brunt Blind Engine", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brunt", - "requirements": ["brunt==0.1.3"], + "requirements": ["brunt==1.1.0"], "codeowners": ["@eavanvalkenburg"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/brunt/strings.json b/homeassistant/components/brunt/strings.json new file mode 100644 index 0000000000000..37b2f95bc0821 --- /dev/null +++ b/homeassistant/components/brunt/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your Brunt integration", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please reenter the password for: {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } + } \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/bg.json b/homeassistant/components/brunt/translations/bg.json new file mode 100644 index 0000000000000..71737c4fb2627 --- /dev/null +++ b/homeassistant/components/brunt/translations/bg.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0437\u0430: {username}", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/ca.json b/homeassistant/components/brunt/translations/ca.json new file mode 100644 index 0000000000000..9a1c657af7ce3 --- /dev/null +++ b/homeassistant/components/brunt/translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Torna a introduir la contrasenya de: {username}", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "title": "Configuraci\u00f3 de la integraci\u00f3 Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/de.json b/homeassistant/components/brunt/translations/de.json new file mode 100644 index 0000000000000..efbc9b437e187 --- /dev/null +++ b/homeassistant/components/brunt/translations/de.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte gib das Passwort f\u00fcr {username} erneut ein:", + "title": "Integration erneut authentifizieren" + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "title": "Richte deine Brunt-Integration ein" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/en.json b/homeassistant/components/brunt/translations/en.json new file mode 100644 index 0000000000000..82fdda368221b --- /dev/null +++ b/homeassistant/components/brunt/translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please reenter the password for: {username}", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Setup your Brunt integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/et.json b/homeassistant/components/brunt/translations/et.json new file mode 100644 index 0000000000000..0d283b8a664c3 --- /dev/null +++ b/homeassistant/components/brunt/translations/et.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Sisesta salas\u00f5na uuesti: {username}", + "title": "Taastuvasta sidumine" + }, + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "title": "Seadista oma Brunti sidumine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/fr.json b/homeassistant/components/brunt/translations/fr.json new file mode 100644 index 0000000000000..611a57ea313ed --- /dev/null +++ b/homeassistant/components/brunt/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe" + }, + "description": "Veuillez saisir \u00e0 nouveau le mot de passe pour\u00a0: {username}", + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "title": "Configurez votre int\u00e9gration Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/he.json b/homeassistant/components/brunt/translations/he.json new file mode 100644 index 0000000000000..d6636c6f86540 --- /dev/null +++ b/homeassistant/components/brunt/translations/he.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/hu.json b/homeassistant/components/brunt/translations/hu.json new file mode 100644 index 0000000000000..3abb5cbf297da --- /dev/null +++ b/homeassistant/components/brunt/translations/hu.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rem, adja meg \u00fajra a jelsz\u00f3t: {felhaszn\u00e1l\u00f3n\u00e9v}", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "A Brunt integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/id.json b/homeassistant/components/brunt/translations/id.json new file mode 100644 index 0000000000000..21b4d381ed588 --- /dev/null +++ b/homeassistant/components/brunt/translations/id.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Masukkan kembali kata sandi untuk: {username}", + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "title": "Siapkan integrasi Brunt Anda" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/it.json b/homeassistant/components/brunt/translations/it.json new file mode 100644 index 0000000000000..7b6d11836d0a0 --- /dev/null +++ b/homeassistant/components/brunt/translations/it.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Reinserisci la password per: {username}", + "title": "Autentica nuovamente l'integrazione" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Configura la tua integrazione Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/ja.json b/homeassistant/components/brunt/translations/ja.json new file mode 100644 index 0000000000000..a0c477443b81d --- /dev/null +++ b/homeassistant/components/brunt/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u518d\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044: {username}", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Brunt\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/lt.json b/homeassistant/components/brunt/translations/lt.json new file mode 100644 index 0000000000000..98e6719deb2da --- /dev/null +++ b/homeassistant/components/brunt/translations/lt.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Slapta\u017eodis" + } + }, + "user": { + "data": { + "password": "Slapta\u017eodis", + "username": "Slapyvardis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/nl.json b/homeassistant/components/brunt/translations/nl.json new file mode 100644 index 0000000000000..86a7df6a58520 --- /dev/null +++ b/homeassistant/components/brunt/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord opnieuw in voor: {username}", + "title": "Verifieer de integratie opnieuw" + }, + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Stel uw Brunt-integratie in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/no.json b/homeassistant/components/brunt/translations/no.json new file mode 100644 index 0000000000000..b77151ac92ace --- /dev/null +++ b/homeassistant/components/brunt/translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Skriv inn passordet p\u00e5 nytt for: {username}", + "title": "Godkjenne integrering p\u00e5 nytt" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "title": "Konfigurer Brunt-integrasjonen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/pl.json b/homeassistant/components/brunt/translations/pl.json new file mode 100644 index 0000000000000..61c5f95269a24 --- /dev/null +++ b/homeassistant/components/brunt/translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + }, + "description": "Wprowad\u017a ponownie has\u0142o dla: {username}:", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Konfiguracja integracji Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/ru.json b/homeassistant/components/brunt/translations/ru.json new file mode 100644 index 0000000000000..1adcd8906d351 --- /dev/null +++ b/homeassistant/components/brunt/translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f {username}.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "title": "Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/sl.json b/homeassistant/components/brunt/translations/sl.json new file mode 100644 index 0000000000000..2a39d333e2f39 --- /dev/null +++ b/homeassistant/components/brunt/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Geslo" + }, + "description": "Ponovno vnesite geslo za: {username}" + }, + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "title": "Nastavite Brunt integracijo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/tr.json b/homeassistant/components/brunt/translations/tr.json new file mode 100644 index 0000000000000..95875b3de674f --- /dev/null +++ b/homeassistant/components/brunt/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "L\u00fctfen \u015fifreyi tekrar girin: {username}", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Brunt entegrasyonunuzu kurun" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/zh-Hant.json b/homeassistant/components/brunt/translations/zh-Hant.json new file mode 100644 index 0000000000000..960fea4967b65 --- /dev/null +++ b/homeassistant/components/brunt/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u91cd\u65b0\u8f38\u5165\u5bc6\u78bc\uff1a{username}", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u8a2d\u5b9a Brunt \u6574\u5408" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 6c6c8a183360c..7ada5b01e46ad 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -3,9 +3,14 @@ from bsblan import BSBLan, BSBLanConnectionError -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -14,7 +19,7 @@ SCAN_INTERVAL = timedelta(seconds=30) -PLATFORMS = [CLIMATE_DOMAIN] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 7533e7e07f9e3..d7f4972c59e00 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -20,24 +20,12 @@ SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - ATTR_TEMPERATURE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_TARGET_TEMPERATURE, - DATA_BSBLAN_CLIENT, - DOMAIN, -) +from .const import ATTR_TARGET_TEMPERATURE, DATA_BSBLAN_CLIENT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -88,96 +76,44 @@ async def async_setup_entry( class BSBLanClimate(ClimateEntity): """Defines a BSBLan climate device.""" + _attr_supported_features = SUPPORT_FLAGS + _attr_hvac_modes = HVAC_MODES + _attr_preset_modes = PRESET_MODES + def __init__( self, entry_id: str, bsblan: BSBLan, info: Info, - ): + ) -> None: """Initialize BSBLan climate device.""" - self._current_temperature: float | None = None - self._available = True - self._hvac_mode: str | None = None - self._target_temperature: float | None = None - self._temperature_unit = None - self._preset_mode = None + self._attr_available = True self._store_hvac_mode = None - self._info: Info = info self.bsblan = bsblan - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._info.device_identification - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._info.device_identification - - @property - def temperature_unit(self) -> str: - """Return the unit of measurement which this thermostat uses.""" - if self._temperature_unit == "°C": - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_FLAGS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def hvac_mode(self): - """Return the current operation mode.""" - return self._hvac_mode - - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return HVAC_MODES - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_modes(self): - """List of available preset modes.""" - return PRESET_MODES - - @property - def preset_mode(self): - """Return the preset_mode.""" - return self._preset_mode + self._attr_name = self._attr_unique_id = info.device_identification + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, info.device_identification)}, + manufacturer="BSBLan", + model=info.controller_variant, + name="BSBLan Device", + ) async def async_set_preset_mode(self, preset_mode): """Set preset mode.""" _LOGGER.debug("Setting preset mode to: %s", preset_mode) if preset_mode == PRESET_NONE: # restore previous hvac mode - self._hvac_mode = self._store_hvac_mode + self._attr_hvac_mode = self._store_hvac_mode else: # Store hvac mode. - self._store_hvac_mode = self._hvac_mode + self._store_hvac_mode = self._attr_hvac_mode await self.async_set_data(preset_mode=preset_mode) async def async_set_hvac_mode(self, hvac_mode): """Set HVAC mode.""" _LOGGER.debug("Setting HVAC mode to: %s", hvac_mode) # preset should be none when hvac mode is set - self._preset_mode = PRESET_NONE + self._attr_preset_mode = PRESET_NONE await self.async_set_data(hvac_mode=hvac_mode) async def async_set_temperature(self, **kwargs): @@ -204,39 +140,33 @@ async def async_set_data(self, **kwargs: Any) -> None: await self.bsblan.thermostat(**data) except BSBLanError: _LOGGER.error("An error occurred while updating the BSBLan device") - self._available = False + self._attr_available = False async def async_update(self) -> None: """Update BSBlan entity.""" try: state: State = await self.bsblan.state() except BSBLanError: - if self._available: + if self.available: _LOGGER.error("An error occurred while updating the BSBLan device") - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True - self._current_temperature = float(state.current_temperature.value) - self._target_temperature = float(state.target_temperature.value) + self._attr_current_temperature = float(state.current_temperature.value) + self._attr_target_temperature = float(state.target_temperature.value) # check if preset is active else get hvac mode _LOGGER.debug("state hvac/preset mode: %s", state.hvac_mode.value) if state.hvac_mode.value == "2": - self._preset_mode = PRESET_ECO + self._attr_preset_mode = PRESET_ECO else: - self._hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] - self._preset_mode = PRESET_NONE - - self._temperature_unit = state.current_temperature.unit - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this BSBLan device.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._info.device_identification)}, - ATTR_NAME: "BSBLan Device", - ATTR_MANUFACTURER: "BSBLan", - ATTR_MODEL: self._info.controller_variant, - } + self._attr_hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] + self._attr_preset_mode = PRESET_NONE + + self._attr_temperature_unit = ( + TEMP_CELSIUS + if state.current_temperature.unit == "°C" + else TEMP_FAHRENHEIT + ) diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 88866b2780130..dff7773910662 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from bsblan import BSBLan, BSBLanError, Info import voluptuous as vol @@ -10,7 +11,6 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from .const import CONF_DEVICE_IDENT, CONF_PASSKEY, DOMAIN @@ -22,7 +22,9 @@ class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if user_input is None: return self._show_setup_form() diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 1dd461e2081fe..0dc2e15a7b46d 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -1,26 +1,23 @@ """Constants for the BSB-Lan integration.""" +from typing import Final DOMAIN = "bsblan" -DATA_BSBLAN_CLIENT = "bsblan_client" -DATA_BSBLAN_TIMER = "bsblan_timer" -DATA_BSBLAN_UPDATED = "bsblan_updated" +DATA_BSBLAN_CLIENT: Final = "bsblan_client" +DATA_BSBLAN_TIMER: Final = "bsblan_timer" +DATA_BSBLAN_UPDATED: Final = "bsblan_updated" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MODEL = "model" -ATTR_MANUFACTURER = "manufacturer" +ATTR_TARGET_TEMPERATURE: Final = "target_temperature" +ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature" +ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" -ATTR_TARGET_TEMPERATURE = "target_temperature" -ATTR_INSIDE_TEMPERATURE = "inside_temperature" -ATTR_OUTSIDE_TEMPERATURE = "outside_temperature" +ATTR_STATE_ON: Final = "on" +ATTR_STATE_OFF: Final = "off" -ATTR_STATE_ON = "on" -ATTR_STATE_OFF = "off" +CONF_DEVICE_IDENT: Final = "device_identification" +CONF_CONTROLLER_FAM: Final = "controller_family" +CONF_CONTROLLER_VARI: Final = "controller_variant" -CONF_DEVICE_IDENT = "device_identification" -CONF_CONTROLLER_FAM = "controller_family" -CONF_CONTROLLER_VARI = "controller_variant" +SENSOR_TYPE_TEMPERATURE: Final = "temperature" -SENSOR_TYPE_TEMPERATURE = "temperature" - -CONF_PASSKEY = "passkey" +CONF_PASSKEY: Final = "passkey" diff --git a/homeassistant/components/bsblan/translations/bg.json b/homeassistant/components/bsblan/translations/bg.json new file mode 100644 index 0000000000000..09a1668e14237 --- /dev/null +++ b/homeassistant/components/bsblan/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/ca.json b/homeassistant/components/bsblan/translations/ca.json index e217787ba19b7..7bcae685f35e5 100644 --- a/homeassistant/components/bsblan/translations/ca.json +++ b/homeassistant/components/bsblan/translations/ca.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -16,7 +16,7 @@ "port": "Port", "username": "Nom d'usuari" }, - "description": "Configura un dispositiu BSB-Lan per a integrar-lo amb Home Assistant.", + "description": "Configura la integraci\u00f3 d'un dispositiu BSB-Lan amb Home Assistant.", "title": "Connexi\u00f3 amb dispositiu BSB-Lan" } } diff --git a/homeassistant/components/bsblan/translations/de.json b/homeassistant/components/bsblan/translations/de.json index d1400529b0b84..079749f0f7a61 100644 --- a/homeassistant/components/bsblan/translations/de.json +++ b/homeassistant/components/bsblan/translations/de.json @@ -6,14 +6,14 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { "host": "Host", "passkey": "Passkey String", "password": "Passwort", - "port": "Port Nummer", + "port": "Port", "username": "Benutzername" }, "description": "Richte dein BSB-Lan Ger\u00e4t f\u00fcr die Integration mit dem Home Assistant ein.", diff --git a/homeassistant/components/bsblan/translations/et.json b/homeassistant/components/bsblan/translations/et.json index 22ff91e7e1b49..e024326127177 100644 --- a/homeassistant/components/bsblan/translations/et.json +++ b/homeassistant/components/bsblan/translations/et.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/fr.json b/homeassistant/components/bsblan/translations/fr.json index 0c54aecdd88f8..dda5e5c293c14 100644 --- a/homeassistant/components/bsblan/translations/fr.json +++ b/homeassistant/components/bsblan/translations/fr.json @@ -6,11 +6,11 @@ "error": { "cannot_connect": "\u00c9chec de connexion" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "passkey": "Cha\u00eene de cl\u00e9 d'acc\u00e8s", "password": "Mot de passe", "port": "Port", diff --git a/homeassistant/components/bsblan/translations/he.json b/homeassistant/components/bsblan/translations/he.json new file mode 100644 index 0000000000000..2c183d1ac247c --- /dev/null +++ b/homeassistant/components/bsblan/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/hu.json b/homeassistant/components/bsblan/translations/hu.json index 50d250cc384dd..60a781cc758a5 100644 --- a/homeassistant/components/bsblan/translations/hu.json +++ b/homeassistant/components/bsblan/translations/hu.json @@ -6,15 +6,18 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", + "passkey": "Jelsz\u00f3 karakterl\u00e1nc", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "\u00c1ll\u00edtsa be a BSB-Lan eszk\u00f6zt az HomeAssistantba val\u00f3 integr\u00e1ci\u00f3hoz.", + "title": "Csatlakoz\u00e1s a BSB-Lan eszk\u00f6zh\u00f6z" } } } diff --git a/homeassistant/components/bsblan/translations/id.json b/homeassistant/components/bsblan/translations/id.json index 6e8ac0bd4cbbf..83fdb88aae4ff 100644 --- a/homeassistant/components/bsblan/translations/id.json +++ b/homeassistant/components/bsblan/translations/id.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/it.json b/homeassistant/components/bsblan/translations/it.json index 3eb7feec6140b..fa7874630b0c1 100644 --- a/homeassistant/components/bsblan/translations/it.json +++ b/homeassistant/components/bsblan/translations/it.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Impossibile connettersi" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/ja.json b/homeassistant/components/bsblan/translations/ja.json new file mode 100644 index 0000000000000..3aa85a17cf87e --- /dev/null +++ b/homeassistant/components/bsblan/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "passkey": "\u30d1\u30b9\u30ad\u30fc\u6587\u5b57\u5217", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "BSB-Lan\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "BSB-Lan device\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/nl.json b/homeassistant/components/bsblan/translations/nl.json index 415cd759a8a88..e07f6bc25f049 100644 --- a/homeassistant/components/bsblan/translations/nl.json +++ b/homeassistant/components/bsblan/translations/nl.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Kan geen verbinding maken" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/no.json b/homeassistant/components/bsblan/translations/no.json index 40981e2b77ca9..043477ed3f92e 100644 --- a/homeassistant/components/bsblan/translations/no.json +++ b/homeassistant/components/bsblan/translations/no.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/pl.json b/homeassistant/components/bsblan/translations/pl.json index 5cf79db3fbaf4..3667c2432bfca 100644 --- a/homeassistant/components/bsblan/translations/pl.json +++ b/homeassistant/components/bsblan/translations/pl.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/ru.json b/homeassistant/components/bsblan/translations/ru.json index 8291a20d3078a..27297418983f7 100644 --- a/homeassistant/components/bsblan/translations/ru.json +++ b/homeassistant/components/bsblan/translations/ru.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -16,7 +16,7 @@ "port": "\u041f\u043e\u0440\u0442", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BSB-Lan.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BSB-Lan.", "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" } } diff --git a/homeassistant/components/bsblan/translations/tr.json b/homeassistant/components/bsblan/translations/tr.json index 803b5102a073c..28df383fbc563 100644 --- a/homeassistant/components/bsblan/translations/tr.json +++ b/homeassistant/components/bsblan/translations/tr.json @@ -6,14 +6,18 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "password": "\u015eifre", + "host": "Sunucu", + "passkey": "Ge\u00e7i\u015f anahtar\u0131 dizesi", + "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 ad\u0131" - } + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "BSB-Lan cihaz\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n.", + "title": "BSB-Lan cihaz\u0131na ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/bsblan/translations/zh-Hant.json b/homeassistant/components/bsblan/translations/zh-Hant.json index ebe0ca6237081..54a6a8067cf52 100644 --- a/homeassistant/components/bsblan/translations/zh-Hant.json +++ b/homeassistant/components/bsblan/translations/zh-Hant.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "BSB-Lan\uff1a{name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 32b8e2aa0507c..ec852f0c31bad 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST @@ -16,7 +16,7 @@ CONF_DEFAULT_IP = "192.168.1.254" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string} ) diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 107eb5598d9d2..a4c8717e4449f 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST @@ -18,7 +18,7 @@ CONF_DEFAULT_IP = "192.168.1.254" CONF_SMARTHUB_MODEL = "smarthub_model" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, vol.Optional(CONF_SMARTHUB_MODEL): vol.In([1, 2]), @@ -59,8 +59,7 @@ def __init__(self, smarthub_client): self.success_init = False # Test the router is accessible - data = self.get_bt_smarthub_data() - if data: + if self.get_bt_smarthub_data(): self.success_init = True else: _LOGGER.info("Failed to connect to %s", self.smarthub.router_ip) @@ -85,8 +84,7 @@ def _update_info(self): return _LOGGER.info("Scanning") - data = self.get_bt_smarthub_data() - if not data: + if not (data := self.get_bt_smarthub_data()): _LOGGER.warning("Error scanning devices") return self.last_results = data diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py index 0474876bf2fde..64b94cfbaa863 100644 --- a/homeassistant/components/buienradar/__init__.py +++ b/homeassistant/components/buienradar/__init__.py @@ -1,46 +1,18 @@ """The buienradar integration.""" from __future__ import annotations -import logging - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -from homeassistant.helpers.typing import ConfigType - -from .const import ( - CONF_COUNTRY, - CONF_DELTA, - CONF_DIMENSION, - CONF_TIMEFRAME, - DEFAULT_COUNTRY, - DEFAULT_DELTA, - DEFAULT_DIMENSION, - DEFAULT_TIMEFRAME, - DOMAIN, -) -PLATFORMS = ["camera", "sensor", "weather"] +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the buienradar component.""" - hass.data.setdefault(DOMAIN, {}) - - weather_configs = _filter_domain_configs(config, "weather", DOMAIN) - sensor_configs = _filter_domain_configs(config, "sensor", DOMAIN) - camera_configs = _filter_domain_configs(config, "camera", DOMAIN) - - _import_configs(hass, weather_configs, sensor_configs, camera_configs) - - return True +PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up buienradar from a config entry.""" + hass.data.setdefault(DOMAIN, {}) hass.config_entries.async_setup_platforms(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True @@ -56,86 +28,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) - - -def _import_configs( - hass: HomeAssistant, - weather_configs: list[ConfigType], - sensor_configs: list[ConfigType], - camera_configs: list[ConfigType], -) -> None: - camera_config = {} - if camera_configs: - camera_config = camera_configs[0] - - for config in sensor_configs: - # Remove weather configurations which share lat/lon with sensor configurations - matching_weather_config = None - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - for weather_config in weather_configs: - weather_latitude = config.get(CONF_LATITUDE, hass.config.latitude) - weather_longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - if latitude == weather_latitude and longitude == weather_longitude: - matching_weather_config = weather_config - break - - if matching_weather_config is not None: - weather_configs.remove(matching_weather_config) - - configs = weather_configs + sensor_configs - - if not configs and camera_configs: - config = { - CONF_LATITUDE: hass.config.latitude, - CONF_LONGITUDE: hass.config.longitude, - } - configs.append(config) - - if configs: - _try_update_unique_id(hass, configs[0], camera_config) - - for config in configs: - data = { - CONF_LATITUDE: config.get(CONF_LATITUDE, hass.config.latitude), - CONF_LONGITUDE: config.get(CONF_LONGITUDE, hass.config.longitude), - CONF_TIMEFRAME: config.get(CONF_TIMEFRAME, DEFAULT_TIMEFRAME), - CONF_COUNTRY: camera_config.get(CONF_COUNTRY, DEFAULT_COUNTRY), - CONF_DELTA: camera_config.get(CONF_DELTA, DEFAULT_DELTA), - CONF_NAME: config.get(CONF_NAME, "Buienradar"), - } - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - ) - - -def _try_update_unique_id( - hass: HomeAssistant, config: ConfigType, camera_config: ConfigType -) -> None: - dimension = camera_config.get(CONF_DIMENSION, DEFAULT_DIMENSION) - country = camera_config.get(CONF_COUNTRY, DEFAULT_COUNTRY) - - registry = entity_registry.async_get(hass) - entity_id = registry.async_get_entity_id("camera", DOMAIN, f"{dimension}_{country}") - - if entity_id is not None: - latitude = config[CONF_LATITUDE] - longitude = config[CONF_LONGITUDE] - - new_unique_id = f"{latitude:2.6f}{longitude:2.6f}" - registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - -def _filter_domain_configs( - config: ConfigType, domain: str, platform: str -) -> list[ConfigType]: - configs = [] - for entry in config: - if entry.startswith(domain): - configs += [x for x in config[entry] if x["platform"] == platform] - return configs diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 1a2d6d4d0bef6..e68b096ca05f6 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -8,19 +8,17 @@ import aiohttp import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import dt as dt_util from .const import ( CONF_COUNTRY, CONF_DELTA, - CONF_DIMENSION, DEFAULT_COUNTRY, DEFAULT_DELTA, DEFAULT_DIMENSION, @@ -34,29 +32,9 @@ # Multiple choice for available Radar Map URL SUPPORTED_COUNTRY_CODES = ["NL", "BE"] -PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE, - vol.Optional(CONF_DELTA, default=600.0): cv.positive_float, - vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string, - vol.Optional(CONF_COUNTRY, default="NL"): vol.All( - vol.Coerce(str), vol.In(SUPPORTED_COUNTRY_CODES) - ), - } - ) -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up buienradar camera platform.""" - _LOGGER.warning( - "Platform configuration is deprecated, will be removed in a future release" - ) - async def async_setup_entry( - hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up buienradar radar-loop camera component.""" config = entry.data @@ -153,8 +131,7 @@ async def __retrieve_radar_image(self) -> bool: _LOGGER.debug("HTTP 304 - success") return True - last_modified = res.headers.get("Last-Modified") - if last_modified: + if last_modified := res.headers.get("Last-Modified"): self._last_modified = last_modified self._last_image = await res.read() @@ -165,7 +142,9 @@ async def __retrieve_radar_image(self) -> bool: _LOGGER.error("Failed to fetch image, %s", type(err)) return False - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """ Return a still image response from the camera. diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index e773b39027ec5..445c6cacbc862 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -1,7 +1,6 @@ """Config flow for buienradar integration.""" from __future__ import annotations -import logging from typing import Any import voluptuous as vol @@ -24,8 +23,6 @@ SUPPORTED_COUNTRY_CODES, ) -_LOGGER = logging.getLogger(__name__) - class BuienradarFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for buienradar.""" @@ -70,18 +67,6 @@ async def async_step_user( errors={}, ) - async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - latitude = import_input[CONF_LATITUDE] - longitude = import_input[CONF_LONGITUDE] - - await self.async_set_unique_id(f"{latitude}-{longitude}") - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=f"{latitude},{longitude}", data=import_input - ) - class BuienradarOptionFlowHandler(config_entries.OptionsFlow): """Handle options.""" diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index cc785512f9b87..6af579dd74f65 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -7,15 +7,10 @@ DEFAULT_DIMENSION = 700 DEFAULT_DELTA = 600 -CONF_DIMENSION = "dimension" CONF_DELTA = "delta" CONF_COUNTRY = "country_code" CONF_TIMEFRAME = "timeframe" -"""Range according to the docs""" -CAMERA_DIM_MIN = 120 -CAMERA_DIM_MAX = 700 - SUPPORTED_COUNTRY_CODES = ["NL", "BE"] DEFAULT_COUNTRY = "NL" diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index d7759aa9b8d61..f88bfb83ddf26 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -3,7 +3,7 @@ "name": "Buienradar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/buienradar", - "requirements": ["buienradar==1.0.4"], + "requirements": ["buienradar==1.0.5"], "codeowners": ["@mjj4791", "@ties", "@Robbie1221"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index e4a317cfacedb..0f1044cec9615 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -1,4 +1,6 @@ """Support for Buienradar.nl weather service.""" +from __future__ import annotations + import logging from buienradar.constants import ( @@ -18,15 +20,17 @@ WINDGUST, WINDSPEED, ) -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, IRRADIATION_WATTS_PER_SQUARE_METER, @@ -39,7 +43,6 @@ TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -57,163 +60,584 @@ # When an error occurred, new call after (minutes): SCHEDULE_NOK = 2 -# Supported sensor types: -# Key: ['label', unit, icon] -SENSOR_TYPES = { - "stationname": ["Stationname", None, None], +STATIONNAME_LABEL = "Stationname" + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="stationname", + name=STATIONNAME_LABEL, + ), # new in json api (>1.0.0): - "barometerfc": ["Barometer value", None, "mdi:gauge"], + SensorEntityDescription( + key="barometerfc", + name="Barometer value", + icon="mdi:gauge", + ), # new in json api (>1.0.0): - "barometerfcname": ["Barometer", None, "mdi:gauge"], + SensorEntityDescription( + key="barometerfcname", + name="Barometer", + icon="mdi:gauge", + ), # new in json api (>1.0.0): - "barometerfcnamenl": ["Barometer", None, "mdi:gauge"], - "condition": ["Condition", None, None], - "conditioncode": ["Condition code", None, None], - "conditiondetailed": ["Detailed condition", None, None], - "conditionexact": ["Full condition", None, None], - "symbol": ["Symbol", None, None], + SensorEntityDescription( + key="barometerfcnamenl", + name="Barometer", + icon="mdi:gauge", + ), + SensorEntityDescription( + key="condition", + name="Condition", + ), + SensorEntityDescription( + key="conditioncode", + name="Condition code", + ), + SensorEntityDescription( + key="conditiondetailed", + name="Detailed condition", + ), + SensorEntityDescription( + key="conditionexact", + name="Full condition", + ), + SensorEntityDescription( + key="symbol", + name="Symbol", + ), # new in json api (>1.0.0): - "feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"], - "humidity": ["Humidity", PERCENTAGE, "mdi:water-percent"], - "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], - "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windforce": ["Wind force", "Bft", "mdi:weather-windy"], - "winddirection": ["Wind direction", None, "mdi:compass-outline"], - "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline"], - "pressure": ["Pressure", PRESSURE_HPA, "mdi:gauge"], - "visibility": ["Visibility", LENGTH_KILOMETERS, None], - "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "precipitation": [ - "Precipitation", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-pouring", - ], - "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"], - "precipitation_forecast_average": [ - "Precipitation forecast average", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-pouring", - ], - "precipitation_forecast_total": [ - "Precipitation forecast total", - LENGTH_MILLIMETERS, - "mdi:weather-pouring", - ], + SensorEntityDescription( + key="feeltemperature", + name="Feel temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="groundtemperature", + name="Ground temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="windspeed", + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce", + name="Wind force", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="winddirection", + name="Wind direction", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth", + name="Wind direction azimuth", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="visibility", + name="Visibility", + native_unit_of_measurement=LENGTH_KILOMETERS, + ), + SensorEntityDescription( + key="windgust", + name="Wind gust", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="precipitation", + name="Precipitation", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="irradiance", + name="Irradiance", + native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, + icon="mdi:sunglasses", + ), + SensorEntityDescription( + key="precipitation_forecast_average", + name="Precipitation forecast average", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="precipitation_forecast_total", + name="Precipitation forecast total", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "rainlast24hour": ["Rain last 24h", LENGTH_MILLIMETERS, "mdi:weather-pouring"], + SensorEntityDescription( + key="rainlast24hour", + name="Rain last 24h", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "rainlasthour": ["Rain last hour", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "temperature_1d": ["Temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_2d": ["Temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_3d": ["Temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_4d": ["Temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_5d": ["Temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_1d": ["Minimum temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_2d": ["Minimum temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_3d": ["Minimum temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_4d": ["Minimum temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_5d": ["Minimum temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], - "rain_1d": ["Rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_2d": ["Rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_3d": ["Rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_4d": ["Rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_5d": ["Rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], + SensorEntityDescription( + key="rainlasthour", + name="Rain last hour", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="temperature_1d", + name="Temperature 1d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_2d", + name="Temperature 2d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_3d", + name="Temperature 3d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_4d", + name="Temperature 4d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_5d", + name="Temperature 5d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_1d", + name="Minimum temperature 1d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_2d", + name="Minimum temperature 2d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_3d", + name="Minimum temperature 3d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_4d", + name="Minimum temperature 4d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_5d", + name="Minimum temperature 5d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="rain_1d", + name="Rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_2d", + name="Rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_3d", + name="Rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_4d", + name="Rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_5d", + name="Rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "minrain_1d": ["Minimum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_2d": ["Minimum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_3d": ["Minimum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_4d": ["Minimum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_5d": ["Minimum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], + SensorEntityDescription( + key="minrain_1d", + name="Minimum rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_2d", + name="Minimum rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_3d", + name="Minimum rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_4d", + name="Minimum rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_5d", + name="Minimum rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "maxrain_1d": ["Maximum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_2d": ["Maximum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_3d": ["Maximum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_4d": ["Maximum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_5d": ["Maximum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rainchance_1d": ["Rainchance 1d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_2d": ["Rainchance 2d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_3d": ["Rainchance 3d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_4d": ["Rainchance 4d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_5d": ["Rainchance 5d", PERCENTAGE, "mdi:weather-pouring"], - "sunchance_1d": ["Sunchance 1d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_2d": ["Sunchance 2d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_3d": ["Sunchance 3d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_4d": ["Sunchance 4d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_5d": ["Sunchance 5d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy"], - "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy"], - "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], - "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], - "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], - "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], - "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], - "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], - "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline"], - "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline"], - "windazimuth_1d": ["Wind direction azimuth 1d", DEGREE, "mdi:compass-outline"], - "windazimuth_2d": ["Wind direction azimuth 2d", DEGREE, "mdi:compass-outline"], - "windazimuth_3d": ["Wind direction azimuth 3d", DEGREE, "mdi:compass-outline"], - "windazimuth_4d": ["Wind direction azimuth 4d", DEGREE, "mdi:compass-outline"], - "windazimuth_5d": ["Wind direction azimuth 5d", DEGREE, "mdi:compass-outline"], - "condition_1d": ["Condition 1d", None, None], - "condition_2d": ["Condition 2d", None, None], - "condition_3d": ["Condition 3d", None, None], - "condition_4d": ["Condition 4d", None, None], - "condition_5d": ["Condition 5d", None, None], - "conditioncode_1d": ["Condition code 1d", None, None], - "conditioncode_2d": ["Condition code 2d", None, None], - "conditioncode_3d": ["Condition code 3d", None, None], - "conditioncode_4d": ["Condition code 4d", None, None], - "conditioncode_5d": ["Condition code 5d", None, None], - "conditiondetailed_1d": ["Detailed condition 1d", None, None], - "conditiondetailed_2d": ["Detailed condition 2d", None, None], - "conditiondetailed_3d": ["Detailed condition 3d", None, None], - "conditiondetailed_4d": ["Detailed condition 4d", None, None], - "conditiondetailed_5d": ["Detailed condition 5d", None, None], - "conditionexact_1d": ["Full condition 1d", None, None], - "conditionexact_2d": ["Full condition 2d", None, None], - "conditionexact_3d": ["Full condition 3d", None, None], - "conditionexact_4d": ["Full condition 4d", None, None], - "conditionexact_5d": ["Full condition 5d", None, None], - "symbol_1d": ["Symbol 1d", None, None], - "symbol_2d": ["Symbol 2d", None, None], - "symbol_3d": ["Symbol 3d", None, None], - "symbol_4d": ["Symbol 4d", None, None], - "symbol_5d": ["Symbol 5d", None, None], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=["symbol", "temperature"] - ): vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES.keys())]), - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - vol.Optional(CONF_TIMEFRAME, default=DEFAULT_TIMEFRAME): vol.All( - vol.Coerce(int), vol.Range(min=5, max=120) - ), - vol.Optional(CONF_NAME, default="br"): cv.string, - } + SensorEntityDescription( + key="maxrain_1d", + name="Maximum rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_2d", + name="Maximum rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_3d", + name="Maximum rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_4d", + name="Maximum rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_5d", + name="Maximum rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_1d", + name="Rainchance 1d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_2d", + name="Rainchance 2d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_3d", + name="Rainchance 3d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_4d", + name="Rainchance 4d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_5d", + name="Rainchance 5d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="sunchance_1d", + name="Sunchance 1d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_2d", + name="Sunchance 2d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_3d", + name="Sunchance 3d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_4d", + name="Sunchance 4d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_5d", + name="Sunchance 5d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="windforce_1d", + name="Wind force 1d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_2d", + name="Wind force 2d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_3d", + name="Wind force 3d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_4d", + name="Wind force 4d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_5d", + name="Wind force 5d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_1d", + name="Wind speed 1d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_2d", + name="Wind speed 2d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_3d", + name="Wind speed 3d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_4d", + name="Wind speed 4d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_5d", + name="Wind speed 5d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="winddirection_1d", + name="Wind direction 1d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_2d", + name="Wind direction 2d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_3d", + name="Wind direction 3d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_4d", + name="Wind direction 4d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_5d", + name="Wind direction 5d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_1d", + name="Wind direction azimuth 1d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_2d", + name="Wind direction azimuth 2d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_3d", + name="Wind direction azimuth 3d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_4d", + name="Wind direction azimuth 4d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_5d", + name="Wind direction azimuth 5d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="condition_1d", + name="Condition 1d", + ), + SensorEntityDescription( + key="condition_2d", + name="Condition 2d", + ), + SensorEntityDescription( + key="condition_3d", + name="Condition 3d", + ), + SensorEntityDescription( + key="condition_4d", + name="Condition 4d", + ), + SensorEntityDescription( + key="condition_5d", + name="Condition 5d", + ), + SensorEntityDescription( + key="conditioncode_1d", + name="Condition code 1d", + ), + SensorEntityDescription( + key="conditioncode_2d", + name="Condition code 2d", + ), + SensorEntityDescription( + key="conditioncode_3d", + name="Condition code 3d", + ), + SensorEntityDescription( + key="conditioncode_4d", + name="Condition code 4d", + ), + SensorEntityDescription( + key="conditioncode_5d", + name="Condition code 5d", + ), + SensorEntityDescription( + key="conditiondetailed_1d", + name="Detailed condition 1d", + ), + SensorEntityDescription( + key="conditiondetailed_2d", + name="Detailed condition 2d", + ), + SensorEntityDescription( + key="conditiondetailed_3d", + name="Detailed condition 3d", + ), + SensorEntityDescription( + key="conditiondetailed_4d", + name="Detailed condition 4d", + ), + SensorEntityDescription( + key="conditiondetailed_5d", + name="Detailed condition 5d", + ), + SensorEntityDescription( + key="conditionexact_1d", + name="Full condition 1d", + ), + SensorEntityDescription( + key="conditionexact_2d", + name="Full condition 2d", + ), + SensorEntityDescription( + key="conditionexact_3d", + name="Full condition 3d", + ), + SensorEntityDescription( + key="conditionexact_4d", + name="Full condition 4d", + ), + SensorEntityDescription( + key="conditionexact_5d", + name="Full condition 5d", + ), + SensorEntityDescription( + key="symbol_1d", + name="Symbol 1d", + ), + SensorEntityDescription( + key="symbol_2d", + name="Symbol 2d", + ), + SensorEntityDescription( + key="symbol_3d", + name="Symbol 3d", + ), + SensorEntityDescription( + key="symbol_4d", + name="Symbol 4d", + ), + SensorEntityDescription( + key="symbol_5d", + name="Symbol 5d", + ), ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up buienradar sensor platform.""" - _LOGGER.warning( - "Platform configuration is deprecated, will be removed in a future release" - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -241,8 +665,8 @@ async def async_setup_entry( ) entities = [ - BrSensor(sensor_type, config.get(CONF_NAME, "Buienradar"), coordinates) - for sensor_type in SENSOR_TYPES + BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description) + for description in SENSOR_TYPES ] async_add_entities(entities) @@ -255,36 +679,30 @@ async def async_setup_entry( class BrSensor(SensorEntity): """Representation of an Buienradar sensor.""" - def __init__(self, sensor_type, client_name, coordinates): + _attr_entity_registry_enabled_default = False + _attr_should_poll = False + + def __init__(self, client_name, coordinates, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = client_name - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self._entity_picture = None - self._attribution = None + self.entity_description = description + self._attr_name = f"{client_name} {description.name}" self._measured = None - self._stationname = None - self._unique_id = self.uid(coordinates) + self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key + ) # All continuous sensors should be forced to be updated - self._force_update = self.type != SYMBOL and not self.type.startswith(CONDITION) + self._attr_force_update = ( + description.key != SYMBOL and not description.key.startswith(CONDITION) + ) - if self.type.startswith(PRECIPITATION_FORECAST): + if description.key.startswith(PRECIPITATION_FORECAST): self._timeframe = None - def uid(self, coordinates): - """Generate a unique id using coordinates and sensor type.""" - # The combination of the location, name and sensor type is unique - return "{:2.6f}{:2.6f}{}".format( - coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], self.type - ) - @callback def data_updated(self, data): """Update data.""" - if self._load_data(data) and self.hass: + if self.hass and self._load_data(data): self.async_write_ha_state() @callback @@ -297,31 +715,30 @@ def _load_data(self, data): # noqa: C901 if self._measured == data.get(MEASURED): return False - self._attribution = data.get(ATTRIBUTION) - self._stationname = data.get(STATIONNAME) self._measured = data.get(MEASURED) + sensor_type = self.entity_description.key if ( - self.type.endswith("_1d") - or self.type.endswith("_2d") - or self.type.endswith("_3d") - or self.type.endswith("_4d") - or self.type.endswith("_5d") + sensor_type.endswith("_1d") + or sensor_type.endswith("_2d") + or sensor_type.endswith("_3d") + or sensor_type.endswith("_4d") + or sensor_type.endswith("_5d") ): # update forcasting sensors: fcday = 0 - if self.type.endswith("_2d"): + if sensor_type.endswith("_2d"): fcday = 1 - if self.type.endswith("_3d"): + if sensor_type.endswith("_3d"): fcday = 2 - if self.type.endswith("_4d"): + if sensor_type.endswith("_4d"): fcday = 3 - if self.type.endswith("_5d"): + if sensor_type.endswith("_5d"): fcday = 4 # update weather symbol & status text - if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): + if sensor_type.startswith(SYMBOL) or sensor_type.startswith(CONDITION): try: condition = data.get(FORECAST)[fcday].get(CONDITION) except IndexError: @@ -330,29 +747,31 @@ def _load_data(self, data): # noqa: C901 if condition: new_state = condition.get(CONDITION) - if self.type.startswith(SYMBOL): + if sensor_type.startswith(SYMBOL): new_state = condition.get(EXACTNL) - if self.type.startswith("conditioncode"): + if sensor_type.startswith("conditioncode"): new_state = condition.get(CONDCODE) - if self.type.startswith("conditiondetailed"): + if sensor_type.startswith("conditiondetailed"): new_state = condition.get(DETAILED) - if self.type.startswith("conditionexact"): + if sensor_type.startswith("conditionexact"): new_state = condition.get(EXACT) img = condition.get(IMAGE) - if new_state != self._state or img != self._entity_picture: - self._state = new_state - self._entity_picture = img + if new_state != self.state or img != self.entity_picture: + self._attr_native_value = new_state + self._attr_entity_picture = img return True return False - if self.type.startswith(WINDSPEED): + if sensor_type.startswith(WINDSPEED): # hass wants windspeeds in km/h not m/s, so convert: try: - self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) - if self._state is not None: - self._state = round(self._state * 3.6, 1) + self._attr_native_value = data.get(FORECAST)[fcday].get( + sensor_type[:-3] + ) + if self.state is not None: + self._attr_native_value = round(self.state * 3.6, 1) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) @@ -360,128 +779,77 @@ def _load_data(self, data): # noqa: C901 # update all other sensors try: - self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_native_value = data.get(FORECAST)[fcday].get( + sensor_type[:-3] + ) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) return False - if self.type == SYMBOL or self.type.startswith(CONDITION): + if sensor_type == SYMBOL or sensor_type.startswith(CONDITION): # update weather symbol & status text - condition = data.get(CONDITION) - if condition: - if self.type == SYMBOL: + if condition := data.get(CONDITION): + if sensor_type == SYMBOL: new_state = condition.get(EXACTNL) - if self.type == CONDITION: + if sensor_type == CONDITION: new_state = condition.get(CONDITION) - if self.type == "conditioncode": + if sensor_type == "conditioncode": new_state = condition.get(CONDCODE) - if self.type == "conditiondetailed": + if sensor_type == "conditiondetailed": new_state = condition.get(DETAILED) - if self.type == "conditionexact": + if sensor_type == "conditionexact": new_state = condition.get(EXACT) img = condition.get(IMAGE) - if new_state != self._state or img != self._entity_picture: - self._state = new_state - self._entity_picture = img + if new_state != self.state or img != self.entity_picture: + self._attr_native_value = new_state + self._attr_entity_picture = img return True return False - if self.type.startswith(PRECIPITATION_FORECAST): + if sensor_type.startswith(PRECIPITATION_FORECAST): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) - self._state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) + self._attr_native_value = nested.get( + sensor_type[len(PRECIPITATION_FORECAST) + 1 :] + ) return True - if self.type in [WINDSPEED, WINDGUST]: + if sensor_type in [WINDSPEED, WINDGUST]: # hass wants windspeeds in km/h not m/s, so convert: - self._state = data.get(self.type) - if self._state is not None: - self._state = round(data.get(self.type) * 3.6, 1) + self._attr_native_value = data.get(sensor_type) + if self.state is not None: + self._attr_native_value = round(data.get(sensor_type) * 3.6, 1) return True - if self.type == VISIBILITY: + if sensor_type == VISIBILITY: # hass wants visibility in km (not m), so convert: - self._state = data.get(self.type) - if self._state is not None: - self._state = round(self._state / 1000, 1) + self._attr_native_value = data.get(sensor_type) + if self.state is not None: + self._attr_native_value = round(self.state / 1000, 1) return True # update all other sensors - self._state = data.get(self.type) - return True - - @property - def attribution(self): - """Return the attribution.""" - return self._attribution - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def entity_picture(self): - """Weather symbol if type is symbol.""" - return self._entity_picture - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self.type.startswith(PRECIPITATION_FORECAST): - result = {ATTR_ATTRIBUTION: self._attribution} + self._attr_native_value = data.get(sensor_type) + if sensor_type.startswith(PRECIPITATION_FORECAST): + result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} if self._timeframe is not None: result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) - return result + self._attr_extra_state_attributes = result result = { - ATTR_ATTRIBUTION: self._attribution, - SENSOR_TYPES["stationname"][0]: self._stationname, + ATTR_ATTRIBUTION: data.get(ATTRIBUTION), + STATIONNAME_LABEL: data.get(STATIONNAME), } if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime local_dt = dt_util.as_local(self._measured) result[MEASURED_LABEL] = local_dt.strftime("%c") - return result - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return possible sensor specific icon.""" - return SENSOR_TYPES[self.type][2] - - @property - def force_update(self): - """Return true for continuous sensors, false for discrete sensors.""" - return self._force_update - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False + self._attr_extra_state_attributes = result + return True diff --git a/homeassistant/components/buienradar/translations/de.json b/homeassistant/components/buienradar/translations/de.json index 4fbb298d38cea..bb09a98617dc4 100644 --- a/homeassistant/components/buienradar/translations/de.json +++ b/homeassistant/components/buienradar/translations/de.json @@ -14,5 +14,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "L\u00e4ndercode des Landes, in dem Kamerabilder angezeigt werden sollen.", + "delta": "Zeitintervall in Sekunden zwischen Kamerabildaktualisierungen", + "timeframe": "Minuten zum Vorausschauen f\u00fcr die Niederschlagsvorhersage" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/fr.json b/homeassistant/components/buienradar/translations/fr.json index d9c2fadcbf7b4..19b7737ae11c3 100644 --- a/homeassistant/components/buienradar/translations/fr.json +++ b/homeassistant/components/buienradar/translations/fr.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/buienradar/translations/he.json b/homeassistant/components/buienradar/translations/he.json new file mode 100644 index 0000000000000..76da9d34ddf65 --- /dev/null +++ b/homeassistant/components/buienradar/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/hu.json b/homeassistant/components/buienradar/translations/hu.json new file mode 100644 index 0000000000000..a064fa943a84f --- /dev/null +++ b/homeassistant/components/buienradar/translations/hu.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "A kamera k\u00e9peinek megjelen\u00edt\u00e9s\u00e9hez az orsz\u00e1g k\u00f3dja.", + "delta": "A kamera k\u00e9pfriss\u00edt\u00e9s\u00e9nek id\u0151tartama m\u00e1sodpercekben", + "timeframe": "Percek, hogy el\u0151retekints\u00fcnk a csapad\u00e9k el\u0151rejelz\u00e9s\u00e9re" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/id.json b/homeassistant/components/buienradar/translations/id.json new file mode 100644 index 0000000000000..46eb80123ccc6 --- /dev/null +++ b/homeassistant/components/buienradar/translations/id.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Lokasi sudah dikonfigurasi" + }, + "error": { + "already_configured": "Lokasi sudah dikonfigurasi" + }, + "step": { + "user": { + "data": { + "latitude": "Lintang", + "longitude": "Bujur" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "Kode negara negara untuk menampilkan gambar kamera.", + "delta": "Interval waktu pembaruan gambar kamera dalam detik", + "timeframe": "Waktu mendatang dalam menit untuk mendapatkan prakiraan curah hujan" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/ja.json b/homeassistant/components/buienradar/translations/ja.json new file mode 100644 index 0000000000000..f21946e9d8c23 --- /dev/null +++ b/homeassistant/components/buienradar/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "\u30ab\u30e1\u30e9\u753b\u50cf\u3092\u8868\u793a\u3059\u308b\u56fd\u306e\u56fd\u30b3\u30fc\u30c9\u3002", + "delta": "\u30ab\u30e1\u30e9\u753b\u50cf\u306e\u66f4\u65b0\u9593\u9694(\u79d2)", + "timeframe": "\u5206\u9593\u306e\u964d\u6c34\u91cf\u4e88\u5831\u3092\u5148\u8aad\u307f\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/pl.json b/homeassistant/components/buienradar/translations/pl.json new file mode 100644 index 0000000000000..3cf2dc8527120 --- /dev/null +++ b/homeassistant/components/buienradar/translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" + }, + "error": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" + }, + "step": { + "user": { + "data": { + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "Kod kraju do wy\u015bwietlania obraz\u00f3w z kamer.", + "delta": "Odst\u0119p czasu w sekundach mi\u0119dzy aktualizacjami obrazu z kamery", + "timeframe": "Czas w minutach poprzedzaj\u0105cy prognoz\u0119 opad\u00f3w" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/tr.json b/homeassistant/components/buienradar/translations/tr.json new file mode 100644 index 0000000000000..db8c7be0ef69b --- /dev/null +++ b/homeassistant/components/buienradar/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "Kamera g\u00f6r\u00fcnt\u00fclerinin g\u00f6r\u00fcnt\u00fclenece\u011fi \u00fclkenin \u00fclke kodu.", + "delta": "Kamera g\u00f6r\u00fcnt\u00fcs\u00fc g\u00fcncellemeleri aras\u0131ndaki saniye cinsinden zaman aral\u0131\u011f\u0131", + "timeframe": "Ya\u011f\u0131\u015f tahmini i\u00e7in dakikalar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 83c511713d016..3686e2bd3c9c6 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,6 +1,7 @@ """Shared utilities for different supported platforms.""" import asyncio from datetime import datetime, timedelta +from http import HTTPStatus import logging import aiohttp @@ -25,7 +26,7 @@ ) from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, HTTP_OK +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -87,19 +88,19 @@ async def get_data(self, url): resp = None try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): resp = await websession.get(url) result[STATUS_CODE] = resp.status result[CONTENT] = await resp.text() - if resp.status == HTTP_OK: + if resp.status == HTTPStatus.OK: result[SUCCESS] = True else: result[MESSAGE] = "Got http statuscode: %d" % (resp.status) return result except (asyncio.TimeoutError, aiohttp.ClientError) as err: - result[MESSAGE] = "%s" % err + result[MESSAGE] = str(err) return result finally: if resp is not None: diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 0aa57efc5f999..aa336d3929c08 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -11,7 +11,6 @@ WINDAZIMUTH, WINDSPEED, ) -import voluptuous as vol from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -35,13 +34,11 @@ ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, - PLATFORM_SCHEMA, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback # Reuse data and API logic from the sensor implementation @@ -76,22 +73,6 @@ ATTR_CONDITION_EXCEPTIONAL: (), } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_FORECAST, default=True): cv.boolean, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up buienradar weather platform.""" - _LOGGER.warning( - "Platform configuration is deprecated, will be removed in a future release" - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -130,12 +111,17 @@ async def async_setup_entry( class BrWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_temperature_unit = TEMP_CELSIUS + def __init__(self, data, config, coordinates): - """Initialise the platform with a data instance and station name.""" + """Initialize the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME, "Buienradar") + self._attr_name = ( + self._stationname or f"BR {data.stationname or '(unknown station)'}" + ) self._data = data - self._unique_id = "{:2.6f}{:2.6f}".format( + self._attr_unique_id = "{:2.6f}{:2.6f}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] ) @@ -144,22 +130,16 @@ def attribution(self): """Return the attribution.""" return self._data.attribution - @property - def name(self): - """Return the name of the sensor.""" - return ( - self._stationname or f"BR {self._data.stationname or '(unknown station)'}" - ) - @property def condition(self): """Return the current condition.""" - if self._data and self._data.condition: - ccode = self._data.condition.get(CONDCODE) - if ccode: - conditions = self.hass.data[DOMAIN].get(DATA_CONDITION) - if conditions: - return conditions.get(ccode) + if ( + self._data + and self._data.condition + and (ccode := self._data.condition.get(CONDCODE)) + and (conditions := self.hass.data[DOMAIN].get(DATA_CONDITION)) + ): + return conditions.get(ccode) @property def temperature(self): @@ -195,11 +175,6 @@ def wind_bearing(self): """Return the current wind bearing (degrees).""" return self._data.wind_bearing - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def forecast(self): """Return the forecast array.""" @@ -226,8 +201,3 @@ def forecast(self): fcdata_out.append(data_out) return fcdata_out - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py new file mode 100644 index 0000000000000..2e9a8c05163f8 --- /dev/null +++ b/homeassistant/components/button/__init__.py @@ -0,0 +1,128 @@ +"""Component to pressing a button as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, SERVICE_PRESS + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + + +class ButtonDeviceClass(StrEnum): + """Device class for buttons.""" + + RESTART = "restart" + UPDATE = "update" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ButtonDeviceClass)) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Button entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_PRESS, + {}, + "_async_press_action", + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class ButtonEntityDescription(EntityDescription): + """A class that describes button entities.""" + + device_class: ButtonDeviceClass | None = None + + +class ButtonEntity(RestoreEntity): + """Representation of a Button entity.""" + + entity_description: ButtonEntityDescription + _attr_should_poll = False + _attr_device_class: ButtonDeviceClass | None + _attr_state: None = None + __last_pressed: datetime | None = None + + @property + def device_class(self) -> ButtonDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.__last_pressed is None: + return None + return self.__last_pressed.isoformat() + + @final + async def _async_press_action(self) -> None: + """Press the button (from e.g., service call). + + Should not be overridden, handle setting last press timestamp. + """ + self.__last_pressed = dt_util.utcnow() + self.async_write_ha_state() + await self.async_press() + + async def async_added_to_hass(self) -> None: + """Call when the button is added to hass.""" + state = await self.async_get_last_state() + if state is not None and state.state is not None: + self.__last_pressed = dt_util.parse_datetime(state.state) + + def press(self) -> None: + """Press the button.""" + raise NotImplementedError() + + async def async_press(self) -> None: + """Press the button.""" + await self.hass.async_add_executor_job(self.press) diff --git a/homeassistant/components/button/const.py b/homeassistant/components/button/const.py new file mode 100644 index 0000000000000..273314b2e97e6 --- /dev/null +++ b/homeassistant/components/button/const.py @@ -0,0 +1,4 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "button" +SERVICE_PRESS = "press" diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py new file mode 100644 index 0000000000000..2dffd9c600fc2 --- /dev/null +++ b/homeassistant/components/button/device_action.py @@ -0,0 +1,58 @@ +"""Provides device actions for Button.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, SERVICE_PRESS + +ACTION_TYPES = {"press"} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + + +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device actions for button devices.""" + registry = entity_registry.async_get(hass) + return [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "press", + } + for entry in entity_registry.async_entries_for_device(registry, device_id) + if entry.domain == DOMAIN + ] + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: config[CONF_ENTITY_ID], + }, + blocking=True, + context=context, + ) diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py new file mode 100644 index 0000000000000..6d4692234f7f2 --- /dev/null +++ b/homeassistant/components/button/device_trigger.py @@ -0,0 +1,73 @@ +"""Provides device triggers for Button.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers.state import ( + async_attach_trigger as async_attach_state_trigger, + async_validate_trigger_config as async_validate_state_trigger_config, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TRIGGER_TYPES = {"pressed"} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for button devices.""" + registry = entity_registry.async_get(hass) + return [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "pressed", + } + for entry in entity_registry.async_entries_for_device(registry, device_id) + if entry.domain == DOMAIN + ] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + state_config = { + CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + } + + state_config = await async_validate_state_trigger_config(hass, state_config) + return await async_attach_state_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/button/manifest.json b/homeassistant/components/button/manifest.json new file mode 100644 index 0000000000000..beeaca487a6b7 --- /dev/null +++ b/homeassistant/components/button/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "button", + "name": "Button", + "documentation": "https://www.home-assistant.io/integrations/button", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/button/services.yaml b/homeassistant/components/button/services.yaml new file mode 100644 index 0000000000000..245368f9d5b33 --- /dev/null +++ b/homeassistant/components/button/services.yaml @@ -0,0 +1,6 @@ +press: + name: Press + description: Press the button entity. + target: + entity: + domain: button diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json new file mode 100644 index 0000000000000..ca774c57d7732 --- /dev/null +++ b/homeassistant/components/button/strings.json @@ -0,0 +1,11 @@ +{ + "title": "Button", + "device_automation": { + "trigger_type": { + "pressed": "{entity_name} has been pressed" + }, + "action_type": { + "press": "Press {entity_name} button" + } + } +} diff --git a/homeassistant/components/button/translations/af.json b/homeassistant/components/button/translations/af.json new file mode 100644 index 0000000000000..7c1e00817ee15 --- /dev/null +++ b/homeassistant/components/button/translations/af.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Nyomd meg a(z) [entity_name] gombot" + }, + "trigger_type": { + "pressed": "[entity_name] megnyomva" + } + }, + "title": "Gomb" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/bg.json b/homeassistant/components/button/translations/bg.json new file mode 100644 index 0000000000000..85b46c0196712 --- /dev/null +++ b/homeassistant/components/button/translations/bg.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} \u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442" + } + }, + "title": "\u0411\u0443\u0442\u043e\u043d" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/ca.json b/homeassistant/components/button/translations/ca.json new file mode 100644 index 0000000000000..376570dd7be9c --- /dev/null +++ b/homeassistant/components/button/translations/ca.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Prem el bot\u00f3 {entity_name}" + }, + "trigger_type": { + "pressed": "S'ha premut {entity_name}" + } + }, + "title": "Bot\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/de.json b/homeassistant/components/button/translations/de.json new file mode 100644 index 0000000000000..26d651ea1f057 --- /dev/null +++ b/homeassistant/components/button/translations/de.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Dr\u00fccke die {entity_name} Taste" + }, + "trigger_type": { + "pressed": "{entity_name} wurde gedr\u00fcckt" + } + }, + "title": "Taste" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/en.json b/homeassistant/components/button/translations/en.json new file mode 100644 index 0000000000000..8b19cf257740f --- /dev/null +++ b/homeassistant/components/button/translations/en.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Press {entity_name} button" + }, + "trigger_type": { + "pressed": "{entity_name} has been pressed" + } + }, + "title": "Button" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/et.json b/homeassistant/components/button/translations/et.json new file mode 100644 index 0000000000000..b5d5cec992b9c --- /dev/null +++ b/homeassistant/components/button/translations/et.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Vajuta nuppu {entity_name}" + }, + "trigger_type": { + "pressed": "Vajutati nuppu {entity_name}" + } + }, + "title": "Nupp" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/fr.json b/homeassistant/components/button/translations/fr.json new file mode 100644 index 0000000000000..5e6adf70da1f9 --- /dev/null +++ b/homeassistant/components/button/translations/fr.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Appuyez sur le bouton {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} a \u00e9t\u00e9 press\u00e9" + } + }, + "title": "Bouton" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/he.json b/homeassistant/components/button/translations/he.json new file mode 100644 index 0000000000000..ea695684d600d --- /dev/null +++ b/homeassistant/components/button/translations/he.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05dc\u05d7\u05e6\u05df {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} \u05e0\u05dc\u05d7\u05e5" + } + }, + "title": "\u05dc\u05d7\u05e6\u05df" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/hu.json b/homeassistant/components/button/translations/hu.json new file mode 100644 index 0000000000000..07d1b33e40112 --- /dev/null +++ b/homeassistant/components/button/translations/hu.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "{entity_name} megnyom\u00e1sa" + }, + "trigger_type": { + "pressed": "{entity_name} megnyomva" + } + }, + "title": "Gomb" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/id.json b/homeassistant/components/button/translations/id.json new file mode 100644 index 0000000000000..bb51d4003dab6 --- /dev/null +++ b/homeassistant/components/button/translations/id.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Tekan tombol {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} telah ditekan" + } + }, + "title": "Tombol" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/it.json b/homeassistant/components/button/translations/it.json new file mode 100644 index 0000000000000..9cfc538a5ce56 --- /dev/null +++ b/homeassistant/components/button/translations/it.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Premi il pulsante {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} \u00e8 stato premuto" + } + }, + "title": "Pulsante" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/ja.json b/homeassistant/components/button/translations/ja.json new file mode 100644 index 0000000000000..9d2f1615dc178 --- /dev/null +++ b/homeassistant/components/button/translations/ja.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "{entity_name} \u30dc\u30bf\u30f3\u3092\u62bc\u3059" + }, + "trigger_type": { + "pressed": "{entity_name} \u304c\u62bc\u3055\u308c\u307e\u3057\u305f" + } + }, + "title": "\u30dc\u30bf\u30f3" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/lt.json b/homeassistant/components/button/translations/lt.json new file mode 100644 index 0000000000000..14c1935abdcfb --- /dev/null +++ b/homeassistant/components/button/translations/lt.json @@ -0,0 +1,3 @@ +{ + "title": "Mygtukas" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/nl.json b/homeassistant/components/button/translations/nl.json new file mode 100644 index 0000000000000..31fe69d11f524 --- /dev/null +++ b/homeassistant/components/button/translations/nl.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Druk op de knop {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} is ingedrukt" + } + }, + "title": "Knop" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/no.json b/homeassistant/components/button/translations/no.json new file mode 100644 index 0000000000000..94daca72747bb --- /dev/null +++ b/homeassistant/components/button/translations/no.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Trykk p\u00e5 {entity_name} -knappen" + }, + "trigger_type": { + "pressed": "{entity_name} har blitt trykket" + } + }, + "title": "Knapp" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/pl.json b/homeassistant/components/button/translations/pl.json new file mode 100644 index 0000000000000..e5af8b8c29b1e --- /dev/null +++ b/homeassistant/components/button/translations/pl.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "naci\u015bnij przycisk {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} zosta\u0142 naci\u015bni\u0119ty" + } + }, + "title": "Przycisk" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/pt-BR.json b/homeassistant/components/button/translations/pt-BR.json new file mode 100644 index 0000000000000..840bc6ddcc214 --- /dev/null +++ b/homeassistant/components/button/translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Pressione o bot\u00e3o {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} foi pressionado" + } + }, + "title": "Bot\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/ru.json b/homeassistant/components/button/translations/ru.json new file mode 100644 index 0000000000000..187f384644202 --- /dev/null +++ b/homeassistant/components/button/translations/ru.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u041d\u0430\u0436\u0430\u0442\u044c \u043a\u043d\u043e\u043f\u043a\u0443 {entity_name}" + }, + "trigger_type": { + "pressed": "\u041d\u0430\u0436\u0430\u0442\u0430 \u043a\u043d\u043e\u043f\u043a\u0430 {entity_name}" + } + }, + "title": "\u041a\u043d\u043e\u043f\u043a\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/sl.json b/homeassistant/components/button/translations/sl.json new file mode 100644 index 0000000000000..84e3a3ff12bf2 --- /dev/null +++ b/homeassistant/components/button/translations/sl.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "pressed": "{entity_name} je pritisnjena" + } + }, + "title": "Gumb" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/th.json b/homeassistant/components/button/translations/th.json new file mode 100644 index 0000000000000..fbcccf87d30a2 --- /dev/null +++ b/homeassistant/components/button/translations/th.json @@ -0,0 +1,3 @@ +{ + "title": "\u0e1b\u0e38\u0e48\u0e21" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/tr.json b/homeassistant/components/button/translations/tr.json new file mode 100644 index 0000000000000..a02a9f5e75bf1 --- /dev/null +++ b/homeassistant/components/button/translations/tr.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "{entity_name} d\u00fc\u011fmesine bas\u0131n" + }, + "trigger_type": { + "pressed": "{entity_name} tu\u015funa bas\u0131ld\u0131" + } + }, + "title": "D\u00fc\u011fme" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/zh-Hans.json b/homeassistant/components/button/translations/zh-Hans.json new file mode 100644 index 0000000000000..88c70556aa10b --- /dev/null +++ b/homeassistant/components/button/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u6309\u4e0b {entity_name} \u6309\u94ae" + }, + "trigger_type": { + "pressed": "{entity_name} \u88ab\u6309\u4e0b" + } + }, + "title": "\u6309\u94ae" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/zh-Hant.json b/homeassistant/components/button/translations/zh-Hant.json new file mode 100644 index 0000000000000..e91b342dd44a6 --- /dev/null +++ b/homeassistant/components/button/translations/zh-Hant.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u6309\u4e0b {entity_name} \u6309\u9215" + }, + "trigger_type": { + "pressed": "{entity_name} \u5df2\u6309\u4e0b" + } + }, + "title": "\u6309\u9215" +} \ No newline at end of file diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 62be361df3b5d..8ed8ea24607ce 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -119,24 +119,13 @@ def __init__(self, name, calendar, entity_id, days, all_day=False, search=None): self.data = WebDavCalendarData(calendar, days, all_day, search) self.entity_id = entity_id self._event = None - self._name = name - self._offset_reached = False - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return {"offset_reached": self._offset_reached} + self._attr_name = name @property def event(self): """Return the next upcoming event.""" return self._event - @property - def name(self): - """Return the name of the entity.""" - return self._name - async def async_get_events(self, hass, start_date, end_date): """Get all events in a specific time frame.""" return await self.data.async_get_events(hass, start_date, end_date) @@ -149,8 +138,8 @@ def update(self): self._event = event return event = calculate_offset(event, OFFSET) - self._offset_reached = is_offset_reached(event) self._event = event + self._attr_extra_state_attributes = {"offset_reached": is_offset_reached(event)} class WebDavCalendarData: @@ -172,6 +161,9 @@ async def async_get_events(self, hass, start_date, end_date): ) event_list = [] for event in vevent_list: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue vevent = event.instance.vevent if not self.is_matching(vevent, self.search): continue @@ -209,6 +201,9 @@ def update(self): # and they would not be properly parsed using their original start/end dates. new_events = [] for event in results: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue vevent = event.instance.vevent for start_dt in vevent.getrruleset() or []: _start_of_today = start_of_today @@ -310,7 +305,9 @@ def to_datetime(obj): # represent same time regardless of which time zone is currently being observed return obj.replace(tzinfo=dt.DEFAULT_TIME_ZONE) return obj - return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + return dt.dt.datetime.combine(obj, dt.dt.time.min).replace( + tzinfo=dt.DEFAULT_TIME_ZONE + ) @staticmethod def get_attr_value(obj, attribute): diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 11a6916ba83ff..1666a30a1f5e9 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from http import HTTPStatus import logging import re from typing import cast, final @@ -9,7 +10,9 @@ from aiohttp import web from homeassistant.components import http -from homeassistant.const import HTTP_BAD_REQUEST, STATE_OFF, STATE_ON +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -46,14 +49,16 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) def get_date(date): @@ -140,8 +145,7 @@ def event(self): @property def state_attributes(self): """Return the entity state attributes.""" - event = self.event - if event is None: + if (event := self.event) is None: return None event = normalize_event(event) @@ -157,8 +161,7 @@ def state_attributes(self): @property def state(self): """Return the state of the calendar event.""" - event = self.event - if event is None: + if (event := self.event) is None: return STATE_OFF event = normalize_event(event) @@ -196,12 +199,12 @@ async def get(self, request, entity_id): start = request.query.get("start") end = request.query.get("end") if None in (start, end, entity): - return web.Response(status=HTTP_BAD_REQUEST) + return web.Response(status=HTTPStatus.BAD_REQUEST) try: start_date = dt.parse_datetime(start) end_date = dt.parse_datetime(end) except (ValueError, AttributeError): - return web.Response(status=HTTP_BAD_REQUEST) + return web.Response(status=HTTPStatus.BAD_REQUEST) event_list = await entity.async_get_events( request.app["hass"], start_date, end_date ) diff --git a/homeassistant/components/calendar/translations/he.json b/homeassistant/components/calendar/translations/he.json index 206528ef6a85c..9a63367069841 100644 --- a/homeassistant/components/calendar/translations/he.json +++ b/homeassistant/components/calendar/translations/he.json @@ -2,8 +2,8 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, - "title": "\u05dc\u05d5\u05bc\u05d7\u05b7 \u05e9\u05c1\u05b8\u05e0\u05b8\u05d4" + "title": "\u05dc\u05d5\u05d7 \u05e9\u05e0\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ru.json b/homeassistant/components/calendar/translations/ru.json index 0a95a70ae0627..81cf8250c214c 100644 --- a/homeassistant/components/calendar/translations/ru.json +++ b/homeassistant/components/calendar/translations/ru.json @@ -1,8 +1,8 @@ { "state": { "_": { - "off": "\u0412\u044b\u043a\u043b", - "on": "\u0412\u043a\u043b" + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" } }, "title": "\u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440\u044c" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b52a36515d8cc..5c81ece61419a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -4,14 +4,17 @@ import asyncio import base64 import collections -from collections.abc import Awaitable, Mapping +from collections.abc import Awaitable, Callable, Mapping from contextlib import suppress +from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial import hashlib +import inspect import logging import os from random import SystemRandom -from typing import Callable, Final, cast, final +from typing import Final, cast, final from aiohttp import web import async_timeout @@ -46,7 +49,7 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity, entity_sources +from homeassistant.helpers.entity import Entity, EntityDescription, entity_sources from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType @@ -60,7 +63,10 @@ DATA_CAMERA_PREFS, DOMAIN, SERVICE_RECORD, + STREAM_TYPE_HLS, + STREAM_TYPE_WEB_RTC, ) +from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences # mypy: allow-untyped-calls @@ -117,6 +123,11 @@ ) +@dataclass +class CameraEntityDescription(EntityDescription): + """A class that describes camera entities.""" + + @attr.s class Image: """Represent an image.""" @@ -132,35 +143,79 @@ async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> return await _async_stream_endpoint_url(hass, camera, fmt) -@bind_hass -async def async_get_image( - hass: HomeAssistant, entity_id: str, timeout: int = 10 +async def _async_get_image( + camera: Camera, + timeout: int = 10, + width: int | None = None, + height: int | None = None, ) -> Image: - """Fetch an image from a camera entity.""" - camera = _get_camera_from_entity_id(hass, entity_id) + """Fetch a snapshot image from a camera. + If width and height are passed, an attempt to scale + the image will be made on a best effort basis. + Not all cameras can scale images or return jpegs + that we can scale, however the majority of cases + are handled. + """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): async with async_timeout.timeout(timeout): - image = await camera.async_camera_image() - - if image: - return Image(camera.content_type, image) + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + sig = inspect.signature(camera.async_camera_image) + if "height" in sig.parameters and "width" in sig.parameters: + image_bytes = await camera.async_camera_image( + width=width, height=height + ) + else: + camera.async_warn_old_async_camera_image_signature() + image_bytes = await camera.async_camera_image() + + if image_bytes: + content_type = camera.content_type + image = Image(content_type, image_bytes) + if ( + width is not None + and height is not None + and ("jpeg" in content_type or "jpg" in content_type) + ): + assert width is not None + assert height is not None + return Image( + content_type, scale_jpeg_camera_image(image, width, height) + ) + + return image raise HomeAssistantError("Unable to get image") +@bind_hass +async def async_get_image( + hass: HomeAssistant, + entity_id: str, + timeout: int = 10, + width: int | None = None, + height: int | None = None, +) -> Image: + """Fetch an image from a camera entity. + + width and height will be passed to the underlying camera. + """ + camera = _get_camera_from_entity_id(hass, entity_id) + return await _async_get_image(camera, timeout, width, height) + + @bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" camera = _get_camera_from_entity_id(hass, entity_id) - return await camera.stream_source() @bind_hass async def async_get_mjpeg_stream( hass: HomeAssistant, request: web.Request, entity_id: str -) -> web.StreamResponse: +) -> web.StreamResponse | None: """Fetch an mjpeg stream from a camera entity.""" camera = _get_camera_from_entity_id(hass, entity_id) @@ -217,14 +272,10 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera: """Get camera component from entity_id.""" - component = hass.data.get(DOMAIN) - - if component is None: + if (component := hass.data.get(DOMAIN)) is None: raise HomeAssistantError("Camera integration not set up") - camera = component.get_entity(entity_id) - - if camera is None: + if (camera := component.get_entity(entity_id)) is None: raise HomeAssistantError("Camera not found") if not camera.is_on: @@ -249,6 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail, SCHEMA_WS_CAMERA_THUMBNAIL ) hass.components.websocket_api.async_register_command(ws_camera_stream) + hass.components.websocket_api.async_register_command(ws_camera_web_rtc_offer) hass.components.websocket_api.async_register_command(websocket_get_prefs) hass.components.websocket_api.async_register_command(websocket_update_prefs) @@ -260,7 +312,7 @@ async def preload_stream(_event: Event) -> None: camera_prefs = prefs.get(camera.entity_id) if not camera_prefs.preload_stream: continue - stream = await camera.create_stream() + stream = await camera.async_create_stream() if not stream: continue stream.keepalive = True @@ -317,78 +369,159 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Camera(Entity): """The base class for camera entities.""" + # Entity Properties + _attr_brand: str | None = None + _attr_frame_interval: float = MIN_STREAM_INTERVAL + _attr_frontend_stream_type: str | None + _attr_is_on: bool = True + _attr_is_recording: bool = False + _attr_is_streaming: bool = False + _attr_model: str | None = None + _attr_motion_detection_enabled: bool = False + _attr_should_poll: bool = False # No need to poll cameras + _attr_state: None = None # State is determined by is_on + _attr_supported_features: int = 0 + def __init__(self) -> None: """Initialize a camera.""" - self.is_streaming: bool = False self.stream: Stream | None = None self.stream_options: dict[str, str] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) + self._warned_old_signature = False self.async_update_token() - - @property - def should_poll(self) -> bool: - """No need to poll cameras.""" - return False + self._create_stream_lock: asyncio.Lock | None = None @property def entity_picture(self) -> str: """Return a link to the camera feed as entity picture.""" + if self._attr_entity_picture is not None: + return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) @property def supported_features(self) -> int: """Flag supported features.""" - return 0 + return self._attr_supported_features @property def is_recording(self) -> bool: """Return true if the device is recording.""" - return False + return self._attr_is_recording + + @property + def is_streaming(self) -> bool: + """Return true if the device is streaming.""" + return self._attr_is_streaming @property def brand(self) -> str | None: """Return the camera brand.""" - return None + return self._attr_brand @property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" - return False + return self._attr_motion_detection_enabled @property def model(self) -> str | None: """Return the camera model.""" - return None + return self._attr_model @property def frame_interval(self) -> float: """Return the interval between frames of the mjpeg stream.""" - return MIN_STREAM_INTERVAL + return self._attr_frame_interval + + @property + def frontend_stream_type(self) -> str | None: + """Return the type of stream supported by this camera. - async def create_stream(self) -> Stream | None: + A camera may have a single stream type which is used to inform the + frontend which camera attributes and player to use. The default type + is to use HLS, and components can override to change the type. + """ + if hasattr(self, "_attr_frontend_stream_type"): + return self._attr_frontend_stream_type + if not self.supported_features & SUPPORT_STREAM: + return None + return STREAM_TYPE_HLS + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.stream and not self.stream.available: + return self.stream.available + return super().available + + async def async_create_stream(self) -> Stream | None: """Create a Stream for stream_source.""" # There is at most one stream (a decode worker) per camera - if not self.stream: - async with async_timeout.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): - source = await self.stream_source() - if not source: - return None - self.stream = create_stream(self.hass, source, options=self.stream_options) - return self.stream + if not self._create_stream_lock: + self._create_stream_lock = asyncio.Lock() + async with self._create_stream_lock: + if not self.stream: + async with async_timeout.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): + source = await self.stream_source() + if not source: + return None + self.stream = create_stream( + self.hass, + source, + options=self.stream_options, + stream_label=self.entity_id, + ) + self.stream.set_update_callback(self.async_write_ha_state) + return self.stream async def stream_source(self) -> str | None: - """Return the source of the stream.""" + """Return the source of the stream. + + This is used by cameras with SUPPORT_STREAM and STREAM_TYPE_HLS. + """ + # pylint: disable=no-self-use return None - def camera_image(self) -> bytes | None: + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + """Handle the WebRTC offer and return an answer. + + This is used by cameras with SUPPORT_STREAM and STREAM_TYPE_WEB_RTC. + """ + raise NotImplementedError() + + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" raise NotImplementedError() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" + sig = inspect.signature(self.camera_image) + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + if "height" in sig.parameters and "width" in sig.parameters: + return await self.hass.async_add_executor_job( + partial(self.camera_image, width=width, height=height) + ) + self.async_warn_old_async_camera_image_signature() return await self.hass.async_add_executor_job(self.camera_image) + # Remove in 2022.1 after all custom components have had a chance to change their signature + @callback + def async_warn_old_async_camera_image_signature(self) -> None: + """Warn once when calling async_camera_image with the function old signature.""" + if self._warned_old_signature: + return + _LOGGER.warning( + "The camera entity %s does not support requesting width and height, please open an issue with the integration author", + self.entity_id, + ) + self._warned_old_signature = True + async def handle_async_still_stream( self, request: web.Request, interval: float ) -> web.StreamResponse: @@ -399,7 +532,7 @@ async def handle_async_still_stream( async def handle_async_mjpeg_stream( self, request: web.Request - ) -> web.StreamResponse: + ) -> web.StreamResponse | None: """Serve an HTTP MJPEG stream from the camera. This method can be overridden by camera platforms to proxy @@ -408,6 +541,7 @@ async def handle_async_mjpeg_stream( return await self.handle_async_still_stream(request, self.frame_interval) @property + @final def state(self) -> str: """Return the camera state.""" if self.is_recording: @@ -419,7 +553,7 @@ def state(self) -> str: @property def is_on(self) -> bool: """Return true if on.""" - return True + return self._attr_is_on def turn_off(self) -> None: """Turn off camera.""" @@ -468,6 +602,9 @@ def state_attributes(self) -> dict[str, str | None]: if self.motion_detection_enabled: attrs["motion_detection"] = self.motion_detection_enabled + if self.frontend_stream_type: + attrs["frontend_stream_type"] = self.frontend_stream_type + return attrs @callback @@ -489,9 +626,7 @@ def __init__(self, component: EntityComponent) -> None: async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: """Start a GET request.""" - camera = self.component.get_entity(entity_id) - - if camera is None: + if (camera := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound() camera = cast(Camera, camera) @@ -523,14 +658,19 @@ class CameraImageView(CameraView): async def handle(self, request: web.Request, camera: Camera) -> web.Response: """Serve camera image.""" - with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(CAMERA_IMAGE_TIMEOUT): - image = await camera.async_camera_image() - - if image: - return web.Response(body=image, content_type=camera.content_type) - - raise web.HTTPInternalServerError() + width = request.query.get("width") + height = request.query.get("height") + try: + image = await _async_get_image( + camera, + CAMERA_IMAGE_TIMEOUT, + int(width) if width else None, + int(height) if height else None, + ) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError() from ex + else: + return web.Response(body=image.content, content_type=image.content_type) class CameraMjpegStream(CameraView): @@ -541,9 +681,11 @@ class CameraMjpegStream(CameraView): async def handle(self, request: web.Request, camera: Camera) -> web.StreamResponse: """Serve camera stream, possibly with interval.""" - interval_str = request.query.get("interval") - if interval_str is None: - return await camera.handle_async_mjpeg_stream(request) + if (interval_str := request.query.get("interval")) is None: + stream = await camera.handle_async_mjpeg_stream(request) + if stream is None: + raise web.HTTPBadGateway() + return stream try: # Compose camera stream from stills @@ -611,6 +753,50 @@ async def ws_camera_stream( ) +@websocket_api.websocket_command( + { + vol.Required("type"): "camera/web_rtc_offer", + vol.Required("entity_id"): cv.entity_id, + vol.Required("offer"): str, + } +) +@websocket_api.async_response +async def ws_camera_web_rtc_offer( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: + """Handle the signal path for a WebRTC stream. + + This signal path is used to route the offer created by the client to the + camera device through the integration for negitioation on initial setup, + which returns an answer. The actual streaming is handled entirely between + the client and camera device. + + Async friendly. + """ + entity_id = msg["entity_id"] + offer = msg["offer"] + camera = _get_camera_from_entity_id(hass, entity_id) + if camera.frontend_stream_type != STREAM_TYPE_WEB_RTC: + connection.send_error( + msg["id"], + "web_rtc_offer_failed", + f"Camera does not support WebRTC, frontend_stream_type={camera.frontend_stream_type}", + ) + return + try: + answer = await camera.async_handle_web_rtc_offer(offer) + except (HomeAssistantError, ValueError) as ex: + _LOGGER.error("Error handling WebRTC offer: %s", ex) + connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex)) + except asyncio.TimeoutError: + _LOGGER.error("Timeout handling WebRTC offer") + connection.send_error( + msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer" + ) + else: + connection.send_result(msg["id"], {"answer": answer}) + + @websocket_api.websocket_command( {vol.Required("type"): "camera/get_prefs", vol.Required("entity_id"): cv.entity_id} ) @@ -667,8 +853,7 @@ def _write_image(to_file: str, image_data: bytes | None) -> None: """Executor helper to write image.""" if image_data is None: return - if not os.path.exists(os.path.dirname(to_file)): - os.makedirs(os.path.dirname(to_file), exist_ok=True) + os.makedirs(os.path.dirname(to_file), exist_ok=True) with open(to_file, "wb") as img_file: img_file.write(image_data) @@ -693,10 +878,13 @@ async def async_handle_play_stream_service( # It is required to send a different payload for cast media players entity_ids = service_call.data[ATTR_MEDIA_PLAYER] + sources = entity_sources(hass) cast_entity_ids = [ entity - for entity, source in entity_sources(hass).items() - if entity in entity_ids and source["domain"] == "cast" + for entity in entity_ids + # All entities should be in sources. This extra guard is to + # avoid people writing to the state machine and breaking it. + if entity in sources and sources[entity]["domain"] == "cast" ] other_entity_ids = list(set(entity_ids) - set(cast_entity_ids)) @@ -734,7 +922,7 @@ async def async_handle_play_stream_service( async def _async_stream_endpoint_url( hass: HomeAssistant, camera: Camera, fmt: str ) -> str: - stream = await camera.create_stream() + stream = await camera.async_create_stream() if not stream: raise HomeAssistantError( f"{camera.entity_id} does not support play stream service" @@ -753,7 +941,7 @@ async def async_handle_record_service( camera: Camera, service_call: ServiceCall ) -> None: """Handle stream recording service calls.""" - stream = await camera.create_stream() + stream = await camera.async_create_stream() if not stream: raise HomeAssistantError(f"{camera.entity_id} does not support record service") diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 2cb01f44aa908..3eb131200e680 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -14,3 +14,12 @@ CAMERA_STREAM_SOURCE_TIMEOUT: Final = 10 CAMERA_IMAGE_TIMEOUT: Final = 10 + +# A camera that supports CAMERA_SUPPORT_STREAM may have a single stream +# type which is used to inform the frontend which player to use. +# Streams with RTSP sources typically use the stream component which uses +# HLS for display. WebRTC streams use the home assistant core for a signal +# path to initiate a stream, but the stream itself is between the client and +# device. +STREAM_TYPE_HLS = "hls" +STREAM_TYPE_WEB_RTC = "web_rtc" diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py new file mode 100644 index 0000000000000..3aadc5c454cc3 --- /dev/null +++ b/homeassistant/components/camera/img_util.py @@ -0,0 +1,101 @@ +"""Image processing for cameras.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] + +_LOGGER = logging.getLogger(__name__) + +JPEG_QUALITY = 75 + +if TYPE_CHECKING: + from turbojpeg import TurboJPEG + + from . import Image + + +def find_supported_scaling_factor( + current_width: int, current_height: int, target_width: int, target_height: int +) -> tuple[int, int] | None: + """Find a supported scaling factor to scale the image. + + If there is no exact match, we use one size up to ensure + the image remains crisp. + """ + for idx, supported_sf in enumerate(SUPPORTED_SCALING_FACTORS): + ratio = supported_sf[0] / supported_sf[1] + width_after_scale = current_width * ratio + height_after_scale = current_height * ratio + if width_after_scale == target_width and height_after_scale == target_height: + return supported_sf + if width_after_scale < target_width or height_after_scale < target_height: + return None if idx == 0 else SUPPORTED_SCALING_FACTORS[idx - 1] + + # Giant image, the most we can reduce by is 1/8 + return SUPPORTED_SCALING_FACTORS[-1] + + +def scale_jpeg_camera_image(cam_image: Image, width: int, height: int) -> bytes: + """Scale a camera image as close as possible to one of the supported scaling factors.""" + turbo_jpeg = TurboJPEGSingleton.instance() + if not turbo_jpeg: + return cam_image.content + + try: + (current_width, current_height, _, _) = turbo_jpeg.decode_header( + cam_image.content + ) + except OSError: + return cam_image.content + + scaling_factor = find_supported_scaling_factor( + current_width, current_height, width, height + ) + if scaling_factor is None: + return cam_image.content + + return cast( + bytes, + turbo_jpeg.scale_with_quality( + cam_image.content, + scaling_factor=scaling_factor, + quality=JPEG_QUALITY, + ), + ) + + +class TurboJPEGSingleton: + """ + Load TurboJPEG only once. + + Ensures we do not log load failures each snapshot + since camera image fetches happen every few + seconds. + """ + + __instance = None + + @staticmethod + def instance() -> TurboJPEG: + """Singleton for TurboJPEG.""" + if TurboJPEGSingleton.__instance is None: + TurboJPEGSingleton() + return TurboJPEGSingleton.__instance + + def __init__(self) -> None: + """Try to create TurboJPEG only once.""" + try: + # TurboJPEG checks for libturbojpeg + # when its created, but it imports + # numpy which may or may not work so + # we have to guard the import here. + from turbojpeg import TurboJPEG # pylint: disable=import-outside-toplevel + + TurboJPEGSingleton.__instance = TurboJPEG() + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error loading libturbojpeg; Cameras may impact HomeKit performance" + ) + TurboJPEGSingleton.__instance = False diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index ed8e10c1956f5..6f1b8fdcb3599 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,6 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], + "requirements": ["PyTurboJPEG==1.6.3"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 36f3d60d0db32..53a149ff7d80e 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -40,9 +40,7 @@ def __init__(self, hass: HomeAssistant) -> None: async def async_initialize(self) -> None: """Finish initializing the preferences.""" - prefs = await self._store.async_load() - - if prefs is None: + if (prefs := await self._store.async_load()) is None: prefs = {} self._prefs = prefs diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 3c8e99f001ba2..024bb92750890 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -4,26 +4,36 @@ turn_off: name: Turn off description: Turn off camera. target: + entity: + domain: camera turn_on: name: Turn on description: Turn on camera. target: + entity: + domain: camera enable_motion_detection: name: Enable motion detection description: Enable the motion detection in a camera. target: + entity: + domain: camera disable_motion_detection: name: Disable motion detection description: Disable the motion detection in a camera. target: + entity: + domain: camera snapshot: name: Take snapshot description: Take a snapshot from a camera. target: + entity: + domain: camera fields: filename: name: Filename @@ -37,12 +47,13 @@ play_stream: name: Play stream description: Play camera stream on supported media player. target: + entity: + domain: camera fields: media_player: name: Media Player description: Name(s) of media player to stream to. required: true - example: "media_player.living_room_tv" selector: entity: domain: media_player @@ -50,7 +61,6 @@ play_stream: name: Format description: Stream format supported by media player. default: "hls" - example: "hls" selector: select: options: @@ -60,6 +70,8 @@ record: name: Record description: Record live camera feed. target: + entity: + domain: camera fields: filename: name: Filename @@ -72,25 +84,19 @@ record: name: Duration description: Target recording length. default: 30 - example: 30 selector: number: min: 1 max: 3600 - step: 1 unit_of_measurement: seconds - mode: slider lookback: name: Lookback description: Target lookback period to include in addition to duration. Only available if there is currently an active HLS stream. default: 0 - example: 4 selector: number: min: 0 max: 300 - step: 1 unit_of_measurement: seconds - mode: slider diff --git a/homeassistant/components/camera/translations/he.json b/homeassistant/components/camera/translations/he.json index ccca3a7909966..b3e16e7082622 100644 --- a/homeassistant/components/camera/translations/he.json +++ b/homeassistant/components/camera/translations/he.json @@ -1,10 +1,10 @@ { "state": { "_": { - "idle": "\u05de\u05d7\u05db\u05d4", + "idle": "\u05de\u05de\u05ea\u05d9\u05df", "recording": "\u05de\u05e7\u05dc\u05d9\u05d8", "streaming": "\u05de\u05d6\u05e8\u05d9\u05dd" } }, - "title": "\u05de\u05b7\u05e6\u05dc\u05b5\u05de\u05b8\u05d4" + "title": "\u05de\u05e6\u05dc\u05de\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ja.json b/homeassistant/components/camera/translations/ja.json index 4ab2b8ed3b6a5..9a893c416433e 100644 --- a/homeassistant/components/camera/translations/ja.json +++ b/homeassistant/components/camera/translations/ja.json @@ -1,7 +1,9 @@ { "state": { "_": { - "idle": "\u30a2\u30a4\u30c9\u30eb" + "idle": "\u30a2\u30a4\u30c9\u30eb", + "recording": "\u30ec\u30b3\u30fc\u30c7\u30a3\u30f3\u30b0", + "streaming": "\u30b9\u30c8\u30ea\u30fc\u30df\u30f3\u30b0" } }, "title": "\u30ab\u30e1\u30e9" diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index c29dfeb1a71c5..9b020a2f09df0 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -1,17 +1,21 @@ """Support for Canary devices.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Final from canary.api import Api -from requests import ConnectTimeout, HTTPError +from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FFMPEG_ARGUMENTS, @@ -23,11 +27,11 @@ ) from .coordinator import CanaryDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30) -CONFIG_SCHEMA = vol.Schema( +CONFIG_SCHEMA: Final = vol.Schema( vol.All( cv.deprecated(DOMAIN), { @@ -45,10 +49,14 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["alarm_control_panel", "camera", "sensor"] +PLATFORMS: Final[list[Platform]] = [ + Platform.ALARM_CONTROL_PANEL, + Platform.CAMERA, + Platform.SENSOR, +] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Canary integration.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index f0d6deb477b78..4d29d4893e754 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -1,7 +1,14 @@ """Support for Canary alarm.""" from __future__ import annotations -from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT +from typing import Any + +from canary.api import ( + LOCATION_MODE_AWAY, + LOCATION_MODE_HOME, + LOCATION_MODE_NIGHT, + Location, +) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.components.alarm_control_panel.const import ( @@ -44,29 +51,27 @@ async def async_setup_entry( class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): """Representation of a Canary alarm control panel.""" - def __init__(self, coordinator, location): + coordinator: CanaryDataUpdateCoordinator + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + + def __init__( + self, coordinator: CanaryDataUpdateCoordinator, location: Location + ) -> None: """Initialize a Canary security camera.""" super().__init__(coordinator) - self._location_id = location.location_id - self._location_name = location.name + self._location_id: str = location.location_id + self._attr_name = location.name + self._attr_unique_id = str(self._location_id) @property - def location(self): + def location(self) -> Location: """Return information about the location.""" return self.coordinator.data["locations"][self._location_id] @property - def name(self): - """Return the name of the alarm.""" - return self._location_name - - @property - def unique_id(self): - """Return the unique ID of the alarm.""" - return str(self._location_id) - - @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if self.location.is_private: return STATE_ALARM_DISARMED @@ -82,30 +87,25 @@ def state(self): return None @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"private": self.location.is_private} - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self.coordinator.canary.set_location_mode( self._location_id, self.location.mode.name, True ) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self.coordinator.canary.set_location_mode(self._location_id, LOCATION_MODE_HOME) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self.coordinator.canary.set_location_mode(self._location_id, LOCATION_MODE_AWAY) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self.coordinator.canary.set_location_mode( self._location_id, LOCATION_MODE_NIGHT diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index ada9d16894242..a4c5a5ac837b0 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -1,19 +1,26 @@ """Support for Canary camera.""" from __future__ import annotations -import asyncio from datetime import timedelta +from typing import Final +from aiohttp.web import Request, StreamResponse +from canary.api import Device, Location +from canary.live_stream_api import LiveStreamSession from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components import ffmpeg +from homeassistant.components.camera import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + Camera, +) +from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import Throttle @@ -22,17 +29,16 @@ CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, DOMAIN, MANUFACTURER, ) from .coordinator import CanaryDataUpdateCoordinator -MIN_TIME_BETWEEN_SESSION_RENEW = timedelta(seconds=90) +MIN_TIME_BETWEEN_SESSION_RENEW: Final = timedelta(seconds=90) -PLATFORM_SCHEMA = vol.All( +PLATFORM_SCHEMA: Final = vol.All( cv.deprecated(CONF_FFMPEG_ARGUMENTS), - PLATFORM_SCHEMA.extend( + PARENT_PLATFORM_SCHEMA.extend( { vol.Optional( CONF_FFMPEG_ARGUMENTS, default=DEFAULT_FFMPEG_ARGUMENTS @@ -51,10 +57,10 @@ async def async_setup_entry( coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ DATA_COORDINATOR ] - ffmpeg_arguments = entry.options.get( + ffmpeg_arguments: str = entry.options.get( CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS ) - cameras = [] + cameras: list[CanaryCamera] = [] for location_id, location in coordinator.data["locations"].items(): for device in location.devices: @@ -65,7 +71,6 @@ async def async_setup_entry( coordinator, location_id, device, - DEFAULT_TIMEOUT, ffmpeg_arguments, ) ) @@ -76,76 +81,70 @@ async def async_setup_entry( class CanaryCamera(CoordinatorEntity, Camera): """An implementation of a Canary security camera.""" - def __init__(self, hass, coordinator, location_id, device, timeout, ffmpeg_args): + coordinator: CanaryDataUpdateCoordinator + + def __init__( + self, + hass: HomeAssistant, + coordinator: CanaryDataUpdateCoordinator, + location_id: str, + device: Device, + ffmpeg_args: str, + ) -> None: """Initialize a Canary security camera.""" super().__init__(coordinator) Camera.__init__(self) - self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg: FFmpegManager = get_ffmpeg_manager(hass) self._ffmpeg_arguments = ffmpeg_args self._location_id = location_id self._device = device - self._device_id = device.device_id - self._device_name = device.name - self._device_type_name = device.device_type["name"] - self._timeout = timeout - self._live_stream_session = None + self._live_stream_session: LiveStreamSession | None = None + self._attr_name = device.name + self._attr_unique_id = str(device.device_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device.device_id))}, + manufacturer=MANUFACTURER, + model=device.device_type["name"], + name=device.name, + ) @property - def location(self): + def location(self) -> Location: """Return information about the location.""" return self.coordinator.data["locations"][self._location_id] @property - def name(self): - """Return the name of this device.""" - return self._device_name - - @property - def unique_id(self): - """Return the unique ID of this camera.""" - return str(self._device_id) - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, str(self._device_id))}, - "name": self._device_name, - "model": self._device_type_name, - "manufacturer": MANUFACTURER, - } - - @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" - return self.location.is_recording + return self.location.is_recording # type: ignore[no-any-return] @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return not self.location.is_recording - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" await self.hass.async_add_executor_job(self.renew_live_stream_session) live_stream_url = await self.hass.async_add_executor_job( getattr, self._live_stream_session, "live_stream_url" ) - - ffmpeg = ImageFrame(self._ffmpeg.binary) - image = await asyncio.shield( - ffmpeg.get_image( - live_stream_url, - output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments, - ) + return await ffmpeg.async_get_image( + self.hass, + live_stream_url, + extra_cmd=self._ffmpeg_arguments, + width=width, + height=height, ) - return image - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + self, request: Request + ) -> StreamResponse | None: """Generate an HTTP MJPEG stream from the camera.""" if self._live_stream_session is None: - return + return None stream = CameraMjpeg(self._ffmpeg.binary) await stream.open_camera( @@ -164,7 +163,7 @@ async def handle_async_mjpeg_stream(self, request): await stream.close() @Throttle(MIN_TIME_BETWEEN_SESSION_RENEW) - def renew_live_stream_session(self): + def renew_live_stream_session(self) -> None: """Renew live stream session.""" self._live_stream_session = self.coordinator.canary.get_live_stream_session( self._device diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index f54ae3c308e88..967273a0f3426 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -2,13 +2,13 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Final from canary.api import Api -from requests import ConnectTimeout, HTTPError +from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult @@ -21,10 +21,10 @@ DOMAIN, ) -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: +def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -46,7 +46,7 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return CanaryOptionsFlowHandler(config_entry) @@ -56,7 +56,9 @@ async def async_step_import( """Handle a flow initiated by configuration file.""" return await self.async_step_user(user_input) - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -100,11 +102,13 @@ async def async_step_user(self, user_input: ConfigType | None = None) -> FlowRes class CanaryOptionsFlowHandler(OptionsFlow): """Handle Canary client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: ConfigType | None = None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage Canary options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/canary/const.py b/homeassistant/components/canary/const.py index 8219a485ef972..210da35c7c188 100644 --- a/homeassistant/components/canary/const.py +++ b/homeassistant/components/canary/const.py @@ -1,16 +1,18 @@ """Constants for the Canary integration.""" -DOMAIN = "canary" +from typing import Final -MANUFACTURER = "Canary Connect, Inc" +DOMAIN: Final = "canary" + +MANUFACTURER: Final = "Canary Connect, Inc" # Configuration -CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" +CONF_FFMPEG_ARGUMENTS: Final = "ffmpeg_arguments" # Data -DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" +DATA_COORDINATOR: Final = "coordinator" +DATA_UNDO_UPDATE_LISTENER: Final = "undo_update_listener" # Defaults -DEFAULT_FFMPEG_ARGUMENTS = "-pred 1" -DEFAULT_TIMEOUT = 10 +DEFAULT_FFMPEG_ARGUMENTS: Final = "-pred 1" +DEFAULT_TIMEOUT: Final = 10 diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index a7f8ea7c8de4c..4c6c9ce57778f 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -1,15 +1,19 @@ """Provides the Canary DataUpdateCoordinator.""" +from __future__ import annotations + +from collections.abc import ValuesView from datetime import timedelta import logging from async_timeout import timeout -from canary.api import Api -from requests import ConnectTimeout, HTTPError +from canary.api import Api, Location +from requests.exceptions import ConnectTimeout, HTTPError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +from .model import CanaryData _LOGGER = logging.getLogger(__name__) @@ -17,7 +21,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Canary data.""" - def __init__(self, hass: HomeAssistant, *, api: Api): + def __init__(self, hass: HomeAssistant, *, api: Api) -> None: """Initialize global Canary data updater.""" self.canary = api update_interval = timedelta(seconds=30) @@ -29,10 +33,10 @@ def __init__(self, hass: HomeAssistant, *, api: Api): update_interval=update_interval, ) - def _update_data(self) -> dict: + def _update_data(self) -> CanaryData: """Fetch data from Canary via sync functions.""" - locations_by_id = {} - readings_by_device_id = {} + locations_by_id: dict[str, Location] = {} + readings_by_device_id: dict[str, ValuesView] = {} for location in self.canary.get_locations(): location_id = location.location_id @@ -49,7 +53,7 @@ def _update_data(self) -> dict: "readings": readings_by_device_id, } - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> CanaryData: """Fetch data from Canary.""" try: diff --git a/homeassistant/components/canary/model.py b/homeassistant/components/canary/model.py new file mode 100644 index 0000000000000..35c6a61a83585 --- /dev/null +++ b/homeassistant/components/canary/model.py @@ -0,0 +1,18 @@ +"""Constants for the Canary integration.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from typing import List, Optional, Tuple, TypedDict + +from canary.api import Location + + +class CanaryData(TypedDict): + """TypedDict for Canary Coordinator Data.""" + + locations: dict[str, Location] + readings: dict[str, ValuesView] + + +SensorTypeItem = Tuple[str, Optional[str], Optional[str], Optional[str], List[str]] diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 0378d34a989fa..cf2b097031166 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -1,55 +1,55 @@ """Support for Canary sensors.""" from __future__ import annotations -from canary.api import SensorType +from typing import Final -from homeassistant.components.sensor import SensorEntity +from canary.api import Device, Location, SensorType + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER from .coordinator import CanaryDataUpdateCoordinator +from .model import SensorTypeItem -SENSOR_VALUE_PRECISION = 2 -ATTR_AIR_QUALITY = "air_quality" +SENSOR_VALUE_PRECISION: Final = 2 +ATTR_AIR_QUALITY: Final = "air_quality" # Define variables to store the device names, as referred to by the Canary API. # Note: If Canary change the name of any of their devices (which they have done), # then these variables will need updating, otherwise the sensors will stop working # and disappear in Home Assistant. -CANARY_PRO = "Canary Pro" -CANARY_FLEX = "Canary Flex" +CANARY_PRO: Final = "Canary Pro" +CANARY_FLEX: Final = "Canary Flex" # Sensor types are defined like so: # sensor type name, unit_of_measurement, icon, device class, products supported -SENSOR_TYPES = [ - ["temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE, [CANARY_PRO]], - ["humidity", PERCENTAGE, None, DEVICE_CLASS_HUMIDITY, [CANARY_PRO]], - ["air_quality", None, "mdi:weather-windy", None, [CANARY_PRO]], - [ +SENSOR_TYPES: Final[list[SensorTypeItem]] = [ + ("temperature", TEMP_CELSIUS, None, SensorDeviceClass.TEMPERATURE, [CANARY_PRO]), + ("humidity", PERCENTAGE, None, SensorDeviceClass.HUMIDITY, [CANARY_PRO]), + ("air_quality", None, "mdi:weather-windy", None, [CANARY_PRO]), + ( "wifi", SIGNAL_STRENGTH_DECIBELS_MILLIWATT, None, - DEVICE_CLASS_SIGNAL_STRENGTH, + SensorDeviceClass.SIGNAL_STRENGTH, [CANARY_FLEX], - ], - ["battery", PERCENTAGE, None, DEVICE_CLASS_BATTERY, [CANARY_FLEX]], + ), + ("battery", PERCENTAGE, None, SensorDeviceClass.BATTERY, [CANARY_FLEX]), ] -STATE_AIR_QUALITY_NORMAL = "normal" -STATE_AIR_QUALITY_ABNORMAL = "abnormal" -STATE_AIR_QUALITY_VERY_ABNORMAL = "very_abnormal" +STATE_AIR_QUALITY_NORMAL: Final = "normal" +STATE_AIR_QUALITY_ABNORMAL: Final = "abnormal" +STATE_AIR_QUALITY_VERY_ABNORMAL: Final = "very_abnormal" async def async_setup_entry( @@ -61,7 +61,7 @@ async def async_setup_entry( coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ DATA_COORDINATOR ] - sensors = [] + sensors: list[CanarySensor] = [] for location in coordinator.data["locations"].values(): for device in location.devices: @@ -79,16 +79,23 @@ async def async_setup_entry( class CanarySensor(CoordinatorEntity, SensorEntity): """Representation of a Canary sensor.""" - def __init__(self, coordinator, sensor_type, location, device): + coordinator: CanaryDataUpdateCoordinator + + def __init__( + self, + coordinator: CanaryDataUpdateCoordinator, + sensor_type: SensorTypeItem, + location: Location, + device: Device, + ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self._sensor_type = sensor_type self._device_id = device.device_id - self._device_name = device.name - self._device_type_name = device.device_type["name"] sensor_type_name = sensor_type[0].replace("_", " ").title() - self._name = f"{location.name} {device.name} {sensor_type_name}" + self._attr_name = f"{location.name} {device.name} {sensor_type_name}" canary_sensor_type = None if self._sensor_type[0] == "air_quality": @@ -103,9 +110,19 @@ def __init__(self, coordinator, sensor_type, location, device): canary_sensor_type = SensorType.BATTERY self._canary_type = canary_sensor_type + self._attr_unique_id = f"{device.device_id}_{sensor_type[0]}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device.device_id))}, + model=device.device_type["name"], + manufacturer=MANUFACTURER, + name=device.name, + ) + self._attr_native_unit_of_measurement = sensor_type[1] + self._attr_device_class = sensor_type[3] + self._attr_icon = sensor_type[2] @property - def reading(self): + def reading(self) -> float | None: """Return the device sensor reading.""" readings = self.coordinator.data["readings"][self._device_id] @@ -124,47 +141,12 @@ def reading(self): return None @property - def name(self): - """Return the name of the Canary sensor.""" - return self._name - - @property - def state(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.reading @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._sensor_type[0]}" - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, str(self._device_id))}, - "name": self._device_name, - "model": self._device_type_name, - "manufacturer": MANUFACTURER, - } - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._sensor_type[1] - - @property - def device_class(self): - """Device class for the sensor.""" - return self._sensor_type[3] - - @property - def icon(self): - """Icon for the sensor.""" - return self._sensor_type[2] - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" reading = self.reading @@ -174,7 +156,7 @@ def extra_state_attributes(self): air_quality = STATE_AIR_QUALITY_VERY_ABNORMAL elif reading <= 0.59: air_quality = STATE_AIR_QUALITY_ABNORMAL - elif reading <= 1.0: + else: air_quality = STATE_AIR_QUALITY_NORMAL return {ATTR_AIR_QUALITY: air_quality} diff --git a/homeassistant/components/canary/translations/bg.json b/homeassistant/components/canary/translations/bg.json new file mode 100644 index 0000000000000..337f138444615 --- /dev/null +++ b/homeassistant/components/canary/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/canary/translations/ca.json b/homeassistant/components/canary/translations/ca.json index c4b80d7537aa7..399f1d8f2866c 100644 --- a/homeassistant/components/canary/translations/ca.json +++ b/homeassistant/components/canary/translations/ca.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/de.json b/homeassistant/components/canary/translations/de.json index bdd746c314988..93ce43c61f568 100644 --- a/homeassistant/components/canary/translations/de.json +++ b/homeassistant/components/canary/translations/de.json @@ -7,13 +7,14 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { "password": "Passwort", "username": "Benutzername" - } + }, + "title": "Mit Canary verbinden" } } }, @@ -21,6 +22,7 @@ "step": { "init": { "data": { + "ffmpeg_arguments": "An ffmpeg \u00fcbergebene Argumente f\u00fcr Kameras", "timeout": "Anfrage-Timeout (Sekunden)" } } diff --git a/homeassistant/components/canary/translations/es-419.json b/homeassistant/components/canary/translations/es-419.json new file mode 100644 index 0000000000000..8ce6a8fb855a2 --- /dev/null +++ b/homeassistant/components/canary/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Conectarse a Canary" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Argumentos pasados a ffmpeg para c\u00e1maras", + "timeout": "Solicitar tiempo de espera (segundos)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/canary/translations/et.json b/homeassistant/components/canary/translations/et.json index 1d30b9efe9ed4..09f46ff20bb2e 100644 --- a/homeassistant/components/canary/translations/et.json +++ b/homeassistant/components/canary/translations/et.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "Canary {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/fr.json b/homeassistant/components/canary/translations/fr.json index 9bb1761f9fba9..45b06208df48f 100644 --- a/homeassistant/components/canary/translations/fr.json +++ b/homeassistant/components/canary/translations/fr.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00c9chec de connexion" }, - "flow_title": "Canary : {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/he.json b/homeassistant/components/canary/translations/he.json new file mode 100644 index 0000000000000..fbea60c370425 --- /dev/null +++ b/homeassistant/components/canary/translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d1\u05e7\u05e9\u05d4 (\u05e9\u05e0\u05d9\u05d5\u05ea)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/canary/translations/hu.json b/homeassistant/components/canary/translations/hu.json index c2c70fdbf22ae..77c7290174268 100644 --- a/homeassistant/components/canary/translations/hu.json +++ b/homeassistant/components/canary/translations/hu.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -22,6 +22,7 @@ "step": { "init": { "data": { + "ffmpeg_arguments": "A kamer\u00e1khoz az ffmpeg-nek \u00e1tadott argumentumok", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s (opcion\u00e1lis)" } } diff --git a/homeassistant/components/canary/translations/id.json b/homeassistant/components/canary/translations/id.json index 5f092847b4d49..6fdc76feb7206 100644 --- a/homeassistant/components/canary/translations/id.json +++ b/homeassistant/components/canary/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/it.json b/homeassistant/components/canary/translations/it.json index b29a758acaafd..8a29451bb44b1 100644 --- a/homeassistant/components/canary/translations/it.json +++ b/homeassistant/components/canary/translations/it.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Impossibile connettersi" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/ja.json b/homeassistant/components/canary/translations/ja.json new file mode 100644 index 0000000000000..9f9903b86e43c --- /dev/null +++ b/homeassistant/components/canary/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Canary\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "ffmpeg\u306b\u6e21\u3055\u308c\u308b\u30ab\u30e1\u30e9\u7528\u306e\u5f15\u6570", + "timeout": "\u30ea\u30af\u30a8\u30b9\u30c8\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/canary/translations/nl.json b/homeassistant/components/canary/translations/nl.json index fbe642bbc9602..ed64af346ea5a 100644 --- a/homeassistant/components/canary/translations/nl.json +++ b/homeassistant/components/canary/translations/nl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Kon niet verbinden" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/no.json b/homeassistant/components/canary/translations/no.json index 0c60c44d4ab75..c092db027930c 100644 --- a/homeassistant/components/canary/translations/no.json +++ b/homeassistant/components/canary/translations/no.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/pl.json b/homeassistant/components/canary/translations/pl.json index 1da4db78731fb..9090b18441440 100644 --- a/homeassistant/components/canary/translations/pl.json +++ b/homeassistant/components/canary/translations/pl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/ru.json b/homeassistant/components/canary/translations/ru.json index 51052d0d68d4b..6509ab99cd62d 100644 --- a/homeassistant/components/canary/translations/ru.json +++ b/homeassistant/components/canary/translations/ru.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/canary/translations/tr.json b/homeassistant/components/canary/translations/tr.json index 6d18629b06792..2e5b05f58c722 100644 --- a/homeassistant/components/canary/translations/tr.json +++ b/homeassistant/components/canary/translations/tr.json @@ -7,11 +7,23 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Canary'ya ba\u011flan" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Kameralar i\u00e7in ffmpeg'e ge\u00e7irilen arg\u00fcmanlar", + "timeout": "\u0130stek Zaman A\u015f\u0131m\u0131 (saniye)" } } } diff --git a/homeassistant/components/canary/translations/zh-Hant.json b/homeassistant/components/canary/translations/zh-Hant.json index c53ffd8327917..6c7dbea4daad7 100644 --- a/homeassistant/components/canary/translations/zh-Hant.json +++ b/homeassistant/components/canary/translations/zh-Hant.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "Canary\uff1a{name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 43b6b77ebd298..b0dffcb2e9ac8 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -4,23 +4,24 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from . import home_assistant_cast from .const import DOMAIN from .media_player import ENTITY_SCHEMA -# Deprecated from 2021.4, remove in 2021.6 -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.MEDIA_PLAYER] + async def async_setup(hass, config): """Set up the Cast component.""" - conf = config.get(DOMAIN) - - if conf is not None: + if (conf := config.get(DOMAIN)) is not None: media_player_config_validated = [] media_player_config = conf.get("media_player", {}) if not isinstance(media_player_config, list): @@ -43,13 +44,12 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry: config_entries.ConfigEntry): +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: """Set up Cast from a config entry.""" await home_assistant_cast.async_setup_ha_cast(hass, entry) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "media_player") - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index bb316f2b5119f..aaf8d5b9c6c94 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -2,6 +2,8 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, CONF_UUID, DOMAIN @@ -49,7 +51,9 @@ async def async_step_user(self, user_input=None): return await self.async_step_config() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 03ffdfbd15c99..06db70b830abf 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,7 +1,6 @@ """Consts for Cast integration.""" DOMAIN = "cast" -DEFAULT_PORT = 8009 # Stores a threading.Lock that is held by the internal pychromecast discovery. INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running" diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py index a5ac4c0204785..e76302fefbc17 100644 --- a/homeassistant/components/cast/discovery.py +++ b/homeassistant/components/cast/discovery.py @@ -11,7 +11,6 @@ from .const import ( CAST_BROWSER_KEY, CONF_KNOWN_HOSTS, - DEFAULT_PORT, INTERNAL_DISCOVERY_RUNNING_KEY, SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, @@ -21,15 +20,13 @@ _LOGGER = logging.getLogger(__name__) -def discover_chromecast(hass: HomeAssistant, device_info): +def discover_chromecast( + hass: HomeAssistant, cast_info: pychromecast.models.CastInfo +) -> None: """Discover a Chromecast.""" info = ChromecastInfo( - services=device_info.services, - uuid=device_info.uuid, - model_name=device_info.model_name, - friendly_name=device_info.friendly_name, - is_audio_group=device_info.port != DEFAULT_PORT, + cast_info=cast_info, ) if info.uuid is None: @@ -74,10 +71,7 @@ def remove_cast(self, uuid, service, cast_info): _remove_chromecast( hass, ChromecastInfo( - services=cast_info.services, - uuid=cast_info.uuid, - model_name=cast_info.model_name, - friendly_name=cast_info.friendly_name, + cast_info=cast_info, ), ) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 71caa6490d852..ba7380bcaa208 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -5,7 +5,8 @@ import attr from pychromecast import dial -from pychromecast.const import CAST_MANUFACTURERS +from pychromecast.const import CAST_TYPE_GROUP +from pychromecast.models import CastInfo @attr.s(slots=True, frozen=True) @@ -15,90 +16,49 @@ class ChromecastInfo: This also has the same attributes as the mDNS fields by zeroconf. """ - services: set | None = attr.ib() - uuid: str | None = attr.ib( - converter=attr.converters.optional(str), default=None - ) # always convert UUID to string if not None - _manufacturer = attr.ib(type=Optional[str], default=None) - model_name: str = attr.ib(default="") - friendly_name: str | None = attr.ib(default=None) - is_audio_group = attr.ib(type=Optional[bool], default=False) + cast_info: CastInfo = attr.ib() is_dynamic_group = attr.ib(type=Optional[bool], default=None) @property - def is_information_complete(self) -> bool: - """Return if all information is filled out.""" - want_dynamic_group = self.is_audio_group - have_dynamic_group = self.is_dynamic_group is not None - have_all_except_dynamic_group = all( - attr.astuple( - self, - filter=attr.filters.exclude( - attr.fields(ChromecastInfo).is_dynamic_group - ), - ) - ) - return have_all_except_dynamic_group and ( - not want_dynamic_group or have_dynamic_group - ) + def friendly_name(self) -> str: + """Return the UUID.""" + return self.cast_info.friendly_name + + @property + def is_audio_group(self) -> bool: + """Return if the cast is an audio group.""" + return self.cast_info.cast_type == CAST_TYPE_GROUP @property - def manufacturer(self) -> str: - """Return the manufacturer.""" - if self._manufacturer: - return self._manufacturer - if not self.model_name: - return None - return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") + def uuid(self) -> bool: + """Return the UUID.""" + return self.cast_info.uuid def fill_out_missing_chromecast_info(self) -> ChromecastInfo: """Return a new ChromecastInfo object with missing attributes filled in. Uses blocking HTTP / HTTPS. """ - if self.is_information_complete: + if not self.is_audio_group or self.is_dynamic_group is not None: # We have all information, no need to check HTTP API. return self # Fill out missing group information via HTTP API. - if self.is_audio_group: - is_dynamic_group = False - http_group_status = None - if self.uuid: - http_group_status = dial.get_multizone_status( - None, - services=self.services, - zconf=ChromeCastZeroconf.get_zeroconf(), - ) - if http_group_status is not None: - is_dynamic_group = any( - str(g.uuid) == self.uuid - for g in http_group_status.dynamic_groups - ) - - return ChromecastInfo( - services=self.services, - uuid=self.uuid, - friendly_name=self.friendly_name, - model_name=self.model_name, - is_audio_group=True, - is_dynamic_group=is_dynamic_group, - ) - - # Fill out some missing information (friendly_name, uuid) via HTTP dial. - http_device_status = dial.get_device_status( - None, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf() + is_dynamic_group = False + http_group_status = None + http_group_status = dial.get_multizone_status( + None, + services=self.cast_info.services, + zconf=ChromeCastZeroconf.get_zeroconf(), ) - if http_device_status is None: - # HTTP dial didn't give us any new information. - return self + if http_group_status is not None: + is_dynamic_group = any( + g.uuid == self.cast_info.uuid for g in http_group_status.dynamic_groups + ) return ChromecastInfo( - services=self.services, - uuid=(self.uuid or http_device_status.uuid), - friendly_name=(self.friendly_name or http_device_status.friendly_name), - manufacturer=(self.manufacturer or http_device_status.manufacturer), - model_name=(self.model_name or http_device_status.model_name), + cast_info=self.cast_info, + is_dynamic_group=is_dynamic_group, ) diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index bb0354bb68e17..e4b72f0bf401f 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -28,7 +28,7 @@ async def async_setup_ha_cast( if user is None: user = await hass.auth.async_create_system_user( - "Home Assistant Cast", [auth.GROUP_ID_ADMIN] + "Home Assistant Cast", group_ids=[auth.GROUP_ID_ADMIN] ) hass.config_entries.async_update_entry( entry, data={**entry.data, "user_id": user.id} @@ -80,6 +80,5 @@ async def async_remove_user( """Remove Home Assistant Cast user.""" user_id: str | None = entry.data.get("user_id") - if user_id is not None: - user = await hass.auth.async_get_user(user_id) + if user_id is not None and (user := await hass.auth.async_get_user(user_id)): await hass.auth.async_remove_user(user) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index c104ff7a12e18..b084540bebb43 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==9.1.2"], + "requirements": ["pychromecast==10.2.2"], "after_dependencies": [ "cloud", "http", diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 9cad02f6c74a0..f3cc9c3266179 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -3,7 +3,7 @@ import asyncio from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta import functools as ft import json import logging @@ -46,6 +46,7 @@ from homeassistant.components.plex.const import PLEX_URI_SCHEME from homeassistant.components.plex.services import lookup_plex_media from homeassistant.const import ( + CAST_APP_ID_HOMEASSISTANT_LOVELACE, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, @@ -55,6 +56,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.network import NoURLAvailableError, get_url import homeassistant.util.dt as dt_util from homeassistant.util.logging import async_create_catching_coro @@ -74,17 +76,11 @@ _LOGGER = logging.getLogger(__name__) -CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" +APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",) -SUPPORT_CAST = ( - SUPPORT_PAUSE - | SUPPORT_PLAY - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP - | SUPPORT_TURN_OFF - | SUPPORT_TURN_ON -) +CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" +SUPPORT_CAST = SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF ENTITY_SCHEMA = vol.All( vol.Schema( @@ -139,7 +135,7 @@ def async_cast_discovered(discover: ChromecastInfo) -> None: """Handle discovery of a new chromecast.""" # If wanted_uuids is set, we're only accepting specific cast devices identified # by UUID - if wanted_uuids is not None and discover.uuid not in wanted_uuids: + if wanted_uuids is not None and str(discover.uuid) not in wanted_uuids: # UUID not matching, ignore. return @@ -160,24 +156,35 @@ class CastDevice(MediaPlayerEntity): "elected leader" itself. """ - def __init__(self, cast_info: ChromecastInfo): + _attr_should_poll = False + _attr_media_image_remotely_accessible = True + + def __init__(self, cast_info: ChromecastInfo) -> None: """Initialize the cast device.""" self._cast_info = cast_info - self.services = cast_info.services self._chromecast: pychromecast.Chromecast | None = None self.cast_status = None self.media_status = None self.media_status_received = None - self.mz_media_status = {} - self.mz_media_status_received = {} + self.mz_media_status: dict[str, pychromecast.controllers.media.MediaStatus] = {} + self.mz_media_status_received: dict[str, datetime] = {} self.mz_mgr = None - self._available = False + self._attr_available = False self._status_listener: CastStatusListener | None = None self._hass_cast_controller: HomeAssistantController | None = None self._add_remove_handler = None self._cast_view_remove_handler = None + self._attr_unique_id = str(cast_info.uuid) + self._attr_name = cast_info.friendly_name + if cast_info.cast_info.model_name != "Google Cast Group": + self._attr_device_info = DeviceInfo( + identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))}, + manufacturer=str(cast_info.cast_info.manufacturer), + model=cast_info.cast_info.model_name, + name=str(cast_info.friendly_name), + ) async def async_added_to_hass(self): """Create chromecast object when added to hass.""" @@ -217,18 +224,11 @@ async def async_connect_to_chromecast(self): "[%s %s] Connecting to cast device by service %s", self.entity_id, self._cast_info.friendly_name, - self.services, + self._cast_info.cast_info.services, ) chromecast = await self.hass.async_add_executor_job( pychromecast.get_chromecast_from_cast_info, - pychromecast.discovery.CastInfo( - self.services, - self._cast_info.uuid, - self._cast_info.model_name, - self._cast_info.friendly_name, - None, - None, - ), + self._cast_info.cast_info, ChromeCastZeroconf.get_zeroconf(), ) self._chromecast = chromecast @@ -239,7 +239,7 @@ async def async_connect_to_chromecast(self): self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) - self._available = False + self._attr_available = False self.cast_status = chromecast.status self.media_status = chromecast.media_controller.status self._chromecast.start() @@ -255,7 +255,7 @@ async def _async_disconnect(self): self.entity_id, self._cast_info.friendly_name, ) - self._available = False + self._attr_available = False self.async_write_ha_state() await self.hass.async_add_executor_job(self._chromecast.disconnect) @@ -282,6 +282,10 @@ def _invalidate(self): def new_cast_status(self, cast_status): """Handle updates of the cast status.""" self.cast_status = cast_status + self._attr_volume_level = cast_status.volume_level if cast_status else None + self._attr_is_volume_muted = ( + cast_status.volume_muted if self.cast_status else None + ) self.schedule_update_ha_state() def new_media_status(self, media_status): @@ -334,13 +338,13 @@ def new_connection_status(self, connection_status): connection_status.status, ) if connection_status.status == CONNECTION_STATUS_DISCONNECTED: - self._available = False + self._attr_available = False self._invalidate() self.schedule_update_ha_state() return new_available = connection_status.status == CONNECTION_STATUS_CONNECTED - if new_available != self._available: + if new_available != self.available: # Connection status callbacks happen often when disconnected. # Only update state when availability changed to put less pressure # on state machine. @@ -350,7 +354,7 @@ def new_connection_status(self, connection_status): self._cast_info.friendly_name, connection_status.status, ) - self._available = new_available + self._attr_available = new_available self.schedule_update_ha_state() def multizone_new_media_status(self, group_uuid, media_status): @@ -393,11 +397,14 @@ def turn_on(self): return if self._chromecast.app_id is not None: - # Quit the previous app before starting splash screen + # Quit the previous app before starting splash screen or media player self._chromecast.quit_app() # The only way we can turn the Chromecast is on is by launching an app - self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED) + if self._chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST: + self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED) + else: + self._chromecast.start_app(pychromecast.config.APP_MEDIA_RECEIVER) def turn_off(self): """Turn off the cast device.""" @@ -482,10 +489,15 @@ async def async_play_media(self, media_type, media_id, **kwargs): def play_media(self, media_type, media_id, **kwargs): """Play media from a URL.""" + extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) + metadata = extra.get("metadata") + # We do not want this to be forwarded to a group if media_type == CAST_DOMAIN: try: app_data = json.loads(media_id) + if metadata is not None: + app_data["metadata"] = extra.get("metadata") except json.JSONDecodeError: _LOGGER.error("Invalid JSON in media_content_id") raise @@ -518,35 +530,8 @@ def play_media(self, media_type, media_id, **kwargs): self._chromecast.register_handler(controller) controller.play_media(media) else: - self._chromecast.media_controller.play_media( - media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {}) - ) - - # ========== Properties ========== - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._cast_info.friendly_name - - @property - def device_info(self): - """Return information about the device.""" - cast_info = self._cast_info - - if cast_info.model_name == "Google Cast Group": - return None - - return { - "name": cast_info.friendly_name, - "identifiers": {(CAST_DOMAIN, cast_info.uuid.replace("-", ""))}, - "model": cast_info.model_name, - "manufacturer": cast_info.manufacturer, - } + app_data = {"media_id": media_id, "media_type": media_type, **extra} + quick_play(self._chromecast, "default_media_receiver", app_data) def _media_status(self): """ @@ -570,46 +555,41 @@ def _media_status(self): @property def state(self): """Return the state of the player.""" - media_status = self._media_status()[0] - - if media_status is None: - return None - if media_status.player_is_playing: + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: return STATE_PLAYING - if media_status.player_is_paused: - return STATE_PAUSED - if media_status.player_is_idle: + if (media_status := self._media_status()[0]) is not None: + if media_status.player_is_playing: + return STATE_PLAYING + if media_status.player_is_paused: + return STATE_PAUSED + if media_status.player_is_idle: + return STATE_IDLE + if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID: + if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO: + # Some apps don't report media status, show the player as playing + return STATE_PLAYING return STATE_IDLE if self._chromecast is not None and self._chromecast.is_idle: return STATE_OFF return None - @property - def available(self): - """Return True if the cast device is connected.""" - return self._available - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self.cast_status.volume_level if self.cast_status else None - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self.cast_status.volume_muted if self.cast_status else None - @property def media_content_id(self): """Content ID of current playing media.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status = self._media_status()[0] return media_status.content_id if media_status else None @property def media_content_type(self): """Content type of current playing media.""" - media_status = self._media_status()[0] - if media_status is None: + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None + if (media_status := self._media_status()[0]) is None: return None if media_status.media_is_tvshow: return MEDIA_TYPE_TVSHOW @@ -622,25 +602,22 @@ def media_content_type(self): @property def media_duration(self): """Duration of current playing media in seconds.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status = self._media_status()[0] return media_status.duration if media_status else None @property def media_image_url(self): """Image url of current playing media.""" - media_status = self._media_status()[0] - if media_status is None: + if (media_status := self._media_status()[0]) is None: return None images = media_status.images return images[0].url if images and images[0].url else None - @property - def media_image_remotely_accessible(self) -> bool: - """If the image url is remotely accessible.""" - return True - @property def media_title(self): """Title of current playing media.""" @@ -705,13 +682,20 @@ def supported_features(self): support = SUPPORT_CAST media_status = self._media_status()[0] + if self._chromecast and self._chromecast.cast_type in ( + pychromecast.const.CAST_TYPE_CHROMECAST, + pychromecast.const.CAST_TYPE_AUDIO, + ): + support |= SUPPORT_TURN_ON + if ( self.cast_status and self.cast_status.volume_control_type != VOLUME_CONTROL_TYPE_FIXED ): support |= SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET - if media_status: + if media_status and self.app_id != CAST_APP_ID_HOMEASSISTANT_LOVELACE: + support |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP if media_status.supports_queue_next: support |= SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK if media_status.supports_seek: @@ -725,6 +709,9 @@ def supported_features(self): @property def media_position(self): """Position of current playing media in seconds.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status = self._media_status()[0] if media_status is None or not ( media_status.player_is_playing @@ -740,14 +727,11 @@ def media_position_updated_at(self): Returns value from homeassistant.util.dt.utcnow(). """ + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status_recevied = self._media_status()[1] return media_status_recevied - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._cast_info.uuid - async def _async_cast_discovered(self, discover: ChromecastInfo): """Handle discovery of new Chromecast.""" if self._cast_info.uuid != discover.uuid: @@ -769,7 +753,7 @@ def _handle_signal_show_view( url_path: str | None, ): """Handle a show view signal.""" - if entity_id != self.entity_id: + if entity_id != self.entity_id or self._chromecast is None: return if self._hass_cast_controller is None: @@ -787,7 +771,6 @@ def __init__(self, hass, cast_info: ChromecastInfo): self.hass = hass self._cast_info = cast_info - self.services = cast_info.services self._chromecast: pychromecast.Chromecast | None = None self.mz_mgr = None self._status_listener: CastStatusListener | None = None @@ -835,18 +818,11 @@ async def async_connect_to_chromecast(self): "[%s %s] Connecting to cast device by service %s", "Dynamic group", self._cast_info.friendly_name, - self.services, + self._cast_info.cast_info.services, ) chromecast = await self.hass.async_add_executor_job( pychromecast.get_chromecast_from_cast_info, - pychromecast.discovery.CastInfo( - self.services, - self._cast_info.uuid, - self._cast_info.model_name, - self._cast_info.friendly_name, - None, - None, - ), + self._cast_info.cast_info, ChromeCastZeroconf.get_zeroconf(), ) self._chromecast = chromecast @@ -897,7 +873,7 @@ async def _async_cast_removed(self, discover: ChromecastInfo): # Removed is not our device. return - if not discover.services: + if not discover.cast_info.services: # Clean up the dynamic group _LOGGER.debug("Clean up dynamic group: %s", discover) await self.async_tear_down() diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml index 9b2b0a739b04b..f0fbcf4a8d757 100644 --- a/homeassistant/components/cast/services.yaml +++ b/homeassistant/components/cast/services.yaml @@ -6,7 +6,6 @@ show_lovelace_view: name: Entity description: Media Player entity to show the Lovelace view on. required: true - example: "media_player.kitchen" selector: entity: integration: cast diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 02bfeccf794af..719465e98ca18 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -30,7 +30,7 @@ }, "advanced_options": { "title": "Advanced Google Cast configuration", - "description": "Allowed UUIDs - A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you don’t want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be will be passed to pychromecast.IGNORE_CEC.", + "description": "Allowed UUIDs - A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you don’t want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.", "data": { "ignore_cec": "Ignore CEC", "uuid": "Allowed UUIDs" diff --git a/homeassistant/components/cast/translations/bg.json b/homeassistant/components/cast/translations/bg.json index 92a840cc5af07..0ab9d863eff91 100644 --- a/homeassistant/components/cast/translations/bg.json +++ b/homeassistant/components/cast/translations/bg.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "\u0412 \u043c\u0440\u0435\u0436\u0430\u0442\u0430 \u043d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 Google Cast \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast." }, "step": { diff --git a/homeassistant/components/cast/translations/ca.json b/homeassistant/components/cast/translations/ca.json index 944c3c043d530..7d87fffb8eb77 100644 --- a/homeassistant/components/cast/translations/ca.json +++ b/homeassistant/components/cast/translations/ca.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "No s'han trobat dispositius a la xarxa", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { @@ -39,14 +38,6 @@ }, "description": "Amfitrions coneguts - Llista, separada per comes, dels noms d'amfitri\u00f3 o adreces IP dels dispositius Cast. Utilitza-ho si el descobriment mDNS no funciona.", "title": "Configuraci\u00f3 de Google Cast" - }, - "options": { - "data": { - "ignore_cec": "Llista opcional que es passar\u00e0 a pychromecast.IGNORE_CEC.", - "known_hosts": "Llista opcional d'amfitrions coneguts per si el descobriment mDNS deixa de funcionar.", - "uuid": "Llista opcional d'UUIDs. No s'afegiran 'casts' que no siguin a la llista." - }, - "description": "Introdueix la configuraci\u00f3 de Google Cast." } } } diff --git a/homeassistant/components/cast/translations/cs.json b/homeassistant/components/cast/translations/cs.json index d3f0e37a13278..f04465341e31f 100644 --- a/homeassistant/components/cast/translations/cs.json +++ b/homeassistant/components/cast/translations/cs.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." }, "step": { diff --git a/homeassistant/components/cast/translations/da.json b/homeassistant/components/cast/translations/da.json index fe6ed03bb5aa3..6dc6f552c8cea 100644 --- a/homeassistant/components/cast/translations/da.json +++ b/homeassistant/components/cast/translations/da.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Ingen Google Cast enheder kunne findes p\u00e5 netv\u00e6rket.", "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Google Cast" }, "step": { diff --git a/homeassistant/components/cast/translations/de.json b/homeassistant/components/cast/translations/de.json index e9821bbe9373b..b337a8575e0d0 100644 --- a/homeassistant/components/cast/translations/de.json +++ b/homeassistant/components/cast/translations/de.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden.", - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "invalid_known_hosts": "Bekannte Hosts m\u00fcssen eine durch Kommata getrennte Liste von Hosts sein." @@ -10,13 +9,13 @@ "step": { "config": { "data": { - "known_hosts": "Optionale Liste bekannter Hosts, wenn die mDNS-Erkennung nicht funktioniert." + "known_hosts": "Bekannte Hosts" }, - "description": "Bitte die Google Cast-Konfiguration eingeben.", - "title": "Google Cast" + "description": "Bekannte Hosts - Eine durch Kommas getrennte Liste von Hostnamen oder IP-Adressen von Cast-Ger\u00e4ten, die verwendet wird, wenn die mDNS-Erkennung nicht funktioniert.", + "title": "Google Cast-Konfiguration" }, "confirm": { - "description": "M\u00f6chtest du Google Cast einrichten?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } }, @@ -25,13 +24,20 @@ "invalid_known_hosts": "Bekannte Hosts m\u00fcssen eine durch Kommata getrennte Liste von Hosts sein." }, "step": { - "options": { + "advanced_options": { "data": { - "ignore_cec": "Optionale Liste, die an pychromecast.IGNORE_CEC \u00fcbergeben wird.", - "known_hosts": "Optionale Liste bekannter Hosts, wenn die mDNS-Erkennung nicht funktioniert.", - "uuid": "Optionale Liste der UUIDs. Casts, die nicht aufgef\u00fchrt sind, werden nicht hinzugef\u00fcgt." + "ignore_cec": "CEC ignorieren", + "uuid": "Zul\u00e4ssige UUIDs" }, - "description": "Bitte die Google Cast-Konfiguration eingeben." + "description": "Erlaubte UUIDs - Eine kommagetrennte Liste von UUIDs von Cast-Ger\u00e4ten, die dem Home Assistant hinzugef\u00fcgt werden sollen. Nur verwenden, wenn du nicht alle verf\u00fcgbaren Cast-Ger\u00e4te hinzuf\u00fcgen m\u00f6chtest.\nCEC ignorieren - Eine kommagetrennte Liste von Chromecasts, die CEC-Daten zur Bestimmung des aktiven Eingangs ignorieren sollen. Dies wird an pychromecast.IGNORE_CEC \u00fcbergeben.", + "title": "Erweiterte Google Cast-Konfiguration" + }, + "basic_options": { + "data": { + "known_hosts": "Bekannte Hosts" + }, + "description": "Bekannte Hosts - Eine durch Kommas getrennte Liste von Hostnamen oder IP-Adressen von Cast-Ger\u00e4ten, die verwendet wird, wenn die mDNS-Erkennung nicht funktioniert.", + "title": "Google Cast-Konfiguration" } } } diff --git a/homeassistant/components/cast/translations/en.json b/homeassistant/components/cast/translations/en.json index b61c5eee99e2e..ac473a5efe573 100644 --- a/homeassistant/components/cast/translations/en.json +++ b/homeassistant/components/cast/translations/en.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { @@ -30,7 +29,7 @@ "ignore_cec": "Ignore CEC", "uuid": "Allowed UUIDs" }, - "description": "Allowed UUIDs - A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you don\u2019t want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be will be passed to pychromecast.IGNORE_CEC.", + "description": "Allowed UUIDs - A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you don\u2019t want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.", "title": "Advanced Google Cast configuration" }, "basic_options": { @@ -39,14 +38,6 @@ }, "description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.", "title": "Google Cast configuration" - }, - "options": { - "data": { - "ignore_cec": "Optional list which will be passed to pychromecast.IGNORE_CEC.", - "known_hosts": "Optional list of known hosts if mDNS discovery is not working.", - "uuid": "Optional list of UUIDs. Casts not listed will not be added." - }, - "description": "Please enter the Google Cast configuration." } } } diff --git a/homeassistant/components/cast/translations/es-419.json b/homeassistant/components/cast/translations/es-419.json index ee30ef16b4697..ffcc09b23ca42 100644 --- a/homeassistant/components/cast/translations/es-419.json +++ b/homeassistant/components/cast/translations/es-419.json @@ -1,22 +1,43 @@ { "config": { "abort": { - "no_devices_found": "No se encontraron dispositivos Google Cast en la red.", "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." }, + "error": { + "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." + }, "step": { + "config": { + "data": { + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos: una lista separada por comas de nombres de host o direcciones IP de dispositivos de transmisi\u00f3n, que se utiliza si el descubrimiento de mDNS no funciona.", + "title": "Configuraci\u00f3n de Google Cast" + }, "confirm": { "description": "\u00bfDesea configurar Google Cast?" } } }, "options": { + "error": { + "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." + }, "step": { - "options": { + "advanced_options": { + "data": { + "ignore_cec": "Ignorar CEC", + "uuid": "UUID permitidos" + }, + "description": "UUID permitidos: una lista separada por comas de UUID de dispositivos de transmisi\u00f3n para agregar a Home Assistant. \u00daselo solo si no desea agregar todos los dispositivos de transmisi\u00f3n disponibles.\nIgnorar CEC: una lista separada por comas de Chromecasts que debe ignorar los datos de CEC para determinar la entrada activa. Esto se pasar\u00e1 a pychromecast.IGNORE_CEC.", + "title": "Configuraci\u00f3n avanzada de Google Cast" + }, + "basic_options": { "data": { - "ignore_cec": "Lista opcional que se pasar\u00e1 a pychromecast.IGNORE_CEC.", - "uuid": "Lista opcional de UUID. No se agregar\u00e1n los dispositivos Cast que no est\u00e9n listados." - } + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos: una lista separada por comas de nombres de host o direcciones IP de dispositivos de transmisi\u00f3n, que se utiliza si el descubrimiento de mDNS no funciona.", + "title": "Configuraci\u00f3n de Google Cast" } } } diff --git a/homeassistant/components/cast/translations/es.json b/homeassistant/components/cast/translations/es.json index 17b0ff4c2c44f..dad23682dac59 100644 --- a/homeassistant/components/cast/translations/es.json +++ b/homeassistant/components/cast/translations/es.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "No se encontraron dispositivos en la red", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { @@ -25,13 +24,20 @@ "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." }, "step": { - "options": { + "advanced_options": { "data": { - "ignore_cec": "Lista opcional que se pasar\u00e1 a pychromecast.IGNORE_CEC.", - "known_hosts": "Lista opcional de hosts conocidos si el descubrimiento mDNS no funciona.", - "uuid": "Lista opcional de UUIDs. Los cast que no aparezcan en la lista no se a\u00f1adir\u00e1n." + "ignore_cec": "Ignorar CEC", + "uuid": "UUID permitidos" }, - "description": "Introduce la configuraci\u00f3n de Google Cast." + "description": "UUID permitidos: lista separada por comas de UUID de dispositivos Cast para a\u00f1adir a Home Assistant. \u00dasalo solo si no deseas a\u00f1adir todos los dispositivos Cast disponibles.\nIgnorar CEC: lista separada por comas de Chromecasts que deben ignorar los datos CEC para determinar la entrada activa. Esto se pasar\u00e1 a pychromecast.IGNORE_CEC.", + "title": "Configuraci\u00f3n avanzada de Google Cast" + }, + "basic_options": { + "data": { + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos - Una lista separada por comas de nombres de host o direcciones IP de dispositivos cast, utilizar si el descubrimiento mDNS no est\u00e1 funcionando.", + "title": "Configuraci\u00f3n de Google Cast" } } } diff --git a/homeassistant/components/cast/translations/et.json b/homeassistant/components/cast/translations/et.json index 48397e044f4d4..6fb37f802e00a 100644 --- a/homeassistant/components/cast/translations/et.json +++ b/homeassistant/components/cast/translations/et.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi Google Casti seadet.", "single_instance_allowed": "Vajalik on ainult \u00fcks Google Casti konfiguratsioon." }, "error": { @@ -30,7 +29,7 @@ "ignore_cec": "Eira CEC-i", "uuid": "Lubatud UUID-d" }, - "description": "Lubatud UUID-d - komadega eraldatud loetelu UUID-dest, mida soovitakse lisada Home Assistant'ile. Kasuta ainult siis, kui ei soovi lisada k\u00f5iki olemasolevaid Cast seadmeid.\nIgnore CEC - komadega eraldatud loetelu Chromecastidest, mis peaksid aktiivse sisendi m\u00e4\u00e4ramisel CEC-andmeid ignoreerima. See edastatakse pychromecast.IGNORE_CEC.", + "description": "Lubatud UUID-d - komadega eraldatud loetelu UUID-dest, mida soovitakse lisada Home Assistant'ile. Kasuta ainult siis,kui ei soovi lisada k\u00f5iki olemasolevaid Cast seadmeid.\nIgnore CEC - komadega eraldatud loetelu Chromecastidest, mis peaksid aktiivse sisendi m\u00e4\u00e4ramisel CEC-andmeid ignoreerima. See edastatakse pychromecast.IGNORE_CEC.", "title": "Google Casti seadistamise t\u00e4psemad valikud" }, "basic_options": { @@ -39,14 +38,6 @@ }, "description": "Tuntud hostid - komadega eraldatud loend hostitud seadmete hostinimedest v\u00f5i IP-aadressidest. Kasuta seda juhul kui mDNS-i tuvastus ei t\u00f6\u00f6ta.", "title": "Google Casti s\u00e4tted" - }, - "options": { - "data": { - "ignore_cec": "Valikuline nimekiri mis edastatakse pychromecast.IGNORE_CEC-ile.", - "known_hosts": "Valikuline loend teadaolevatest hostidest kui mDNS-i tuvastamine ei t\u00f6\u00f6ta.", - "uuid": "Valikuline UUIDide loend. Loetlemata cast-e ei lisata." - }, - "description": "Sisesta Google Casti andmed." } } } diff --git a/homeassistant/components/cast/translations/fi.json b/homeassistant/components/cast/translations/fi.json index 730f3ebf9b315..74cf54852a8fb 100644 --- a/homeassistant/components/cast/translations/fi.json +++ b/homeassistant/components/cast/translations/fi.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Verkosta ei l\u00f6ydy Google Cast -laitteita.", "single_instance_allowed": "Vain yksi Google Cast -m\u00e4\u00e4ritys on tarpeen." }, "step": { diff --git a/homeassistant/components/cast/translations/fr.json b/homeassistant/components/cast/translations/fr.json index a907fbe4a7631..c07122f820fcf 100644 --- a/homeassistant/components/cast/translations/fr.json +++ b/homeassistant/components/cast/translations/fr.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Aucun appareil Google Cast trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { "invalid_known_hosts": "Les h\u00f4tes connus doivent \u00eatre une liste d'h\u00f4tes s\u00e9par\u00e9s par des virgules." @@ -16,7 +15,7 @@ "title": "Google Cast" }, "confirm": { - "description": "Voulez-vous configurer Google Cast?" + "description": "Voulez-vous commencer la configuration ?" } } }, @@ -39,14 +38,6 @@ }, "description": "H\u00f4tes connus - Une liste de noms d'h\u00f4te ou d'adresses IP s\u00e9par\u00e9s par des virgules des p\u00e9riph\u00e9riques de diffusion, \u00e0 utiliser si la d\u00e9couverte mDNS ne fonctionne pas.", "title": "Configuration de Google Cast" - }, - "options": { - "data": { - "ignore_cec": "Liste facultative qui sera transmise \u00e0 pychromecast.IGNORE_CEC.", - "known_hosts": "Liste facultative des h\u00f4tes connus si la d\u00e9couverte mDNS ne fonctionne pas.", - "uuid": "Liste facultative des UUID. Les moulages non r\u00e9pertori\u00e9s ne seront pas ajout\u00e9s." - }, - "description": "Veuillez saisir la configuration de Google Cast." } } } diff --git a/homeassistant/components/cast/translations/he.json b/homeassistant/components/cast/translations/he.json index 0bc4e834cb720..d50e5b2068457 100644 --- a/homeassistant/components/cast/translations/he.json +++ b/homeassistant/components/cast/translations/he.json @@ -1,12 +1,38 @@ { "config": { "abort": { - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Google Cast \u05d1\u05e8\u05e9\u05ea.", - "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Google Cast \u05e0\u05d7\u05d5\u05e6\u05d4." + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "invalid_known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd." }, "step": { + "config": { + "data": { + "known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd" + }, + "description": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc \u05e9\u05de\u05d5\u05ea \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d9\u05e6\u05d5\u05e7\u05d9\u05dd, \u05d4\u05e9\u05ea\u05de\u05e9 \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 mDNS \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc.", + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea Google Cast" + }, "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Google Cast?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + } + } + }, + "options": { + "error": { + "invalid_known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd." + }, + "step": { + "advanced_options": { + "title": "\u05ea\u05e6\u05d5\u05e8\u05d4 \u05de\u05ea\u05e7\u05d3\u05de\u05ea \u05e9\u05dc Google Cast" + }, + "basic_options": { + "data": { + "known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd" + }, + "description": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc \u05e9\u05de\u05d5\u05ea \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d9\u05e6\u05d5\u05e7\u05d9\u05dd, \u05d4\u05e9\u05ea\u05de\u05e9 \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 mDNS \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc.", + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea Google Cast" } } } diff --git a/homeassistant/components/cast/translations/hu.json b/homeassistant/components/cast/translations/hu.json index 7e5625c925d5f..a4c8da3242e3f 100644 --- a/homeassistant/components/cast/translations/hu.json +++ b/homeassistant/components/cast/translations/hu.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "error": { @@ -10,13 +9,13 @@ "step": { "config": { "data": { - "known_hosts": "Opcion\u00e1lis lista az ismert hosztokr\u00f3l, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik." + "known_hosts": "Ismert hosztok" }, - "description": "K\u00e9rj\u00fck, add meg a Google Cast konfigur\u00e1ci\u00f3t.", - "title": "Google Cast" + "description": "Ismert c\u00edmek - A cast eszk\u00f6z\u00f6k hostneveinek vagy IP-c\u00edmeinek vessz\u0151vel elv\u00e1lasztott list\u00e1ja, akkor haszn\u00e1lja, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik.", + "title": "Google Cast konfigur\u00e1ci\u00f3" }, "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, @@ -25,12 +24,20 @@ "invalid_known_hosts": "Az ismert hosztoknak vessz\u0151vel elv\u00e1lasztott hosztok list\u00e1j\u00e1nak kell lennie." }, "step": { - "options": { + "advanced_options": { "data": { - "known_hosts": "Opcion\u00e1lis lista az ismert hosztokr\u00f3l, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik.", - "uuid": "Az UUID-k opcion\u00e1lis list\u00e1ja. A felsorol\u00e1sban nem szerepl\u0151 szerepl\u0151g\u00e1rd\u00e1k nem ker\u00fclnek hozz\u00e1ad\u00e1sra." + "ignore_cec": "A CEC figyelmen k\u00edv\u00fcl hagy\u00e1sa", + "uuid": "Enged\u00e9lyezett UUID-k" }, - "description": "K\u00e9rj\u00fck, add meg a Google Cast konfigur\u00e1ci\u00f3t." + "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni Home Assistanthoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\nCEC figyelmen k\u00edv\u00fcl hagy\u00e1sa - vessz\u0151vel elv\u00e1lasztott Chromecast-lista, amelynek figyelmen k\u00edv\u00fcl kell hagynia a CEC-adatokat az akt\u00edv bemenet meghat\u00e1roz\u00e1s\u00e1hoz. Ezt tov\u00e1bb\u00edtjuk a pychromecast.IGNORE_CEC c\u00edmre.", + "title": "Speci\u00e1lis Google Cast-konfigur\u00e1ci\u00f3" + }, + "basic_options": { + "data": { + "known_hosts": "Ismert gazd\u00e1k" + }, + "description": "Ismert gazd\u00e1k - vessz\u0151vel elv\u00e1lasztott lista az eszk\u00f6z\u00f6k hosztneveir\u0151l vagy IP-c\u00edmeir\u0151l, akkor haszn\u00e1lja, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik.", + "title": "Google Cast konfigur\u00e1ci\u00f3" } } } diff --git a/homeassistant/components/cast/translations/id.json b/homeassistant/components/cast/translations/id.json index d086b388252fc..e81b25a4844ad 100644 --- a/homeassistant/components/cast/translations/id.json +++ b/homeassistant/components/cast/translations/id.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "error": { @@ -10,10 +9,10 @@ "step": { "config": { "data": { - "known_hosts": "Daftar opsional host yang diketahui jika penemuan mDNS tidak berfungsi." + "known_hosts": "Host yang dikenal" }, - "description": "Masukkan konfigurasi Google Cast.", - "title": "Google Cast" + "description": "Host yang Dikenal - Daftar nama host atau alamat IP perangkat cast, dipisahkan dengan tanda koma, gunakan jika penemuan mDNS tidak berfungsi.", + "title": "Konfigurasi Google Cast" }, "confirm": { "description": "Ingin memulai penyiapan?" @@ -25,13 +24,20 @@ "invalid_known_hosts": "Host yang diketahui harus berupa daftar host yang dipisahkan koma." }, "step": { - "options": { + "advanced_options": { "data": { - "ignore_cec": "Daftar opsional yang akan diteruskan ke pychromecast.IGNORE_CEC.", - "known_hosts": "Daftar opsional host yang diketahui jika penemuan mDNS tidak berfungsi.", - "uuid": "Daftar opsional UUID. Cast yang tidak tercantum tidak akan ditambahkan." + "ignore_cec": "Abaikan CEC", + "uuid": "UUID yang diizinkan" }, - "description": "Masukkan konfigurasi Google Cast." + "description": "UUID yang Diizinkan - Daftar UUID perangkat Cast yang dipisahkan koma untuk ditambahkan ke Home Assistant. Gunakan hanya jika Anda tidak ingin menambahkan semua perangkat cast yang tersedia.\nAbaikan CEC - Daftar Chromecast yang dipisahkan koma yang harus mengabaikan data CEC untuk menentukan input aktif. Daftar ini akan diteruskan ke pychromecast.IGNORE_CEC.", + "title": "Konfigurasi Google Cast tingkat lanjut" + }, + "basic_options": { + "data": { + "known_hosts": "Host yang dikenal" + }, + "description": "Host yang Dikenal - Daftar nama host atau alamat IP perangkat cast, dipisahkan dengan tanda koma, gunakan jika penemuan mDNS tidak berfungsi.", + "title": "Konfigurasi Google Cast" } } } diff --git a/homeassistant/components/cast/translations/it.json b/homeassistant/components/cast/translations/it.json index c0ff9144a2f5f..d96bb9763c6c6 100644 --- a/homeassistant/components/cast/translations/it.json +++ b/homeassistant/components/cast/translations/it.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Nessun dispositivo trovato sulla rete", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { @@ -39,14 +38,6 @@ }, "description": "Host conosciuti: un elenco separato da virgole di nomi host o indirizzi IP di dispositivi di trasmissione, da utilizzare se l'individuazione di mDNS non funziona.", "title": "Configurazione di Google Cast" - }, - "options": { - "data": { - "ignore_cec": "Elenco opzionale che sar\u00e0 passato a pychromecast.IGNORE_CEC.", - "known_hosts": "Elenco facoltativo di host noti se l'individuazione di mDNS non funziona.", - "uuid": "Elenco opzionale di UUID. I cast non elencati non saranno aggiunti." - }, - "description": "Inserisci la configurazione di Google Cast." } } } diff --git a/homeassistant/components/cast/translations/ja.json b/homeassistant/components/cast/translations/ja.json index fa1d40315620e..626ef56cba1ce 100644 --- a/homeassistant/components/cast/translations/ja.json +++ b/homeassistant/components/cast/translations/ja.json @@ -1,11 +1,43 @@ { "config": { "abort": { - "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306bGoogle Cast\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8\u306f\u3001\u30b3\u30f3\u30de\u3067\u533a\u5207\u3089\u308c\u305f\u30db\u30b9\u30c8\u306e\u30ea\u30b9\u30c8\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002" }, "step": { + "config": { + "data": { + "known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8" + }, + "description": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8 - Cast\u30c7\u30d0\u30a4\u30b9\u306e\u30db\u30b9\u30c8\u540d\u3001\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u306e\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u3002mDNS\u691c\u51fa\u304c\u6a5f\u80fd\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u4f7f\u7528\u3057\u307e\u3059\u3002", + "title": "Google Cast\u306e\u8a2d\u5b9a" + }, "confirm": { - "description": "Google Cast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "options": { + "error": { + "invalid_known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8\u306f\u3001\u30b3\u30f3\u30de\u3067\u533a\u5207\u3089\u308c\u305f\u30db\u30b9\u30c8\u306e\u30ea\u30b9\u30c8\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "CEC\u3092\u7121\u8996\u3059\u308b", + "uuid": "\u8a31\u53ef\u3055\u308c\u305fUUID" + }, + "description": "Allowed(\u8a31\u53ef) UUIDs - Home Assistant\u306b\u8ffd\u52a0\u3059\u308b\u30ad\u30e3\u30b9\u30c8\u30c7\u30d0\u30a4\u30b9\u306eUUID\u3092\u30b3\u30f3\u30de\u533a\u5207\u306e\u30ea\u30b9\u30c8\u3002\u4f7f\u7528\u53ef\u80fd\u306a\u3059\u3079\u3066\u306e\u30ad\u30e3\u30b9\u30c8\u30c7\u30d0\u30a4\u30b9\u3092\u8ffd\u52a0\u3057\u305f\u304f\u306a\u3044\u5834\u5408\u306b\u306e\u307f\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002Ignore(\u7121\u8996) CEC - \u30a2\u30af\u30c6\u30a3\u30d6\u306a\u5165\u529b\u3092\u6c7a\u5b9a\u3059\u308b\u305f\u3081\u306b\u3001CEC\u30c7\u30fc\u30bf\u3092\u7121\u8996\u3057\u305f\u3044\u30af\u30ed\u30fc\u30e0\u30ad\u30e3\u30b9\u30c8\u306e\u30b3\u30f3\u30de\u533a\u5207\u306e\u30ea\u30b9\u30c8\u3002\u3053\u308c\u306f\u3001pychromecast.IGNORE_CEC \u306b\u6e21\u3055\u308c\u307e\u3059\u3002", + "title": "Google Cast\u306e\u9ad8\u5ea6\u306a\u8a2d\u5b9a" + }, + "basic_options": { + "data": { + "known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8" + }, + "description": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8 - Cast\u30c7\u30d0\u30a4\u30b9\u306e\u30db\u30b9\u30c8\u540d\u3001\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u306e\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u3002mDNS\u691c\u51fa\u304c\u6a5f\u80fd\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u4f7f\u7528\u3057\u307e\u3059\u3002", + "title": "Google Cast\u306e\u8a2d\u5b9a" } } } diff --git a/homeassistant/components/cast/translations/ko.json b/homeassistant/components/cast/translations/ko.json index b0bfd3271c9a9..2f1ee52675f21 100644 --- a/homeassistant/components/cast/translations/ko.json +++ b/homeassistant/components/cast/translations/ko.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "\ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "error": { @@ -23,16 +22,6 @@ "options": { "error": { "invalid_known_hosts": "\uc54c\ub824\uc9c4 \ud638\uc2a4\ud2b8\ub294 \uc27c\ud45c\ub85c \uad6c\ubd84\ub41c \ud638\uc2a4\ud2b8 \ubaa9\ub85d\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4." - }, - "step": { - "options": { - "data": { - "ignore_cec": "pychromecast.IGNORE_CEC\uc5d0 \uc804\ub2ec\ub420 \uc120\ud0dd\uc801 \ubaa9\ub85d\uc785\ub2c8\ub2e4.", - "known_hosts": "mDNS \uac80\uc0c9\uc774 \uc791\ub3d9\ud558\uc9c0 \uc54a\ub294 \uacbd\uc6b0 \uc54c\ub824\uc9c4 \ud638\uc2a4\ud2b8\uc758 \uc120\ud0dd\uc801 \ubaa9\ub85d\uc785\ub2c8\ub2e4.", - "uuid": "UUID\uc758 \uc120\ud0dd\uc801 \ubaa9\ub85d\uc785\ub2c8\ub2e4. \ubaa9\ub85d\uc5d0 \uc5c6\ub294 \uce90\uc2a4\ud2b8\ub294 \ucd94\uac00\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." - }, - "description": "Google Cast \uad6c\uc131\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." - } } } } \ No newline at end of file diff --git a/homeassistant/components/cast/translations/lb.json b/homeassistant/components/cast/translations/lb.json index 8f572aa48cea6..6b1454a6b57a0 100644 --- a/homeassistant/components/cast/translations/lb.json +++ b/homeassistant/components/cast/translations/lb.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Keng Apparater am Netzwierk fonnt.", "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun m\u00e9iglech." }, "step": { diff --git a/homeassistant/components/cast/translations/nl.json b/homeassistant/components/cast/translations/nl.json index aaf662c91f3de..26dc954ef1374 100644 --- a/homeassistant/components/cast/translations/nl.json +++ b/homeassistant/components/cast/translations/nl.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Geen apparaten gevonden op het netwerk", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "error": { @@ -10,13 +9,13 @@ "step": { "config": { "data": { - "known_hosts": "Optionele lijst van bekende hosts indien mDNS discovery niet werkt." + "known_hosts": "Bekende hosts" }, - "description": "Voer de Google Cast configuratie in.", - "title": "Google Cast" + "description": "Bekende hosts - Een door komma's gescheiden lijst van hostnamen of IP-adressen van cast-apparaten, te gebruiken als mDNS-ontdekking niet werkt.", + "title": "Google Cast configuratie" }, "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, @@ -39,14 +38,6 @@ }, "description": "Bekende hosts - Een door komma's gescheiden lijst met hostnamen of IP-adressen van cast-apparaten, te gebruiken als mDNS-detectie niet werkt.", "title": "Google Cast configuratie" - }, - "options": { - "data": { - "ignore_cec": "Optionele lijst die zal worden doorgegeven aan pychromecast.IGNORE_CEC.", - "known_hosts": "Optionele lijst van bekende hosts indien mDNS discovery niet werkt.", - "uuid": "Optionele lijst van UUID's. Casts die niet in de lijst staan, worden niet toegevoegd." - }, - "description": "Voer de Google Cast configuratie in." } } } diff --git a/homeassistant/components/cast/translations/nn.json b/homeassistant/components/cast/translations/nn.json index 44d2679281238..3afcfc6bc2e97 100644 --- a/homeassistant/components/cast/translations/nn.json +++ b/homeassistant/components/cast/translations/nn.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Klar", "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Google Cast-konfigurasjon." }, "step": { diff --git a/homeassistant/components/cast/translations/no.json b/homeassistant/components/cast/translations/no.json index 315926a84be72..6c8d7cb5760b8 100644 --- a/homeassistant/components/cast/translations/no.json +++ b/homeassistant/components/cast/translations/no.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { @@ -30,7 +29,7 @@ "ignore_cec": "Ignorer CEC", "uuid": "Tillatte UUIDer" }, - "description": "Tillatte UUID-er - En komma-separert liste over UUID-er for Cast-enheter \u00e5 legge til i Home Assistant. Bruk bare hvis du ikke vil legge til alle tilgjengelige cast-enheter.\n Ignorer CEC - En kommaseparert liste over Chromecasts som b\u00f8r ignorere CEC-data for \u00e5 bestemme den aktive inngangen. Dette vil bli sendt til pychromecast.IGNORE_CEC.", + "description": "Tillatte UUIDer - En kommadelt liste over UUIDer med Cast-enheter som skal legges til i Home Assistant. Bruk bare hvis du ikke vil legge til alle tilgjengelige cast-enheter.\nIgnorer CEC - En kommadelt liste over Chromecaster som b\u00f8r ignorere CEC-data for \u00e5 bestemme de aktive inngangene. Dette vil bli sendt til pychromecast. IGNORE_CEC.", "title": "Avansert Google Cast-konfigurasjon" }, "basic_options": { @@ -39,14 +38,6 @@ }, "description": "Kjente verter - En kommaseparert liste over vertsnavn eller IP-adresser til cast-enheter, bruk hvis mDNS discovery ikke fungerer.", "title": "Google Cast-konfigurasjon" - }, - "options": { - "data": { - "ignore_cec": "Valgfri liste som sendes til pychromecast.IGNORE_CEC.", - "known_hosts": "Valgfri liste over kjente verter hvis mDNS-oppdagelse ikke fungerer.", - "uuid": "Valgfri liste over UUIDer. Medvirkende som ikke er oppf\u00f8rt, blir ikke lagt til." - }, - "description": "Angi Google Cast-konfigurasjonen." } } } diff --git a/homeassistant/components/cast/translations/pl.json b/homeassistant/components/cast/translations/pl.json index 5802bda502abd..4b8459a5319c0 100644 --- a/homeassistant/components/cast/translations/pl.json +++ b/homeassistant/components/cast/translations/pl.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "error": { @@ -10,9 +9,9 @@ "step": { "config": { "data": { - "known_hosts": "Opcjonalna lista znanych host\u00f3w, je\u015bli wykrywanie mDNS nie dzia\u0142a." + "known_hosts": "Znane hosta." }, - "description": "Wprowad\u017a konfiguracj\u0119 Google Cast.", + "description": "Znane hosta - lista rozdzielonych przecinkami nazw host\u00f3w lub adres\u00f3w IP urz\u0105dze\u0144 przesy\u0142aj\u0105cych. U\u017cyj, je\u015bli wykrywanie mDNS nie dzia\u0142a.", "title": "Google Cast" }, "confirm": { @@ -25,13 +24,20 @@ "invalid_known_hosts": "Znane hosty musz\u0105 by\u0107 list\u0105 host\u00f3w oddzielonych przecinkami." }, "step": { - "options": { + "advanced_options": { "data": { - "ignore_cec": "Opcjonalna lista, kt\u00f3ra zostanie przekazana do pychromecast.IGNORE_CEC.", - "known_hosts": "Opcjonalna lista znanych host\u00f3w, je\u015bli wykrywanie mDNS nie dzia\u0142a.", - "uuid": "Opcjonalna lista identyfikator\u00f3w UUID. Casty nie wymienione na li\u015bcie nie zostan\u0105 dodane." + "ignore_cec": "Ignoruj CEC", + "uuid": "Dozwolone identyfikatory UUID" }, - "description": "Wprowad\u017a konfiguracj\u0119 Google Cast." + "description": "Dozwolone identyfikatory UUID - lista oddzielonych przecinkami identyfikator\u00f3w UUID urz\u0105dze\u0144 przesy\u0142aj\u0105cych, kt\u00f3re mo\u017cna doda\u0107 do Home Assistanta. U\u017cywaj tylko wtedy, gdy nie chcesz dodawa\u0107 wszystkich dost\u0119pnych urz\u0105dze\u0144 przesy\u0142aj\u0105cych.\nIgnoruj CEC - lista Chromecast\u00f3w oddzielonych przecinkami, kt\u00f3re powinny ignorowa\u0107 dane CEC przy okre\u015blaniu aktywnego wej\u015bcia. Zostanie to przekazane do pychromecast.IGNORE_CEC.", + "title": "Zaawansowana konfiguracja Google Cast" + }, + "basic_options": { + "data": { + "known_hosts": "Znane hosta" + }, + "description": "Znane hosta - lista rozdzielonych przecinkami nazw host\u00f3w lub adres\u00f3w IP urz\u0105dze\u0144 przesy\u0142aj\u0105cych. U\u017cyj, je\u015bli wykrywanie mDNS nie dzia\u0142a.", + "title": "Konfiguracja Google Cast" } } } diff --git a/homeassistant/components/cast/translations/pt-BR.json b/homeassistant/components/cast/translations/pt-BR.json index 000971f9e4c73..8abd2dac5e5f4 100644 --- a/homeassistant/components/cast/translations/pt-BR.json +++ b/homeassistant/components/cast/translations/pt-BR.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Nenhum dispositivo Google Cast encontrado na rede.", "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." }, "step": { diff --git a/homeassistant/components/cast/translations/pt.json b/homeassistant/components/cast/translations/pt.json index 2a5b62a9de190..bb29c92312859 100644 --- a/homeassistant/components/cast/translations/pt.json +++ b/homeassistant/components/cast/translations/pt.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Nenhum dispositivo Google Cast descoberto na rede.", "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." }, "step": { @@ -12,12 +11,5 @@ "description": "Deseja configurar o Google Cast?" } } - }, - "options": { - "step": { - "options": { - "description": "Por favor introduza a configura\u00e7\u00e3o do Google Cast" - } - } } } \ No newline at end of file diff --git a/homeassistant/components/cast/translations/ro.json b/homeassistant/components/cast/translations/ro.json index 6e93a0fdb1ad7..1eb46021d98bf 100644 --- a/homeassistant/components/cast/translations/ro.json +++ b/homeassistant/components/cast/translations/ro.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.", "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast." }, "step": { diff --git a/homeassistant/components/cast/translations/ru.json b/homeassistant/components/cast/translations/ru.json index cb432acbf6476..84fc0835de065 100644 --- a/homeassistant/components/cast/translations/ru.json +++ b/homeassistant/components/cast/translations/ru.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." }, "error": { @@ -39,14 +38,6 @@ }, "description": "\u0418\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0435 \u0445\u043e\u0441\u0442\u044b \u2014 \u0441\u043f\u0438\u0441\u043e\u043a \u0438\u043c\u0435\u043d \u0445\u043e\u0441\u0442\u043e\u0432 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u044f\u0442\u044b\u043c\u0438. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435 mDNS \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Google Cast" - }, - "options": { - "data": { - "ignore_cec": "\u041d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u0441\u043f\u0438\u0441\u043e\u043a, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d \u0432 pychromecast.IGNORE_CEC.", - "known_hosts": "\u041d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u0441\u043f\u0438\u0441\u043e\u043a \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0445 \u0445\u043e\u0441\u0442\u043e\u0432, \u0435\u0441\u043b\u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435 mDNS \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442.", - "uuid": "\u041d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u0441\u043f\u0438\u0441\u043e\u043a UUID. \u041d\u0435 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u043d\u044b\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u0442\u044c\u0441\u044f \u043d\u0435 \u0431\u0443\u0434\u0443\u0442." - }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Google Cast." } } } diff --git a/homeassistant/components/cast/translations/sl.json b/homeassistant/components/cast/translations/sl.json index c4d2ba980069d..15e2b33ab305d 100644 --- a/homeassistant/components/cast/translations/sl.json +++ b/homeassistant/components/cast/translations/sl.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.", "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a." }, "step": { diff --git a/homeassistant/components/cast/translations/sv.json b/homeassistant/components/cast/translations/sv.json index 982b52b65dd78..0d5fb586cc85b 100644 --- a/homeassistant/components/cast/translations/sv.json +++ b/homeassistant/components/cast/translations/sv.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Inga Google Cast-enheter hittades i n\u00e4tverket.", "single_instance_allowed": "Endast en enda konfiguration av Google Cast \u00e4r n\u00f6dv\u00e4ndig." }, "step": { diff --git a/homeassistant/components/cast/translations/th.json b/homeassistant/components/cast/translations/th.json index 64a5eaa3085c2..f0b06a06dc9a3 100644 --- a/homeassistant/components/cast/translations/th.json +++ b/homeassistant/components/cast/translations/th.json @@ -1,8 +1,5 @@ { "config": { - "abort": { - "no_devices_found": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c Google Cast \u0e1a\u0e19\u0e40\u0e04\u0e23\u0e37\u0e2d\u0e02\u0e48\u0e32\u0e22" - }, "step": { "confirm": { "description": "\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32 Google Cast \u0e2b\u0e23\u0e37\u0e2d\u0e44\u0e21\u0e48?" diff --git a/homeassistant/components/cast/translations/tr.json b/homeassistant/components/cast/translations/tr.json index 8de4663957ea8..3a2609c302af0 100644 --- a/homeassistant/components/cast/translations/tr.json +++ b/homeassistant/components/cast/translations/tr.json @@ -3,10 +3,42 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "invalid_known_hosts": "Bilinen ana bilgisayarlar, virg\u00fclle ayr\u0131lm\u0131\u015f bir ana bilgisayar listesi olmal\u0131d\u0131r." + }, "step": { + "config": { + "data": { + "known_hosts": "Bilinen ana bilgisayarlar" + }, + "description": "Bilinen Ana Bilgisayarlar - Yay\u0131n cihazlar\u0131n\u0131n ana bilgisayar adlar\u0131n\u0131n veya IP adreslerinin virg\u00fclle ayr\u0131lm\u0131\u015f listesi, mDNS ke\u015ffi \u00e7al\u0131\u015fm\u0131yorsa kullan\u0131n.", + "title": "Google Cast yap\u0131land\u0131rmas\u0131" + }, "confirm": { "description": "Kuruluma ba\u015flamak ister misiniz?" } } + }, + "options": { + "error": { + "invalid_known_hosts": "Bilinen ana bilgisayarlar, virg\u00fclle ayr\u0131lm\u0131\u015f bir ana bilgisayar listesi olmal\u0131d\u0131r." + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "CEC'yi yoksay", + "uuid": "\u0130zin verilen UUID'ler" + }, + "description": "\u0130zin Verilen UUID'ler - Home Asistan\u0131'na eklenecek Cast cihazlar\u0131n\u0131n UUID'lerinin virg\u00fclle ayr\u0131lm\u0131\u015f listesi. Yaln\u0131zca mevcut t\u00fcm yay\u0131n cihazlar\u0131n\u0131 eklemek istemiyorsan\u0131z kullan\u0131n.\n CEC'yi Yoksay - Etkin giri\u015fi belirlemek i\u00e7in CEC verilerini yoksaymas\u0131 gereken virg\u00fclle ayr\u0131lm\u0131\u015f bir Chromecast listesi. Bu, pychromecast.IGNORE_CEC'e iletilecektir.", + "title": "Geli\u015fmi\u015f Google Cast yap\u0131land\u0131rmas\u0131" + }, + "basic_options": { + "data": { + "known_hosts": "Bilinen ana bilgisayarlar" + }, + "description": "Bilinen Ana Bilgisayarlar - Yay\u0131n cihazlar\u0131n\u0131n ana bilgisayar adlar\u0131n\u0131n veya IP adreslerinin virg\u00fclle ayr\u0131lm\u0131\u015f listesi, mDNS ke\u015ffi \u00e7al\u0131\u015fm\u0131yorsa kullan\u0131n.", + "title": "Google Cast yap\u0131land\u0131rmas\u0131" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/cast/translations/uk.json b/homeassistant/components/cast/translations/uk.json index 292861e9129db..5f8d69f5f29b8 100644 --- a/homeassistant/components/cast/translations/uk.json +++ b/homeassistant/components/cast/translations/uk.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "\u041f\u0440\u0438\u0441\u0442\u0440\u043e\u0457 \u043d\u0435 \u0437\u043d\u0430\u0439\u0434\u0435\u043d\u0456 \u0432 \u043c\u0435\u0440\u0435\u0436\u0456.", "single_instance_allowed": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0436\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u041c\u043e\u0436\u043d\u0430 \u0434\u043e\u0434\u0430\u0442\u0438 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e." }, "step": { diff --git a/homeassistant/components/cast/translations/vi.json b/homeassistant/components/cast/translations/vi.json index f65f3c58ebe18..175f6d22886a4 100644 --- a/homeassistant/components/cast/translations/vi.json +++ b/homeassistant/components/cast/translations/vi.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Google Cast n\u00e0o tr\u00ean m\u1ea1ng.", "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Google Cast l\u00e0 \u0111\u1ee7." }, "step": { diff --git a/homeassistant/components/cast/translations/zh-Hans.json b/homeassistant/components/cast/translations/zh-Hans.json index 0feaac564400a..073919a1282a5 100644 --- a/homeassistant/components/cast/translations/zh-Hans.json +++ b/homeassistant/components/cast/translations/zh-Hans.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Google Cast \u8bbe\u5907\u3002", "single_instance_allowed": "Google Cast \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002" }, "step": { @@ -13,12 +12,5 @@ "description": "\u60a8\u60f3\u8981\u914d\u7f6e Google Cast \u5417\uff1f" } } - }, - "options": { - "step": { - "options": { - "description": "\u8bf7\u786e\u8ba4Goole Cast\u7684\u914d\u7f6e" - } - } } } \ No newline at end of file diff --git a/homeassistant/components/cast/translations/zh-Hant.json b/homeassistant/components/cast/translations/zh-Hant.json index 947fce7a44b22..1994465c410fd 100644 --- a/homeassistant/components/cast/translations/zh-Hant.json +++ b/homeassistant/components/cast/translations/zh-Hant.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "error": { @@ -30,7 +29,7 @@ "ignore_cec": "\u5ffd\u7565 CEC", "uuid": "\u5df2\u5141\u8a31 UUID" }, - "description": "\u5df2\u5141\u8a31 UUID - \u4ee5\u9017\u865f\u5206\u9694\u7684 Chromecast \u88dd\u7f6e UUID \u5217\u8868\u4ee5\u65b0\u589e\u81f3 Home Assistant\u3002\u50c5\u65bc\u4e0d\u60f3\u5168\u90e8\u65b0\u589e\u6642\u4f7f\u7528\u3002\n\u5ffd\u7565 CEC - \u4ee5\u9017\u865f\u5206\u9694\u7684 Chromecast \u88dd\u7f6e\u5217\u8868\u3001\u5ffd\u7565\u5176 CEC \u63a7\u5236\u4ee5\u907f\u514d\u555f\u52d5\u8f38\u5165\u4f86\u6e90\u3002\u8cc7\u6599\u5c07\u6703\u50b3\u905e\u81f3 pychromecast.IGNORE_CEC\u3002", + "description": "\u5df2\u5141\u8a31 UUID - \u4ee5\u9017\u865f\u5206\u9694\u7684 Chromecast \u88dd\u7f6e UUID \u5217\u8868\u4ee5\u65b0\u589e\u81f3 Home Assistant\u3002\u50c5\u65bc\u4e0d\u60f3\u5c07\u5168\u90e8 Cast \u88dd\u7f6e\u65b0\u589e\u6642\u4f7f\u7528\u3002\n\u5ffd\u7565 CEC - \u4ee5\u9017\u865f\u5206\u9694\u7684 Chromecast \u88dd\u7f6e\u5217\u8868\u3001\u5ffd\u7565\u5176 CEC \u63a7\u5236\u4ee5\u907f\u514d\u555f\u52d5\u8f38\u5165\u4f86\u6e90\u3002\u8cc7\u6599\u5c07\u6703\u50b3\u905e\u81f3 pychromecast.IGNORE_CEC\u3002", "title": "Google Cast \u9032\u968e\u8a2d\u5b9a" }, "basic_options": { @@ -39,14 +38,6 @@ }, "description": "\u5df2\u77e5\u4e3b\u6a5f - \u4ee5\u9017\u865f\u5206\u9694\u7684 Chromecast \u88dd\u7f6e\u4e3b\u6a5f\u540d\u7a31 hostnames \u6216 IP \u4f4d\u5740\u3001\u5047\u5982 mDNS \u63a2\u7d22\u5931\u6548\u7684\u72c0\u6cc1\u3002", "title": "Google Cast \u8a2d\u5b9a" - }, - "options": { - "data": { - "ignore_cec": "\u9078\u9805\u5217\u8868\u5c07\u50b3\u905e\u81f3 pychromecast.IGNORE_CEC\u3002", - "known_hosts": "\u5047\u5982 mDNS \u63a2\u7d22\u7121\u6cd5\u4f5c\u7528\uff0c\u5247\u70ba\u5df2\u77e5\u4e3b\u6a5f\u7684\u9078\u9805\u5217\u8868\u3002", - "uuid": "UUID \u9078\u9805\u5217\u8868\u3002\u672a\u5217\u51fa\u7684 Cast \u88dd\u7f6e\u5c07\u4e0d\u6703\u9032\u884c\u65b0\u589e\u3002" - }, - "description": "\u8acb\u8f38\u5165 Google Cast \u8a2d\u5b9a\u3002" } } } diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index f91eaab49b66c..2980d4ca5d496 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -3,10 +3,16 @@ from datetime import datetime, timedelta import logging +from typing import Optional from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STARTED, + Platform, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_PORT, DOMAIN @@ -17,16 +23,15 @@ SCAN_INTERVAL = timedelta(hours=12) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load the saved entities.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) - await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator @@ -34,7 +39,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=f"{host}:{port}") - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + async def async_finish_startup(_): + await coordinator.async_refresh() + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + if hass.state == CoreState.running: + await async_finish_startup(None) + else: + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, async_finish_startup + ) + ) return True @@ -44,7 +60,7 @@ async def async_unload_entry(hass, entry): return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime]): +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[Optional[datetime]]): """Class to manage fetching Cert Expiry data from single endpoint.""" def __init__(self, hass, host, port): diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index d1b9588f5b177..13336c597715a 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Cert Expiry platform.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -25,7 +27,7 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._errors = {} + self._errors: dict[str, str] = {} async def _test_connection(self, user_input=None): """Test connection to the server and try to get the certificate.""" diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index a05acdb5d7739..bad8228901732 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -1,16 +1,17 @@ """Counter for the days until an HTTPS (TLS) certificate will expire.""" -from datetime import timedelta +from __future__ import annotations + +from datetime import datetime, timedelta import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - DEVICE_CLASS_TIMESTAMP, - EVENT_HOMEASSISTANT_START, +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later @@ -62,10 +63,7 @@ async def async_setup_entry(hass, entry, async_add_entities): class CertExpiryEntity(CoordinatorEntity): """Defines a base Cert Expiry entity.""" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return "mdi:certificate" + _attr_icon = "mdi:certificate" @property def extra_state_attributes(self): @@ -79,24 +77,17 @@ def extra_state_attributes(self): class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): """Implementation of the Cert Expiry timestamp sensor.""" - @property - def device_class(self): - """Return the device class of the sensor.""" - return DEVICE_CLASS_TIMESTAMP + _attr_device_class = SensorDeviceClass.TIMESTAMP - @property - def name(self): - """Return the name of the sensor.""" - return f"Cert Expiry Timestamp ({self.coordinator.name})" + def __init__(self, coordinator) -> None: + """Initialize a Cert Expiry timestamp sensor.""" + super().__init__(coordinator) + self._attr_name = f"Cert Expiry Timestamp ({coordinator.name})" + self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" @property - def state(self): + def native_value(self) -> datetime | None: """Return the state of the sensor.""" if self.coordinator.data: - return self.coordinator.data.isoformat() + return self.coordinator.data return None - - @property - def unique_id(self): - """Return a unique id for the sensor.""" - return f"{self.coordinator.host}:{self.coordinator.port}-timestamp" diff --git a/homeassistant/components/cert_expiry/translations/ca.json b/homeassistant/components/cert_expiry/translations/ca.json index 42da690550b98..1a9d3b109a5cd 100644 --- a/homeassistant/components/cert_expiry/translations/ca.json +++ b/homeassistant/components/cert_expiry/translations/ca.json @@ -20,5 +20,5 @@ } } }, - "title": "Caducitat del certificat" + "title": "Caducitat de certificat" } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/cs.json b/homeassistant/components/cert_expiry/translations/cs.json index c4b61df7084ac..44adaa19710ee 100644 --- a/homeassistant/components/cert_expiry/translations/cs.json +++ b/homeassistant/components/cert_expiry/translations/cs.json @@ -6,7 +6,8 @@ }, "error": { "connection_refused": "P\u0159ipojen\u00ed bylo odm\u00edtnuto p\u0159i p\u0159ipojov\u00e1n\u00ed k hostiteli", - "connection_timeout": "\u010casov\u00fd limit p\u0159i p\u0159ipojen\u00ed k tomuto hostiteli vypr\u0161el" + "connection_timeout": "\u010casov\u00fd limit p\u0159i p\u0159ipojen\u00ed k tomuto hostiteli vypr\u0161el", + "resolve_failed": "Tohoto hostitele nelze vy\u0159e\u0161it" }, "step": { "user": { @@ -14,7 +15,8 @@ "host": "Hostitel", "name": "N\u00e1zev certifik\u00e1tu", "port": "Port" - } + }, + "title": "Definujte certifik\u00e1t, kter\u00fd chcete testovat" } } }, diff --git a/homeassistant/components/cert_expiry/translations/de.json b/homeassistant/components/cert_expiry/translations/de.json index 2c01c9f71a676..640e715b359a5 100644 --- a/homeassistant/components/cert_expiry/translations/de.json +++ b/homeassistant/components/cert_expiry/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Diese Kombination aus Host und Port ist bereits konfiguriert.", + "already_configured": "Der Dienst ist bereits konfiguriert", "import_failed": "Import aus Konfiguration fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/cert_expiry/translations/fr.json b/homeassistant/components/cert_expiry/translations/fr.json index 070b5e26cbaf8..ae2a83be84923 100644 --- a/homeassistant/components/cert_expiry/translations/fr.json +++ b/homeassistant/components/cert_expiry/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cette combinaison h\u00f4te et port est d\u00e9j\u00e0 configur\u00e9e", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "import_failed": "\u00c9chec de l'importation \u00e0 partir de la configuration" }, "error": { diff --git a/homeassistant/components/cert_expiry/translations/he.json b/homeassistant/components/cert_expiry/translations/he.json new file mode 100644 index 0000000000000..1e14311ef678f --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, + "error": { + "connection_refused": "\u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e0\u05d3\u05d7\u05d4 \u05d1\u05e2\u05ea \u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc\u05de\u05d0\u05e8\u05d7", + "connection_timeout": "\u05d7\u05dc\u05e3 \u05d6\u05de\u05df \u05e7\u05e6\u05d5\u05d1 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc\u05de\u05d0\u05e8\u05d7 \u05d6\u05d4", + "resolve_failed": "\u05d0\u05d9\u05df \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05e4\u05e2\u05e0\u05d7 \u05de\u05d0\u05e8\u05d7 \u05d6\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/hu.json b/homeassistant/components/cert_expiry/translations/hu.json index 2ae516565e3a3..26f314651157b 100644 --- a/homeassistant/components/cert_expiry/translations/hu.json +++ b/homeassistant/components/cert_expiry/translations/hu.json @@ -4,14 +4,21 @@ "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "import_failed": "Nem siker\u00fclt import\u00e1lni a konfigur\u00e1ci\u00f3t" }, + "error": { + "connection_refused": "A kapcsolat megtagadva a gazdag\u00e9phez val\u00f3 csatlakoz\u00e1skor", + "connection_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s, ehhez a c\u00edmhez kapcsol\u00f3d\u00e1skor", + "resolve_failed": "Ez a c\u00edm nem oldhat\u00f3 fel" + }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "A tan\u00fas\u00edtv\u00e1ny neve", "port": "Port" - } + }, + "title": "Hat\u00e1rozza meg a vizsg\u00e1land\u00f3 tan\u00fas\u00edtv\u00e1nyt" } } - } + }, + "title": "Tan\u00fas\u00edtv\u00e1ny lej\u00e1rata" } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/it.json b/homeassistant/components/cert_expiry/translations/it.json index 232ee01022154..4904f7afe3ae0 100644 --- a/homeassistant/components/cert_expiry/translations/it.json +++ b/homeassistant/components/cert_expiry/translations/it.json @@ -16,7 +16,7 @@ "name": "Il nome del certificato", "port": "Porta" }, - "title": "Definire il certificato da testare" + "title": "Definire il certificato da provare" } } }, diff --git a/homeassistant/components/cert_expiry/translations/ja.json b/homeassistant/components/cert_expiry/translations/ja.json new file mode 100644 index 0000000000000..5b3aa8dbe616d --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "import_failed": "\u30b3\u30f3\u30d5\u30a3\u30b0\u304b\u3089\u306e\u30a4\u30f3\u30dd\u30fc\u30c8\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "connection_refused": "\u30db\u30b9\u30c8\u306b\u63a5\u7d9a\u3059\u308b\u3068\u304d\u306b\u63a5\u7d9a\u304c\u62d2\u5426\u3055\u308c\u307e\u3057\u305f", + "connection_timeout": "\u3053\u306e\u30db\u30b9\u30c8\u306b\u63a5\u7d9a\u3059\u308b\u3068\u304d\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", + "resolve_failed": "\u3053\u306e\u30db\u30b9\u30c8\u306f\u89e3\u6c7a\u3067\u304d\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u8a3c\u660e\u66f8\u306e\u540d\u524d", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u30c6\u30b9\u30c8\u3059\u308b\u8a3c\u660e\u66f8\u3092\u5b9a\u7fa9\u3059\u308b" + } + } + }, + "title": "\u8a3c\u660e\u66f8\u306e\u6709\u52b9\u671f\u9650" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/tr.json b/homeassistant/components/cert_expiry/translations/tr.json index 6c05bef3a65f6..173e78adb4473 100644 --- a/homeassistant/components/cert_expiry/translations/tr.json +++ b/homeassistant/components/cert_expiry/translations/tr.json @@ -1,15 +1,24 @@ { "config": { "abort": { - "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "import_failed": "Yap\u0131land\u0131rmadan i\u00e7e aktarma ba\u015far\u0131s\u0131z oldu" + }, + "error": { + "connection_refused": "Ana bilgisayara ba\u011flan\u0131rken ba\u011flant\u0131 reddedildi", + "connection_timeout": "Bu ana bilgisayara ba\u011flan\u0131rken zaman a\u015f\u0131m\u0131", + "resolve_failed": "Bu ana bilgisayar \u00e7\u00f6z\u00fcmlenemedi" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", + "name": "Sertifikan\u0131n ad\u0131", "port": "Port" - } + }, + "title": "Test edilecek sertifikay\u0131 tan\u0131mlay\u0131n" } } - } + }, + "title": "Sertifikan\u0131n Sona Erme Tarihi" } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/zh-Hans.json b/homeassistant/components/cert_expiry/translations/zh-Hans.json index 07affc990a817..201749ae79608 100644 --- a/homeassistant/components/cert_expiry/translations/zh-Hans.json +++ b/homeassistant/components/cert_expiry/translations/zh-Hans.json @@ -1,15 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "import_failed": "\u914d\u7f6e\u5bfc\u5165\u5931\u8d25" + }, "error": { - "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6" + "connection_refused": "\u8fde\u63a5\u5230\u4e3b\u673a\u65f6\u88ab\u62d2\u7edd\u8fde\u63a5", + "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6", + "resolve_failed": "\u65e0\u6cd5\u89e3\u6790\u4e3b\u673a" }, "step": { "user": { "data": { - "host": "\u8bc1\u4e66\u7684\u4e3b\u673a\u540d", + "host": "\u4e3b\u673a\u5730\u5740", "name": "\u8bc1\u4e66\u7684\u540d\u79f0", "port": "\u8bc1\u4e66\u7684\u7aef\u53e3" - } + }, + "title": "\u5b9a\u4e49\u8981\u6d4b\u8bd5\u7684\u8bc1\u4e66" } } } diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py index 2b62039989a1d..bfdc12c35ce32 100644 --- a/homeassistant/components/channels/media_player.py +++ b/homeassistant/components/channels/media_player.py @@ -255,7 +255,7 @@ def play_media(self, media_type, media_id, **kwargs): if media_type == MEDIA_TYPE_CHANNEL: response = self.client.play_channel(media_id) self.update_state(response) - elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW]: + elif media_type in (MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW): response = self.client.play_recording(media_id) self.update_state(response) diff --git a/homeassistant/components/channels/services.yaml b/homeassistant/components/channels/services.yaml index f06b2bfd90556..5aa2f1ebda73d 100644 --- a/homeassistant/components/channels/services.yaml +++ b/homeassistant/components/channels/services.yaml @@ -1,23 +1,33 @@ seek_forward: + name: Seek forward description: Seek forward by a set number of seconds. - fields: - entity_id: - description: Name of entity for the instance of Channels to seek in. - example: "media_player.family_room_channels" + target: + entity: + integration: channels + domain: media_player seek_backward: + name: Seek backward description: Seek backward by a set number of seconds. - fields: - entity_id: - description: Name of entity for the instance of Channels to seek in. - example: "media_player.family_room_channels" + target: + entity: + integration: channels + domain: media_player seek_by: + name: Seek by description: Seek by an inputted number of seconds. + target: + entity: + integration: channels + domain: media_player fields: - entity_id: - description: Name of entity for the instance of Channels to seek in. - example: "media_player.family_room_channels" seconds: + name: Seconds description: Number of seconds to seek by. Negative numbers seek backwards. - example: 120 + required: true + selector: + number: + min: -3600 + max: 3600 + unit_of_measurement: seconds diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 0c77fc6fd7ee1..9861f657ff615 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend( + PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, @@ -65,9 +65,7 @@ def _update_info(self): Returns boolean if scanning successful. """ - string_result = self._get_arp_data() - - if string_result: + if string_result := self._get_arp_data(): self.last_results = [] last_results = [] @@ -118,7 +116,7 @@ def _get_arp_data(self): router_hostname = initial_line[len(initial_line) - 1] router_hostname += "#" # Set the discovered hostname as prompt - regex_expression = ("(?i)^%s" % router_hostname).encode() + regex_expression = f"(?i)^{router_hostname}".encode() cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE) # Allow full arp table to print at once cisco_ssh.sendline("terminal length 0") diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index b032ca30fc3e7..5854f4a72a14b 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import ( @@ -23,7 +23,7 @@ DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index bc323a51151e0..937e2582fbbae 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -135,7 +135,7 @@ async def async_citybikes_request(hass, uri, schema): try: session = async_get_clientsession(hass) - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) json_response = await req.json() @@ -265,50 +265,32 @@ async def async_refresh(self, now=None): class CityBikesStation(SensorEntity): """CityBikes API Sensor.""" + _attr_native_unit_of_measurement = "bikes" + _attr_icon = "mdi:bike" + def __init__(self, network, station_id, entity_id): """Initialize the sensor.""" self._network = network self._station_id = station_id - self._station_data = {} self.entity_id = entity_id - @property - def state(self): - """Return the state of the sensor.""" - return self._station_data.get(ATTR_FREE_BIKES) - - @property - def name(self): - """Return the name of the sensor.""" - return self._station_data.get(ATTR_NAME) - async def async_update(self): """Update station state.""" for station in self._network.stations: if station[ATTR_ID] == self._station_id: - self._station_data = station + station_data = station break - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self._station_data: - return { + self._attr_name = station_data.get(ATTR_NAME) + self._attr_native_value = station_data.get(ATTR_FREE_BIKES) + self._attr_extra_state_attributes = ( + { ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION, - ATTR_UID: self._station_data.get(ATTR_EXTRA, {}).get(ATTR_UID), - ATTR_LATITUDE: self._station_data[ATTR_LATITUDE], - ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE], - ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS], - ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP], + ATTR_UID: station_data.get(ATTR_EXTRA, {}).get(ATTR_UID), + ATTR_LATITUDE: station_data[ATTR_LATITUDE], + ATTR_LONGITUDE: station_data[ATTR_LONGITUDE], + ATTR_EMPTY_SLOTS: station_data[ATTR_EMPTY_SLOTS], + ATTR_TIMESTAMP: station_data[ATTR_TIMESTAMP], } - return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return "bikes" - - @property - def icon(self): - """Return the icon.""" - return "mdi:bike" + if station_data + else {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} + ) diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 44ba5c3d6001f..2d7099e1f54cc 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -67,18 +67,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ClementineDevice(MediaPlayerEntity): """Representation of Clementine Player.""" + _attr_media_content_type = MEDIA_TYPE_MUSIC + _attr_supported_features = SUPPORT_CLEMENTINE + def __init__(self, client, name): """Initialize the Clementine device.""" self._client = client - self._name = name - self._muted = False - self._volume = 0.0 - self._track_id = 0 - self._last_track_id = 0 - self._track_name = "" - self._track_artist = "" - self._track_album_name = "" - self._state = None + self._attr_name = name def update(self): """Retrieve the latest data from the Clementine Player.""" @@ -86,59 +81,37 @@ def update(self): client = self._client if client.state == "Playing": - self._state = STATE_PLAYING + self._attr_state = STATE_PLAYING elif client.state == "Paused": - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED elif client.state == "Disconnected": - self._state = STATE_OFF + self._attr_state = STATE_OFF else: - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED if client.last_update and (time.time() - client.last_update > 40): - self._state = STATE_OFF + self._attr_state = STATE_OFF - self._volume = float(client.volume) if client.volume else 0.0 + volume = float(client.volume) if client.volume else 0.0 + self._attr_volume_level = volume / 100.0 + if client.active_playlist_id in client.playlists: + self._attr_source = client.playlists[client.active_playlist_id]["name"] + else: + self._attr_source = "Unknown" + self._attr_source_list = [s["name"] for s in client.playlists.values()] if client.current_track: - self._track_id = client.current_track["track_id"] - self._track_name = client.current_track["title"] - self._track_artist = client.current_track["track_artist"] - self._track_album_name = client.current_track["track_album"] + self._attr_media_title = client.current_track["title"] + self._attr_media_artist = client.current_track["track_artist"] + self._attr_media_album_name = client.current_track["track_album"] + self._attr_media_image_hash = client.current_track["track_id"] + else: + self._attr_media_image_hash = None except Exception: - self._state = STATE_OFF + self._attr_state = STATE_OFF raise - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume / 100.0 - - @property - def source(self): - """Return current source name.""" - source_name = "Unknown" - client = self._client - if client.active_playlist_id in client.playlists: - source_name = client.playlists[client.active_playlist_id]["name"] - return source_name - - @property - def source_list(self): - """List of available input sources.""" - source_names = [s["name"] for s in self._client.playlists.values()] - return source_names - def select_source(self, source): """Select input source.""" client = self._client @@ -146,39 +119,6 @@ def select_source(self, source): if len(sources) == 1: client.change_song(sources[0]["id"], 0) - @property - def media_content_type(self): - """Content type of current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def media_title(self): - """Title of current playing media.""" - return self._track_name - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self._track_artist - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self._track_album_name - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_CLEMENTINE - - @property - def media_image_hash(self): - """Hash value for media image.""" - if self._client.current_track: - return self._client.current_track["track_id"] - - return None - async def async_get_media_image(self): """Fetch media image of current playing image.""" if self._client.current_track: @@ -207,19 +147,19 @@ def set_volume_level(self, volume): def media_play_pause(self): """Simulate play pause media player.""" - if self._state == STATE_PLAYING: + if self.state == STATE_PLAYING: self.media_pause() else: self.media_play() def media_play(self): """Send play command.""" - self._state = STATE_PLAYING + self._attr_state = STATE_PLAYING self._client.play() def media_pause(self): """Send media pause command to media player.""" - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED self._client.pause() def media_next_track(self): diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index 09096f44b7403..fdefb25aef450 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -1,11 +1,12 @@ """Clickatell platform for notify component.""" +from http import HTTPStatus import logging import requests import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT, HTTP_ACCEPTED, HTTP_OK +from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,5 +38,5 @@ def send_message(self, message="", **kwargs): data = {"apiKey": self.api_key, "to": self.recipient, "content": message} resp = requests.get(BASE_API_URL, params=data, timeout=5) - if (resp.status_code != HTTP_OK) or (resp.status_code != HTTP_ACCEPTED): + if resp.status_code not in (HTTPStatus.OK, HTTPStatus.ACCEPTED): _LOGGER.error("Error %s : %s", resp.status_code, resp.text) diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index 18562260431c5..74f1c2e1ae518 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -1,4 +1,5 @@ """Clicksend platform for notify component.""" +from http import HTTPStatus import json import logging @@ -13,7 +14,6 @@ CONF_SENDER, CONF_USERNAME, CONTENT_TYPE_JSON, - HTTP_OK, ) import homeassistant.helpers.config_validation as cv @@ -81,7 +81,7 @@ def send_message(self, message="", **kwargs): auth=(self.username, self.api_key), timeout=TIMEOUT, ) - if resp.status_code == HTTP_OK: + if resp.status_code == HTTPStatus.OK: return obj = json.loads(resp.text) @@ -101,6 +101,4 @@ def _authenticate(config): auth=(config[CONF_USERNAME], config[CONF_API_KEY]), timeout=TIMEOUT, ) - if resp.status_code != HTTP_OK: - return False - return True + return resp.status_code == HTTPStatus.OK diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index 6648333bb5411..712787c34e693 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -1,4 +1,5 @@ """clicksend_tts platform for notify component.""" +from http import HTTPStatus import json import logging @@ -12,7 +13,6 @@ CONF_RECIPIENT, CONF_USERNAME, CONTENT_TYPE_JSON, - HTTP_OK, ) import homeassistant.helpers.config_validation as cv @@ -88,7 +88,7 @@ def send_message(self, message="", **kwargs): timeout=TIMEOUT, ) - if resp.status_code == HTTP_OK: + if resp.status_code == HTTPStatus.OK: return obj = json.loads(resp.text) response_msg = obj["response_msg"] @@ -108,7 +108,4 @@ def _authenticate(config): timeout=TIMEOUT, ) - if resp.status_code != HTTP_OK: - return False - - return True + return resp.status_code == HTTPStatus.OK diff --git a/homeassistant/components/climacell/__init__.py b/homeassistant/components/climacell/__init__.py index 85a23ef10a966..e3edc77895521 100644 --- a/homeassistant/components/climacell/__init__.py +++ b/homeassistant/components/climacell/__init__.py @@ -15,8 +15,6 @@ UnknownException, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -24,9 +22,11 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -35,7 +35,6 @@ ) from .const import ( - ATTR_FIELD, ATTRIBUTION, CC_ATTR_CLOUD_COVER, CC_ATTR_CONDITION, @@ -76,7 +75,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] def _set_update_interval(hass: HomeAssistant, current_entry: ConfigEntry) -> timedelta: @@ -109,19 +108,19 @@ def _set_update_interval(hass: HomeAssistant, current_entry: ConfigEntry) -> tim return interval -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ClimaCell API from a config entry.""" hass.data.setdefault(DOMAIN, {}) - params = {} + params: dict[str, Any] = {} # If config entry options not set up, set them up - if not config_entry.options: + if not entry.options: params["options"] = { CONF_TIMESTEP: DEFAULT_TIMESTEP, } else: # Use valid timestep if it's invalid - timestep = config_entry.options[CONF_TIMESTEP] + timestep = entry.options[CONF_TIMESTEP] if timestep not in (1, 5, 15, 30): if timestep <= 2: timestep = 1 @@ -131,38 +130,38 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b timestep = 15 else: timestep = 30 - new_options = config_entry.options.copy() + new_options = entry.options.copy() new_options[CONF_TIMESTEP] = timestep params["options"] = new_options # Add API version if not found - if CONF_API_VERSION not in config_entry.data: - new_data = config_entry.data.copy() + if CONF_API_VERSION not in entry.data: + new_data = entry.data.copy() new_data[CONF_API_VERSION] = 3 params["data"] = new_data if params: - hass.config_entries.async_update_entry(config_entry, **params) + hass.config_entries.async_update_entry(entry, **params) - api_class = ClimaCellV3 if config_entry.data[CONF_API_VERSION] == 3 else ClimaCellV4 + api_class = ClimaCellV3 if entry.data[CONF_API_VERSION] == 3 else ClimaCellV4 api = api_class( - config_entry.data[CONF_API_KEY], - config_entry.data.get(CONF_LATITUDE, hass.config.latitude), - config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), + entry.data[CONF_API_KEY], + entry.data.get(CONF_LATITUDE, hass.config.latitude), + entry.data.get(CONF_LONGITUDE, hass.config.longitude), session=async_get_clientsession(hass), ) coordinator = ClimaCellDataUpdateCoordinator( hass, - config_entry, + entry, api, - _set_update_interval(hass, config_entry), + _set_update_interval(hass, entry), ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][config_entry.entry_id] = coordinator + hass.data[DOMAIN][entry.entry_id] = coordinator - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -207,7 +206,7 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - data = {FORECASTS: {}} + data: dict[str, Any] = {FORECASTS: {}} try: if self._api_version == 3: data[CURRENT] = await self._api.realtime( @@ -223,10 +222,7 @@ async def _async_update_data(self) -> dict[str, Any]: CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_PRECIPITATION_TYPE, - *[ - sensor_type[ATTR_FIELD] - for sensor_type in CC_V3_SENSOR_TYPES - ], + *(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES), ] ) data[FORECASTS][HOURLY] = await self._api.forecast_hourly( @@ -283,7 +279,7 @@ async def _async_update_data(self) -> dict[str, Any]: CC_ATTR_WIND_GUST, CC_ATTR_CLOUD_COVER, CC_ATTR_PRECIPITATION_TYPE, - *[sensor_type[ATTR_FIELD] for sensor_type in CC_SENSOR_TYPES], + *(sensor_type.key for sensor_type in CC_SENSOR_TYPES), ], [ CC_ATTR_TEMPERATURE_LOW, @@ -361,10 +357,10 @@ def attribution(self): @property def device_info(self) -> DeviceInfo: """Return device registry information.""" - return { - "identifiers": {(DOMAIN, self._config_entry.data[CONF_API_KEY])}, - "name": "ClimaCell", - "manufacturer": "ClimaCell", - "sw_version": f"v{self.api_version}", - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, + manufacturer="ClimaCell", + name="ClimaCell", + sw_version=f"v{self.api_version}", + ) diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 0352807138a15..83152cc38f2a4 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -1,15 +1,23 @@ """Constants for the ClimaCell integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import IntEnum + from pyclimacell.const import ( DAILY, HOURLY, NOWCAST, HealthConcernType, PollenIndex, + PrecipitationType, PrimaryPollutantType, V3PollenIndex, WeatherCode, ) +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -25,14 +33,26 @@ ATTR_CONDITION_WINDY, ) from homeassistant.const import ( - ATTR_NAME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_UNIT_OF_MEASUREMENT, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, + IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + IRRADIATION_WATTS_PER_SQUARE_METER, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) +from homeassistant.util.distance import convert as distance_convert +from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.temperature import convert as temp_convert CONF_TIMESTEP = "timestep" FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] @@ -43,7 +63,7 @@ DOMAIN = "climacell" ATTRIBUTION = "Powered by ClimaCell" -MAX_REQUESTS_PER_DAY = 500 +MAX_REQUESTS_PER_DAY = 100 CLEAR_CONDITIONS = {"night": ATTR_CONDITION_CLEAR_NIGHT, "day": ATTR_CONDITION_SUNNY} @@ -53,12 +73,6 @@ NOWCAST: 30, } -# Sensor type keys -ATTR_FIELD = "field" -ATTR_METRIC_CONVERSION = "metric_conversion" -ATTR_VALUE_MAP = "value_map" -ATTR_IS_METRIC_CHECK = "is_metric_check" - # Additional attributes ATTR_WIND_GUST = "wind_gust" ATTR_CLOUD_COVER = "cloud_cover" @@ -126,78 +140,202 @@ CC_ATTR_POLLEN_WEED = "weedIndex" CC_ATTR_POLLEN_GRASS = "grassIndex" CC_ATTR_FIRE_INDEX = "fireIndex" +CC_ATTR_FEELS_LIKE = "temperatureApparent" +CC_ATTR_DEW_POINT = "dewPoint" +CC_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" +CC_ATTR_SOLAR_GHI = "solarGHI" +CC_ATTR_CLOUD_BASE = "cloudBase" +CC_ATTR_CLOUD_CEILING = "cloudCeiling" + + +@dataclass +class ClimaCellSensorEntityDescription(SensorEntityDescription): + """Describes a ClimaCell sensor entity.""" -CC_SENSOR_TYPES = [ - { - ATTR_FIELD: CC_ATTR_PARTICULATE_MATTER_25, - ATTR_NAME: "Particulate Matter < 2.5 μm", - CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 3.2808399 ** 3, - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_PARTICULATE_MATTER_10, - ATTR_NAME: "Particulate Matter < 10 μm", - CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 3.2808399 ** 3, - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_NITROGEN_DIOXIDE, - ATTR_NAME: "Nitrogen Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_ATTR_CARBON_MONOXIDE, - ATTR_NAME: "Carbon Monoxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_ATTR_SULFUR_DIOXIDE, - ATTR_NAME: "Sulfur Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - {ATTR_FIELD: CC_ATTR_EPA_AQI, ATTR_NAME: "US EPA Air Quality Index"}, - { - ATTR_FIELD: CC_ATTR_EPA_PRIMARY_POLLUTANT, - ATTR_NAME: "US EPA Primary Pollutant", - ATTR_VALUE_MAP: PrimaryPollutantType, - }, - { - ATTR_FIELD: CC_ATTR_EPA_HEALTH_CONCERN, - ATTR_NAME: "US EPA Health Concern", - ATTR_VALUE_MAP: HealthConcernType, - }, - {ATTR_FIELD: CC_ATTR_CHINA_AQI, ATTR_NAME: "China MEP Air Quality Index"}, - { - ATTR_FIELD: CC_ATTR_CHINA_PRIMARY_POLLUTANT, - ATTR_NAME: "China MEP Primary Pollutant", - ATTR_VALUE_MAP: PrimaryPollutantType, - }, - { - ATTR_FIELD: CC_ATTR_CHINA_HEALTH_CONCERN, - ATTR_NAME: "China MEP Health Concern", - ATTR_VALUE_MAP: HealthConcernType, - }, - { - ATTR_FIELD: CC_ATTR_POLLEN_TREE, - ATTR_NAME: "Tree Pollen Index", - ATTR_VALUE_MAP: PollenIndex, - }, - { - ATTR_FIELD: CC_ATTR_POLLEN_WEED, - ATTR_NAME: "Weed Pollen Index", - ATTR_VALUE_MAP: PollenIndex, - }, - { - ATTR_FIELD: CC_ATTR_POLLEN_GRASS, - ATTR_NAME: "Grass Pollen Index", - ATTR_VALUE_MAP: PollenIndex, - }, - {ATTR_FIELD: CC_ATTR_FIRE_INDEX, ATTR_NAME: "Fire Index"}, -] + unit_imperial: str | None = None + unit_metric: str | None = None + metric_conversion: Callable[[float], float] | float = 1.0 + is_metric_check: bool | None = None + device_class: str | None = None + value_map: IntEnum | None = None + + def __post_init__(self) -> None: + """Post initialization.""" + units = (self.unit_imperial, self.unit_metric) + if any(u is not None for u in units) and any(u is None for u in units): + raise RuntimeError( + "`unit_imperial` and `unit_metric` both need to be None or both need " + "to be defined." + ) + + +CC_SENSOR_TYPES = ( + ClimaCellSensorEntityDescription( + key=CC_ATTR_FEELS_LIKE, + name="Feels Like", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=SensorDeviceClass.TEMPERATURE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_DEW_POINT, + name="Dew Point", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=SensorDeviceClass.TEMPERATURE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PRESSURE_SURFACE_LEVEL, + name="Pressure (Surface Level)", + unit_imperial=PRESSURE_INHG, + unit_metric=PRESSURE_HPA, + metric_conversion=lambda val: pressure_convert( + val, PRESSURE_INHG, PRESSURE_HPA + ), + is_metric_check=True, + device_class=SensorDeviceClass.PRESSURE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_SOLAR_GHI, + name="Global Horizontal Irradiance", + unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, + metric_conversion=3.15459, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CLOUD_BASE, + name="Cloud Base", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CLOUD_CEILING, + name="Cloud Ceiling", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CLOUD_COVER, + name="Cloud Cover", + unit_imperial=PERCENTAGE, + unit_metric=PERCENTAGE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_WIND_GUST, + name="Wind Gust", + unit_imperial=SPEED_MILES_PER_HOUR, + unit_metric=SPEED_METERS_PER_SECOND, + metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) + / 3600, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PRECIPITATION_TYPE, + name="Precipitation Type", + value_map=PrecipitationType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_OZONE, + name="Ozone", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_SULFUR_DIOXIDE, + name="Sulfur Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + value_map=PrimaryPollutantType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + value_map=HealthConcernType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + value_map=PrimaryPollutantType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + value_map=HealthConcernType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=PollenIndex, + ), + ClimaCellSensorEntityDescription( + CC_ATTR_FIRE_INDEX, + name="Fire Index", + ), +) # V3 constants CONDITIONS_V3 = { @@ -261,67 +399,89 @@ CC_V3_ATTR_POLLEN_GRASS = "pollen_grass" CC_V3_ATTR_FIRE_INDEX = "fire_index" -CC_V3_SENSOR_TYPES = [ - { - ATTR_FIELD: CC_V3_ATTR_PARTICULATE_MATTER_25, - ATTR_NAME: "Particulate Matter < 2.5 μm", - CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 1 / (3.2808399 ** 3), - ATTR_IS_METRIC_CHECK: False, - }, - { - ATTR_FIELD: CC_V3_ATTR_PARTICULATE_MATTER_10, - ATTR_NAME: "Particulate Matter < 10 μm", - CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 1 / (3.2808399 ** 3), - ATTR_IS_METRIC_CHECK: False, - }, - { - ATTR_FIELD: CC_V3_ATTR_NITROGEN_DIOXIDE, - ATTR_NAME: "Nitrogen Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_V3_ATTR_CARBON_MONOXIDE, - ATTR_NAME: "Carbon Monoxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, - }, - { - ATTR_FIELD: CC_V3_ATTR_SULFUR_DIOXIDE, - ATTR_NAME: "Sulfur Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - {ATTR_FIELD: CC_V3_ATTR_EPA_AQI, ATTR_NAME: "US EPA Air Quality Index"}, - { - ATTR_FIELD: CC_V3_ATTR_EPA_PRIMARY_POLLUTANT, - ATTR_NAME: "US EPA Primary Pollutant", - }, - {ATTR_FIELD: CC_V3_ATTR_EPA_HEALTH_CONCERN, ATTR_NAME: "US EPA Health Concern"}, - {ATTR_FIELD: CC_V3_ATTR_CHINA_AQI, ATTR_NAME: "China MEP Air Quality Index"}, - { - ATTR_FIELD: CC_V3_ATTR_CHINA_PRIMARY_POLLUTANT, - ATTR_NAME: "China MEP Primary Pollutant", - }, - { - ATTR_FIELD: CC_V3_ATTR_CHINA_HEALTH_CONCERN, - ATTR_NAME: "China MEP Health Concern", - }, - { - ATTR_FIELD: CC_V3_ATTR_POLLEN_TREE, - ATTR_NAME: "Tree Pollen Index", - ATTR_VALUE_MAP: V3PollenIndex, - }, - { - ATTR_FIELD: CC_V3_ATTR_POLLEN_WEED, - ATTR_NAME: "Weed Pollen Index", - ATTR_VALUE_MAP: V3PollenIndex, - }, - { - ATTR_FIELD: CC_V3_ATTR_POLLEN_GRASS, - ATTR_NAME: "Grass Pollen Index", - ATTR_VALUE_MAP: V3PollenIndex, - }, - {ATTR_FIELD: CC_V3_ATTR_FIRE_INDEX, ATTR_NAME: "Fire Index"}, -] +CC_V3_SENSOR_TYPES = ( + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_OZONE, + name="Ozone", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=False, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=False, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_SULFUR_DIOXIDE, + name="Sulfur Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=V3PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=V3PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=V3PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_FIRE_INDEX, + name="Fire Index", + ), +) diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py index df6110794037c..597e1095f89eb 100644 --- a/homeassistant/components/climacell/sensor.py +++ b/homeassistant/components/climacell/sensor.py @@ -2,40 +2,24 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Mapping -import logging -from typing import Any from pyclimacell.const import CURRENT from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_NAME, - CONF_API_VERSION, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_VERSION, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( - ATTR_FIELD, - ATTR_IS_METRIC_CHECK, - ATTR_METRIC_CONVERSION, - ATTR_VALUE_MAP, CC_SENSOR_TYPES, CC_V3_SENSOR_TYPES, DOMAIN, + ClimaCellSensorEntityDescription, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -44,17 +28,18 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - api_version = config_entry.data[CONF_API_VERSION] + api_class: type[BaseClimaCellSensorEntity] + sensor_types: tuple[ClimaCellSensorEntityDescription, ...] - if api_version == 3: + if (api_version := config_entry.data[CONF_API_VERSION]) == 3: api_class = ClimaCellV3SensorEntity sensor_types = CC_V3_SENSOR_TYPES else: api_class = ClimaCellSensorEntity sensor_types = CC_SENSOR_TYPES entities = [ - api_class(config_entry, coordinator, api_version, sensor_type) - for sensor_type in sensor_types + api_class(hass, config_entry, coordinator, api_version, description) + for description in sensor_types ] async_add_entities(entities) @@ -62,52 +47,30 @@ async def async_setup_entry( class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): """Base ClimaCell sensor entity.""" + entity_description: ClimaCellSensorEntityDescription + def __init__( self, + hass: HomeAssistant, config_entry: ConfigEntry, coordinator: ClimaCellDataUpdateCoordinator, api_version: int, - sensor_type: dict[str, str | float], + description: ClimaCellSensorEntityDescription, ) -> None: """Initialize ClimaCell Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) - self.sensor_type = sensor_type - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"{self._config_entry.data[CONF_NAME]} - {self.sensor_type[ATTR_NAME]}" - - @property - def unique_id(self) -> str: - """Return the unique id of the entity.""" - return f"{self._config_entry.unique_id}_{slugify(self.sensor_type[ATTR_NAME])}" - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes.""" - return {ATTR_ATTRIBUTION: self.attribution} - - @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - if CONF_UNIT_OF_MEASUREMENT in self.sensor_type: - return self.sensor_type[CONF_UNIT_OF_MEASUREMENT] - - if ( - CONF_UNIT_SYSTEM_IMPERIAL in self.sensor_type - and CONF_UNIT_SYSTEM_METRIC in self.sensor_type - ): - if self.hass.config.units.is_metric: - return self.sensor_type[CONF_UNIT_SYSTEM_METRIC] - return self.sensor_type[CONF_UNIT_SYSTEM_IMPERIAL] - - return None + self.entity_description = description + self._attr_entity_registry_enabled_default = False + self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" + self._attr_unique_id = ( + f"{self._config_entry.unique_id}_{slugify(description.name)}" + ) + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} + self._attr_native_unit_of_measurement = ( + description.unit_metric + if hass.config.units.is_metric + else description.unit_imperial + ) @property @abstractmethod @@ -115,22 +78,30 @@ def _state(self) -> str | int | float | None: """Return the raw state.""" @property - def state(self) -> str | int | float | None: + def native_value(self) -> str | int | float | None: """Return the state.""" + state = self._state if ( - self._state is not None - and CONF_UNIT_SYSTEM_IMPERIAL in self.sensor_type - and CONF_UNIT_SYSTEM_METRIC in self.sensor_type - and ATTR_METRIC_CONVERSION in self.sensor_type - and ATTR_IS_METRIC_CHECK in self.sensor_type + state is not None + and not isinstance(state, str) + and self.entity_description.unit_imperial is not None + and self.entity_description.metric_conversion != 1.0 + and self.entity_description.is_metric_check is not None and self.hass.config.units.is_metric - == self.sensor_type[ATTR_IS_METRIC_CHECK] + == self.entity_description.is_metric_check ): - return round(self._state * self.sensor_type[ATTR_METRIC_CONVERSION], 4) + conversion = self.entity_description.metric_conversion + # When conversion is a callable, we assume it's a single input function + if callable(conversion): + return round(conversion(state), 4) + + return round(state * conversion, 4) + + if self.entity_description.value_map is not None and state is not None: + # mypy bug: "Literal[IntEnum.value]" not callable + return self.entity_description.value_map(state).name.lower() # type: ignore[misc] - if ATTR_VALUE_MAP in self.sensor_type and self._state is not None: - return self.sensor_type[ATTR_VALUE_MAP](self._state).name.lower() - return self._state + return state class ClimaCellSensorEntity(BaseClimaCellSensorEntity): @@ -139,7 +110,7 @@ class ClimaCellSensorEntity(BaseClimaCellSensorEntity): @property def _state(self) -> str | int | float | None: """Return the raw state.""" - return self._get_current_property(self.sensor_type[ATTR_FIELD]) + return self._get_current_property(self.entity_description.key) class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity): @@ -149,5 +120,5 @@ class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity): def _state(self) -> str | int | float | None: """Return the raw state.""" return self._get_cc_value( - self.coordinator.data[CURRENT], self.sensor_type[ATTR_FIELD] + self.coordinator.data[CURRENT], self.entity_description.key ) diff --git a/homeassistant/components/climacell/strings.json b/homeassistant/components/climacell/strings.json index f4347d254b704..44021f4b6d066 100644 --- a/homeassistant/components/climacell/strings.json +++ b/homeassistant/components/climacell/strings.json @@ -1,5 +1,4 @@ { - "title": "ClimaCell", "config": { "step": { "user": { diff --git a/homeassistant/components/climacell/translations/bg.json b/homeassistant/components/climacell/translations/bg.json index 6b1e4d3cba27a..af84485310d6a 100644 --- a/homeassistant/components/climacell/translations/bg.json +++ b/homeassistant/components/climacell/translations/bg.json @@ -1,11 +1,13 @@ { "config": { "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", "name": "\u0418\u043c\u0435" diff --git a/homeassistant/components/climacell/translations/ca.json b/homeassistant/components/climacell/translations/ca.json index 3f215b6323435..e7b04018934e3 100644 --- a/homeassistant/components/climacell/translations/ca.json +++ b/homeassistant/components/climacell/translations/ca.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Tipus de previsi\u00f3", "timestep": "Minuts entre previsions NowCast" }, "description": "Si decideixes activar l'entitat de predicci\u00f3 \"nowcast\", podr\u00e0s configurar l'interval en minuts entre cada previsi\u00f3. El nombre de previsions proporcionades dep\u00e8n d'aquest interval de minuts.", diff --git a/homeassistant/components/climacell/translations/de.json b/homeassistant/components/climacell/translations/de.json index e53b96d8e731c..123a1257d9934 100644 --- a/homeassistant/components/climacell/translations/de.json +++ b/homeassistant/components/climacell/translations/de.json @@ -23,10 +23,9 @@ "step": { "init": { "data": { - "forecast_types": "Vorhersage Arten", "timestep": "Minuten zwischen den Kurzvorhersagen" }, - "description": "Wenn du die Vorhersage-Entitit\u00e4t \"Kurzvorhersage\" aktivierst, kannst du die Anzahl der Minuten zwischen den einzelnen Vorhersagen konfigurieren. Die Anzahl der bereitgestellten Vorhersagen h\u00e4ngt von der Anzahl der zwischen den Vorhersagen gew\u00e4hlten Minuten ab.", + "description": "Wenn du die Vorhersage-Entitit\u00e4t \"Kurzvorhersage\" aktivierst, kannst du die Anzahl der Minuten zwischen den einzelnen Vorhersagen konfigurieren. Die Anzahl der bereitgestellten Vorhersagen h\u00e4ngt von der Anzahl der zwischen den Vorhersagen gew\u00e4hlten Minuten ab.", "title": "Aktualisiere ClimaCell-Optionen" } } diff --git a/homeassistant/components/climacell/translations/el.json b/homeassistant/components/climacell/translations/el.json index 45ed5d8a722b4..91f38277ae365 100644 --- a/homeassistant/components/climacell/translations/el.json +++ b/homeassistant/components/climacell/translations/el.json @@ -19,7 +19,6 @@ "step": { "init": { "data": { - "forecast_types": "\u0395\u03af\u03b4\u03bf\u03c2/\u03b7 \u0394\u03b5\u03bb\u03c4\u03af\u03bf\u03c5/\u03c9\u03bd", "timestep": "\u039b\u03b5\u03c0\u03c4\u03ac \u03bc\u03b5\u03c4\u03b1\u03be\u03cd \u03c4\u03c9\u03bd \u03b4\u03b5\u03bb\u03c4\u03af\u03c9\u03bd NowCast" }, "description": "\u0395\u03ac\u03bd \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03bf\u03bd\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1 \u03b4\u03b5\u03bb\u03c4\u03af\u03c9\u03bd 'nowcast', \u03bc\u03c0\u03bf\u03c1\u03b5\u03af\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc \u03c4\u03c9\u03bd \u03bb\u03b5\u03c0\u03c4\u03ce\u03bd \u03bc\u03b5\u03c4\u03b1\u03be\u03cd \u03ba\u03ac\u03b8\u03b5 \u03b4\u03b5\u03bb\u03c4\u03af\u03bf\u03c5. \u039f \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc\u03c2 \u03c4\u03c9\u03bd \u03b4\u03b5\u03bb\u03c4\u03af\u03c9\u03bd \u03c0\u03bf\u03c5 \u03c0\u03b1\u03c1\u03ad\u03c7\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b5\u03be\u03b1\u03c1\u03c4\u03ac\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc \u03c4\u03c9\u03bd \u03bb\u03b5\u03c0\u03c4\u03ce\u03bd \u03c0\u03bf\u03c5 \u03b5\u03c0\u03b9\u03bb\u03ad\u03b3\u03bf\u03bd\u03c4\u03b1\u03b9 \u03bc\u03b5\u03c4\u03b1\u03be\u03cd \u03c4\u03c9\u03bd \u03b4\u03b5\u03bb\u03c4\u03af\u03c9\u03bd.", diff --git a/homeassistant/components/climacell/translations/en.json b/homeassistant/components/climacell/translations/en.json index c126cf170b131..3e5cd436ba838 100644 --- a/homeassistant/components/climacell/translations/en.json +++ b/homeassistant/components/climacell/translations/en.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Forecast Type(s)", "timestep": "Min. Between NowCast Forecasts" }, "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", diff --git a/homeassistant/components/climacell/translations/es-419.json b/homeassistant/components/climacell/translations/es-419.json new file mode 100644 index 0000000000000..deb60db2004c2 --- /dev/null +++ b/homeassistant/components/climacell/translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "rate_limited": "Actualmente la tarifa est\u00e1 limitada. Vuelve a intentarlo m\u00e1s tarde." + }, + "step": { + "user": { + "data": { + "api_version": "Versi\u00f3n de la API" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. entre pron\u00f3sticos de NowCast" + }, + "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/es.json b/homeassistant/components/climacell/translations/es.json index ec3bfd1596785..4e709f03ad161 100644 --- a/homeassistant/components/climacell/translations/es.json +++ b/homeassistant/components/climacell/translations/es.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Tipo(s) de pron\u00f3stico", "timestep": "Min. Entre pron\u00f3sticos de NowCast" }, "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos.", diff --git a/homeassistant/components/climacell/translations/et.json b/homeassistant/components/climacell/translations/et.json index 4e9cec722ef07..46ac184fa3c86 100644 --- a/homeassistant/components/climacell/translations/et.json +++ b/homeassistant/components/climacell/translations/et.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Prognoosi t\u00fc\u00fcp (t\u00fc\u00fcbid)", "timestep": "Minuteid NowCasti prognooside vahel" }, "description": "Kui otsustad lubada \"nowcast\" prognoosi\u00fcksuse, saad seadistada minutite arvu iga prognoosi vahel. Esitatavate prognooside arv s\u00f5ltub prognooside vahel valitud minutite arvust.", diff --git a/homeassistant/components/climacell/translations/fr.json b/homeassistant/components/climacell/translations/fr.json index c0e8d5b88a487..89e3f43be56db 100644 --- a/homeassistant/components/climacell/translations/fr.json +++ b/homeassistant/components/climacell/translations/fr.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Type(s) de pr\u00e9vision", "timestep": "Min. Entre les pr\u00e9visions NowCast" }, "description": "Si vous choisissez d'activer l'entit\u00e9 de pr\u00e9vision \u00abnowcast\u00bb, vous pouvez configurer le nombre de minutes entre chaque pr\u00e9vision. Le nombre de pr\u00e9visions fournies d\u00e9pend du nombre de minutes choisies entre les pr\u00e9visions.", diff --git a/homeassistant/components/climacell/translations/he.json b/homeassistant/components/climacell/translations/he.json index 81a4b5c1fce61..b663a5e0a0f53 100644 --- a/homeassistant/components/climacell/translations/he.json +++ b/homeassistant/components/climacell/translations/he.json @@ -1,12 +1,15 @@ { "config": { "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "user": { "data": { "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "api_version": "\u05d2\u05e8\u05e1\u05ea API", "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", "name": "\u05e9\u05dd" diff --git a/homeassistant/components/climacell/translations/hu.json b/homeassistant/components/climacell/translations/hu.json index 6d97a51b530d6..3454a48945559 100644 --- a/homeassistant/components/climacell/translations/hu.json +++ b/homeassistant/components/climacell/translations/hu.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", + "rate_limited": "Jelenleg korl\u00e1tozott sebess\u00e9g\u0171, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg k\u00e9s\u0151bb \u00fajra.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { @@ -14,13 +15,17 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, - "description": "Ha a Sz\u00e9less\u00e9g \u00e9s Hossz\u00fas\u00e1g nincs megadva, akkor a Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban l\u00e9v\u0151 alap\u00e9rtelmezett \u00e9rt\u00e9keket fogjuk haszn\u00e1lni. Minden el\u0151rejelz\u00e9si t\u00edpushoz l\u00e9trej\u00f6n egy entit\u00e1s, de alap\u00e9rtelmez\u00e9s szerint csak az \u00e1ltalad kiv\u00e1lasztottak lesznek enged\u00e9lyezve." + "description": "Ha a Sz\u00e9less\u00e9g \u00e9s Hossz\u00fas\u00e1g nincs megadva, akkor a Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban l\u00e9v\u0151 alap\u00e9rtelmezett \u00e9rt\u00e9keket fogjuk haszn\u00e1lni. Minden el\u0151rejelz\u00e9si t\u00edpushoz l\u00e9trej\u00f6n egy entit\u00e1s, de alap\u00e9rtelmez\u00e9s szerint csak az \u00d6n \u00e1ltal kiv\u00e1lasztottak lesznek enged\u00e9lyezve." } } }, "options": { "step": { "init": { + "data": { + "timestep": "Min. A NowCast el\u0151rejelz\u00e9sek k\u00f6z\u00f6tt" + }, + "description": "Ha a `nowcast` el\u0151rejelz\u00e9si entit\u00e1s enged\u00e9lyez\u00e9s\u00e9t v\u00e1lasztja, be\u00e1ll\u00edthatja az egyes el\u0151rejelz\u00e9sek k\u00f6z\u00f6tti percek sz\u00e1m\u00e1t. A megadott el\u0151rejelz\u00e9sek sz\u00e1ma az el\u0151rejelz\u00e9sek k\u00f6z\u00f6tt kiv\u00e1lasztott percek sz\u00e1m\u00e1t\u00f3l f\u00fcgg.", "title": "Friss\u00edtse a ClimaCell be\u00e1ll\u00edt\u00e1sokat" } } diff --git a/homeassistant/components/climacell/translations/id.json b/homeassistant/components/climacell/translations/id.json index b9f8c4ea9817d..88b377261bb67 100644 --- a/homeassistant/components/climacell/translations/id.json +++ b/homeassistant/components/climacell/translations/id.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Jenis Prakiraan", "timestep": "Jarak Interval Prakiraan NowCast dalam Menit" }, "description": "Jika Anda memilih untuk mengaktifkan entitas prakiraan 'nowcast', Anda dapat mengonfigurasi jarak interval prakiraan dalam menit. Jumlah prakiraan yang diberikan tergantung pada nilai interval yang dipilih.", diff --git a/homeassistant/components/climacell/translations/it.json b/homeassistant/components/climacell/translations/it.json index bbd8e33d30502..bd1bdd8823806 100644 --- a/homeassistant/components/climacell/translations/it.json +++ b/homeassistant/components/climacell/translations/it.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Tipo(i) di previsione", "timestep": "Minuti tra le previsioni di NowCast" }, "description": "Se scegli di abilitare l'entit\u00e0 di previsione `nowcast`, puoi configurare il numero di minuti tra ogni previsione. Il numero di previsioni fornite dipende dal numero di minuti scelti tra le previsioni.", diff --git a/homeassistant/components/climacell/translations/ja.json b/homeassistant/components/climacell/translations/ja.json new file mode 100644 index 0000000000000..5114f8e988191 --- /dev/null +++ b/homeassistant/components/climacell/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "rate_limited": "\u73fe\u5728\u30ec\u30fc\u30c8\u304c\u5236\u9650\u3055\u308c\u3066\u3044\u307e\u3059\u306e\u3067\u3001\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "api_version": "API\u30d0\u30fc\u30b8\u30e7\u30f3", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "\u7def\u5ea6\u3068\u7d4c\u5ea6\u304c\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001Home Assistant\u8a2d\u5b9a\u306e\u65e2\u5b9a\u5024\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306f\u4e88\u6e2c\u30bf\u30a4\u30d7\u3054\u3068\u306b\u4f5c\u6210\u3055\u308c\u307e\u3059\u304c\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u9078\u629e\u3057\u305f\u3082\u306e\u3060\u3051\u304c\u6709\u52b9\u306b\u306a\u308a\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "\u6700\u5c0f: NowCast Forecasts\u306e\u9593" + }, + "description": "`nowcast` forecast(\u4e88\u6e2c) \u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u6709\u52b9\u306b\u3059\u308b\u3053\u3068\u3092\u9078\u629e\u3057\u305f\u5834\u5408\u3001\u5404\u4e88\u6e2c\u9593\u306e\u5206\u6570\u3092\u8a2d\u5b9a\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002\u63d0\u4f9b\u3055\u308c\u308bforecast(\u4e88\u6e2c)\u306e\u6570\u306f\u3001forecast(\u4e88\u6e2c)\u306e\u9593\u306b\u9078\u629e\u3057\u305f\u5206\u6570\u306b\u4f9d\u5b58\u3057\u307e\u3059\u3002", + "title": "ClimaCell\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u66f4\u65b0\u3057\u307e\u3059" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/ko.json b/homeassistant/components/climacell/translations/ko.json index 901fd429b1a04..b5936bbc7d746 100644 --- a/homeassistant/components/climacell/translations/ko.json +++ b/homeassistant/components/climacell/translations/ko.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "\uc77c\uae30\uc608\ubcf4 \uc720\ud615", "timestep": "\ub2e8\uae30\uc608\uce21 \uc77c\uae30\uc608\ubcf4 \uac04 \ucd5c\uc18c \uc2dc\uac04" }, "description": "`nowcast` \uc77c\uae30\uc608\ubcf4 \uad6c\uc131\uc694\uc18c\ub97c \uc0ac\uc6a9\ud558\ub3c4\ub85d \uc120\ud0dd\ud55c \uacbd\uc6b0 \uac01 \uc77c\uae30\uc608\ubcf4 \uc0ac\uc774\uc758 \uc2dc\uac04(\ubd84)\uc744 \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc81c\uacf5\ub41c \uc77c\uae30\uc608\ubcf4 \ud69f\uc218\ub294 \uc608\uce21 \uac04 \uc120\ud0dd\ud55c \uc2dc\uac04(\ubd84)\uc5d0 \ub530\ub77c \ub2ec\ub77c\uc9d1\ub2c8\ub2e4.", diff --git a/homeassistant/components/climacell/translations/nl.json b/homeassistant/components/climacell/translations/nl.json index 925300c089dfc..a8754e8194314 100644 --- a/homeassistant/components/climacell/translations/nl.json +++ b/homeassistant/components/climacell/translations/nl.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Voorspellingstype(n)", "timestep": "Min. Tussen NowCast-voorspellingen" }, "description": "Als u ervoor kiest om de `nowcast` voorspellingsentiteit in te schakelen, kan u het aantal minuten tussen elke voorspelling configureren. Het aantal voorspellingen hangt af van het aantal gekozen minuten tussen de voorspellingen.", diff --git a/homeassistant/components/climacell/translations/no.json b/homeassistant/components/climacell/translations/no.json index 2aad790060717..b9994be7a8685 100644 --- a/homeassistant/components/climacell/translations/no.json +++ b/homeassistant/components/climacell/translations/no.json @@ -15,7 +15,7 @@ "longitude": "Lengdegrad", "name": "Navn" }, - "description": "Hvis Breddegrad og Lengdegrad ikke er oppgitt, vil standardverdiene i Home Assistant-konfigurasjonen bli brukt. Det blir opprettet en enhet for hver prognosetype, men bare de du velger blir aktivert som standard." + "description": "Hvis Breddegrad og Lengdegrad ikke er oppgitt, vil standardverdiene i Home Assistant-konfigurasjonen bli brukt. Det blir opprettet en entitet for hver prognosetype, men bare de du velger blir aktivert som standard." } } }, @@ -23,10 +23,9 @@ "step": { "init": { "data": { - "forecast_types": "Prognosetype(r)", "timestep": "Min. mellom NowCast prognoser" }, - "description": "Hvis du velger \u00e5 aktivere \u00abnowcast\u00bb -varselenheten, kan du konfigurere antall minutter mellom hver prognose. Antall angitte prognoser avhenger av antall minutter som er valgt mellom prognosene.", + "description": "Hvis du velger \u00e5 aktivere \u00abnowcast\u00bb -varselentiteten, kan du konfigurere antall minutter mellom hver prognose. Antall angitte prognoser avhenger av antall minutter som er valgt mellom prognosene.", "title": "Oppdater ClimaCell Alternativer" } } diff --git a/homeassistant/components/climacell/translations/pl.json b/homeassistant/components/climacell/translations/pl.json index 6c8bad0f57a40..dc08cb9b1bfcd 100644 --- a/homeassistant/components/climacell/translations/pl.json +++ b/homeassistant/components/climacell/translations/pl.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "Typ(y) prognozy", "timestep": "Minuty pomi\u0119dzy prognozami" }, "description": "Je\u015bli zdecydujesz si\u0119 w\u0142\u0105czy\u0107 encj\u0119 prognozy \u201enowcast\u201d, mo\u017cesz skonfigurowa\u0107 liczb\u0119 minut mi\u0119dzy ka\u017cd\u0105 prognoz\u0105. Liczba dostarczonych prognoz zale\u017cy od liczby minut wybranych mi\u0119dzy prognozami.", diff --git a/homeassistant/components/climacell/translations/ru.json b/homeassistant/components/climacell/translations/ru.json index 7e40c61911204..0f1a80b5e0902 100644 --- a/homeassistant/components/climacell/translations/ru.json +++ b/homeassistant/components/climacell/translations/ru.json @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "\u0422\u0438\u043f(\u044b) \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430", "timestep": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f (\u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445)" }, "description": "\u0415\u0441\u043b\u0438 \u0412\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0435\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 'nowcast', \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430.", diff --git a/homeassistant/components/climacell/translations/tr.json b/homeassistant/components/climacell/translations/tr.json new file mode 100644 index 0000000000000..3481e0d61b145 --- /dev/null +++ b/homeassistant/components/climacell/translations/tr.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "rate_limited": "\u015eu anda oran s\u0131n\u0131rl\u0131, l\u00fctfen daha sonra tekrar deneyin.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "api_version": "API S\u00fcr\u00fcm\u00fc", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Enlem ve Boylam sa\u011flanmazsa, Home Assistant yap\u0131land\u0131rmas\u0131ndaki varsay\u0131lan de\u011ferler kullan\u0131l\u0131r. Her tahmin t\u00fcr\u00fc i\u00e7in bir varl\u0131k olu\u015fturulacak, ancak varsay\u0131lan olarak yaln\u0131zca se\u00e7tikleriniz etkinle\u015ftirilecektir." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. NowCast Tahminleri Aras\u0131nda" + }, + "description": "'Nowcast' tahmin varl\u0131\u011f\u0131n\u0131 etkinle\u015ftirmeyi se\u00e7erseniz, her tahmin aras\u0131ndaki dakika say\u0131s\u0131n\u0131 yap\u0131land\u0131rabilirsiniz. Sa\u011flanan tahmin say\u0131s\u0131, tahminler aras\u0131nda se\u00e7ilen dakika say\u0131s\u0131na ba\u011fl\u0131d\u0131r.", + "title": "ClimaCell Se\u00e7eneklerini G\u00fcncelle" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/zh-Hant.json b/homeassistant/components/climacell/translations/zh-Hant.json index 710759b954cbc..5ef7396b0e501 100644 --- a/homeassistant/components/climacell/translations/zh-Hant.json +++ b/homeassistant/components/climacell/translations/zh-Hant.json @@ -2,14 +2,14 @@ "config": { "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "invalid_api_key": "API \u91d1\u9470\u7121\u6548", "rate_limited": "\u9054\u5230\u9650\u5236\u983b\u7387\u3001\u8acb\u7a0d\u5019\u518d\u8a66\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "api_version": "API \u7248\u672c", "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6", @@ -23,7 +23,6 @@ "step": { "init": { "data": { - "forecast_types": "\u9810\u5831\u985e\u578b", "timestep": "NowCast \u9810\u5831\u9593\u9694\u5206\u9418" }, "description": "\u5047\u5982\u9078\u64c7\u958b\u555f `nowcast` \u9810\u5831\u5be6\u9ad4\u3001\u5c07\u53ef\u4ee5\u8a2d\u5b9a\u9810\u5831\u983b\u7387\u9593\u9694\u5206\u9418\u6578\u3002\u6839\u64da\u6240\u8f38\u5165\u7684\u9593\u9694\u6642\u9593\u5c07\u6c7a\u5b9a\u9810\u5831\u7684\u6578\u76ee\u3002", diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index 1b3b50e856613..eafb47aac9943 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -4,8 +4,7 @@ from abc import abstractmethod from collections.abc import Mapping from datetime import datetime -import logging -from typing import Any +from typing import Any, cast from pyclimacell.const import ( CURRENT, @@ -38,6 +37,8 @@ LENGTH_MILES, PRESSURE_HPA, PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant @@ -46,6 +47,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as distance_convert from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.speed import convert as speed_convert from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( @@ -94,8 +96,6 @@ MAX_FORECASTS, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -109,7 +109,7 @@ async def async_setup_entry( api_class = ClimaCellV3WeatherEntity if api_version == 3 else ClimaCellWeatherEntity entities = [ api_class(config_entry, coordinator, api_version, forecast_type) - for forecast_type in [DAILY, HOURLY, NOWCAST] + for forecast_type in (DAILY, HOURLY, NOWCAST) ] async_add_entities(entities) @@ -127,29 +127,16 @@ def __init__( """Initialize ClimaCell Weather Entity.""" super().__init__(config_entry, coordinator, api_version) self.forecast_type = forecast_type - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - if self.forecast_type == DEFAULT_FORECAST_TYPE: - return True - - return False - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"{self._config_entry.data[CONF_NAME]} - {self.forecast_type.title()}" - - @property - def unique_id(self) -> str: - """Return the unique id of the entity.""" - return f"{self._config_entry.unique_id}_{self.forecast_type}" + self._attr_entity_registry_enabled_default = ( + forecast_type == DEFAULT_FORECAST_TYPE + ) + self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_unique_id = f"{config_entry.unique_id}_{forecast_type}" @staticmethod @abstractmethod def _translate_condition( - condition: int | None, sun_is_up: bool = True + condition: str | int | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" @@ -157,7 +144,7 @@ def _forecast_dict( self, forecast_dt: datetime, use_datetime: bool, - condition: str, + condition: int | str, precipitation: float | None, precipitation_probability: float | None, temp: float | None, @@ -182,7 +169,10 @@ def _forecast_dict( ) if wind_speed: wind_speed = round( - distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) data = { @@ -204,11 +194,12 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: wind_gust = self.wind_gust if wind_gust and self.hass.config.units.is_metric: wind_gust = round( - distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + self.wind_gust, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) cloud_cover = self.cloud_cover - if cloud_cover is not None: - cloud_cover /= 100 return { ATTR_CLOUD_COVER: cloud_cover, ATTR_WIND_GUST: wind_gust, @@ -254,7 +245,10 @@ def wind_speed(self): """Return the wind speed.""" if self.hass.config.units.is_metric and self._wind_speed: return round( - distance_convert(self._wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + self._wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) return self._wind_speed @@ -276,9 +270,11 @@ def visibility(self): class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v4 API to retrieve weather data.""" + _attr_temperature_unit = TEMP_FAHRENHEIT + @staticmethod def _translate_condition( - condition: int | None, sun_is_up: bool = True + condition: int | str | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" if condition is None: @@ -296,11 +292,6 @@ def temperature(self): """Return the platform temperature.""" return self._get_current_property(CC_ATTR_TEMPERATURE) - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - @property def _pressure(self): """Return the raw pressure.""" @@ -386,12 +377,13 @@ def forecast(self): precipitation_probability = values.get(CC_ATTR_PRECIPITATION_PROBABILITY) temp = values.get(CC_ATTR_TEMPERATURE_HIGH) - temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) + temp_low = None wind_direction = values.get(CC_ATTR_WIND_DIRECTION) wind_speed = values.get(CC_ATTR_WIND_SPEED) if self.forecast_type == DAILY: use_datetime = False + temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) if precipitation: precipitation = precipitation * 24 elif self.forecast_type == NOWCAST: @@ -426,13 +418,16 @@ def forecast(self): class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v3 API to retrieve weather data.""" + _attr_temperature_unit = TEMP_FAHRENHEIT + @staticmethod def _translate_condition( - condition: str | None, sun_is_up: bool = True + condition: int | str | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" if not condition: return None + condition = cast(str, condition) if "clear" in condition.lower(): if sun_is_up: return CLEAR_CONDITIONS["day"] @@ -446,11 +441,6 @@ def temperature(self): self.coordinator.data[CURRENT], CC_V3_ATTR_TEMPERATURE ) - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - @property def _pressure(self): """Return the raw pressure.""" diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 30842f1fe23cc..ae1c434d34273 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,7 +1,7 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations -from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -9,6 +9,7 @@ import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_TENTHS, @@ -19,17 +20,17 @@ STATE_ON, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp -from homeassistant.helpers.typing import ConfigType, ServiceDataType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.temperature import convert as convert_temperature from .const import ( @@ -157,19 +158,52 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass: HomeAssistant, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class ClimateEntityDescription(EntityDescription): + """A class that describes climate entities.""" class ClimateEntity(Entity): """Base class for climate entities.""" + entity_description: ClimateEntityDescription + _attr_current_humidity: int | None = None + _attr_current_temperature: float | None = None + _attr_fan_mode: str | None + _attr_fan_modes: list[str] | None + _attr_hvac_action: str | None = None + _attr_hvac_mode: str + _attr_hvac_modes: list[str] + _attr_is_aux_heat: bool | None + _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY + _attr_max_temp: float + _attr_min_humidity: int = DEFAULT_MIN_HUMIDITY + _attr_min_temp: float + _attr_precision: float + _attr_preset_mode: str | None + _attr_preset_modes: list[str] | None + _attr_supported_features: int + _attr_swing_mode: str | None + _attr_swing_modes: list[str] | None + _attr_target_humidity: int | None = None + _attr_target_temperature_high: float | None + _attr_target_temperature_low: float | None + _attr_target_temperature_step: float | None = None + _attr_target_temperature: float | None = None + _attr_temperature_unit: str + @property def state(self) -> str: """Return the current state.""" @@ -178,6 +212,8 @@ def state(self) -> str: @property def precision(self) -> float: """Return the precision of the system.""" + if hasattr(self, "_attr_precision"): + return self._attr_precision if self.hass.config.units.temperature_unit == TEMP_CELSIUS: return PRECISION_TENTHS return PRECISION_WHOLE @@ -219,7 +255,7 @@ def capability_attributes(self) -> dict[str, Any] | None: def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" supported_features = self.supported_features - data = { + data: dict[str, str | float | None] = { ATTR_CURRENT_TEMPERATURE: show_temp( self.hass, self.current_temperature, @@ -276,33 +312,33 @@ def state_attributes(self) -> dict[str, Any]: @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - raise NotImplementedError() + return self._attr_temperature_unit @property def current_humidity(self) -> int | None: """Return the current humidity.""" - return None + return self._attr_current_humidity @property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" - return None + return self._attr_target_humidity @property - @abstractmethod def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode. Need to be one of HVAC_MODE_*. """ + return self._attr_hvac_mode @property - @abstractmethod def hvac_modes(self) -> list[str]: """Return the list of available hvac operation modes. Need to be a subset of HVAC_MODES. """ + return self._attr_hvac_modes @property def hvac_action(self) -> str | None: @@ -310,22 +346,22 @@ def hvac_action(self) -> str | None: Need to be one of CURRENT_HVAC_*. """ - return None + return self._attr_hvac_action @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return None + return self._attr_current_temperature @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return None + return self._attr_target_temperature @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - return None + return self._attr_target_temperature_step @property def target_temperature_high(self) -> float | None: @@ -333,7 +369,7 @@ def target_temperature_high(self) -> float | None: Requires SUPPORT_TARGET_TEMPERATURE_RANGE. """ - raise NotImplementedError + return self._attr_target_temperature_high @property def target_temperature_low(self) -> float | None: @@ -341,7 +377,7 @@ def target_temperature_low(self) -> float | None: Requires SUPPORT_TARGET_TEMPERATURE_RANGE. """ - raise NotImplementedError + return self._attr_target_temperature_low @property def preset_mode(self) -> str | None: @@ -349,7 +385,7 @@ def preset_mode(self) -> str | None: Requires SUPPORT_PRESET_MODE. """ - raise NotImplementedError + return self._attr_preset_mode @property def preset_modes(self) -> list[str] | None: @@ -357,7 +393,7 @@ def preset_modes(self) -> list[str] | None: Requires SUPPORT_PRESET_MODE. """ - raise NotImplementedError + return self._attr_preset_modes @property def is_aux_heat(self) -> bool | None: @@ -365,7 +401,7 @@ def is_aux_heat(self) -> bool | None: Requires SUPPORT_AUX_HEAT. """ - raise NotImplementedError + return self._attr_is_aux_heat @property def fan_mode(self) -> str | None: @@ -373,7 +409,7 @@ def fan_mode(self) -> str | None: Requires SUPPORT_FAN_MODE. """ - raise NotImplementedError + return self._attr_fan_mode @property def fan_modes(self) -> list[str] | None: @@ -381,7 +417,7 @@ def fan_modes(self) -> list[str] | None: Requires SUPPORT_FAN_MODE. """ - raise NotImplementedError + return self._attr_fan_modes @property def swing_mode(self) -> str | None: @@ -389,7 +425,7 @@ def swing_mode(self) -> str | None: Requires SUPPORT_SWING_MODE. """ - raise NotImplementedError + return self._attr_swing_mode @property def swing_modes(self) -> list[str] | None: @@ -397,7 +433,7 @@ def swing_modes(self) -> list[str] | None: Requires SUPPORT_SWING_MODE. """ - raise NotImplementedError + return self._attr_swing_modes def set_temperature(self, **kwargs) -> None: """Set new target temperature.""" @@ -468,8 +504,7 @@ async def async_turn_aux_heat_off(self) -> None: async def async_turn_on(self) -> None: """Turn the entity on.""" if hasattr(self, "turn_on"): - # pylint: disable=no-member - await self.hass.async_add_executor_job(self.turn_on) + await self.hass.async_add_executor_job(self.turn_on) # type: ignore[attr-defined] return # Fake turn on @@ -482,8 +517,7 @@ async def async_turn_on(self) -> None: async def async_turn_off(self) -> None: """Turn the entity off.""" if hasattr(self, "turn_off"): - # pylint: disable=no-member - await self.hass.async_add_executor_job(self.turn_off) + await self.hass.async_add_executor_job(self.turn_off) # type: ignore[attr-defined] return # Fake turn off @@ -493,51 +527,55 @@ async def async_turn_off(self) -> None: @property def supported_features(self) -> int: """Return the list of supported features.""" - raise NotImplementedError() + return self._attr_supported_features @property def min_temp(self) -> float: """Return the minimum temperature.""" - return convert_temperature( - DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit - ) + if not hasattr(self, "_attr_min_temp"): + return convert_temperature( + DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit + ) + return self._attr_min_temp @property def max_temp(self) -> float: """Return the maximum temperature.""" - return convert_temperature( - DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit - ) + if not hasattr(self, "_attr_max_temp"): + return convert_temperature( + DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit + ) + return self._attr_max_temp @property def min_humidity(self) -> int: """Return the minimum humidity.""" - return DEFAULT_MIN_HUMIDITY + return self._attr_min_humidity @property def max_humidity(self) -> int: """Return the maximum humidity.""" - return DEFAULT_MAX_HUMIDITY + return self._attr_max_humidity async def async_service_aux_heat( - entity: ClimateEntity, service: ServiceDataType + entity: ClimateEntity, service_call: ServiceCall ) -> None: """Handle aux heat service.""" - if service.data[ATTR_AUX_HEAT]: + if service_call.data[ATTR_AUX_HEAT]: await entity.async_turn_aux_heat_on() else: await entity.async_turn_aux_heat_off() async def async_service_temperature_set( - entity: ClimateEntity, service: ServiceDataType + entity: ClimateEntity, service_call: ServiceCall ) -> None: """Handle set temperature service.""" hass = entity.hass kwargs = {} - for value, temp in service.data.items(): + for value, temp in service_call.data.items(): if value in CONVERTIBLE_ATTRIBUTE: kwargs[value] = convert_temperature( temp, hass.config.units.temperature_unit, entity.temperature_unit @@ -546,15 +584,3 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) - - -class ClimateDevice(ClimateEntity): - """Representation of a climate entity (for backwards compatibility).""" - - def __init_subclass__(cls, **kwargs): - """Print deprecation warning.""" - super().__init_subclass__(**kwargs) - _LOGGER.warning( - "ClimateDevice is deprecated, modify %s to extend ClimateEntity", - cls.__name__, - ) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index af6c9364b18a5..773ee5920dacd 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -63,12 +63,14 @@ FAN_LOW = "low" FAN_MEDIUM = "medium" FAN_HIGH = "high" +FAN_TOP = "top" FAN_MIDDLE = "middle" FAN_FOCUS = "focus" FAN_DIFFUSE = "diffuse" # Possible swing state +SWING_ON = "on" SWING_OFF = "off" SWING_BOTH = "both" SWING_VERTICAL = "vertical" diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 02474a47f96ea..34217e8872de6 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -5,15 +5,15 @@ from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, HomeAssistantError from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import get_capability, get_supported_features from . import DOMAIN, const @@ -38,7 +38,9 @@ ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Climate devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] @@ -48,29 +50,17 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) + supported_features = get_supported_features(hass, entry.entity_id) - # We need a state or else we can't populate the HVAC and preset modes. - if state is None: - continue + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "set_hvac_mode", - } - ) - if state.attributes[ATTR_SUPPORTED_FEATURES] & const.SUPPORT_PRESET_MODE: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "set_preset_mode", - } - ) + actions.append({**base_action, CONF_TYPE: "set_hvac_mode"}) + if supported_features & const.SUPPORT_PRESET_MODE: + actions.append({**base_action, CONF_TYPE: "set_preset_mode"}) return actions @@ -95,18 +85,26 @@ async def async_call_action_from_config( async def async_get_action_capabilities(hass, config): """List action capabilities.""" - state = hass.states.get(config[CONF_ENTITY_ID]) action_type = config[CONF_TYPE] fields = {} if action_type == "set_hvac_mode": - hvac_modes = state.attributes[const.ATTR_HVAC_MODES] if state else [] + try: + hvac_modes = ( + get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_HVAC_MODES) + or [] + ) + except HomeAssistantError: + hvac_modes = [] fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) elif action_type == "set_preset_mode": - if state: - preset_modes = state.attributes.get(const.ATTR_PRESET_MODES, []) - else: + try: + preset_modes = ( + get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_PRESET_MODES) + or [] + ) + except HomeAssistantError: preset_modes = [] fields[vol.Required(const.ATTR_PRESET_MODE)] = vol.In(preset_modes) diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index d20c202e93bc6..f3e01b5a387c5 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -5,16 +5,16 @@ from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, HomeAssistantError, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN, const @@ -52,43 +52,28 @@ async def async_get_conditions( if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) - - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_hvac_mode", - } - ) - - if ( - state - and state.attributes[ATTR_SUPPORTED_FEATURES] & const.SUPPORT_PRESET_MODE - ): - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_preset_mode", - } - ) + supported_features = get_supported_features(hass, entry.entity_id) + + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + + conditions.append({**base_condition, CONF_TYPE: "is_hvac_mode"}) + + if supported_features & const.SUPPORT_PRESET_MODE: + conditions.append({**base_condition, CONF_TYPE: "is_preset_mode"}) return conditions @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_hvac_mode": attribute = const.ATTR_HVAC_MODE else: @@ -97,28 +82,35 @@ def async_condition_from_config( def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" state = hass.states.get(config[ATTR_ENTITY_ID]) - return state and state.attributes.get(attribute) == config[attribute] + return state.attributes.get(attribute) == config[attribute] if state else False return test_is_state async def async_get_condition_capabilities(hass, config): """List condition capabilities.""" - state = hass.states.get(config[CONF_ENTITY_ID]) condition_type = config[CONF_TYPE] fields = {} if condition_type == "is_hvac_mode": - hvac_modes = state.attributes[const.ATTR_HVAC_MODES] if state else [] + try: + hvac_modes = ( + get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_HVAC_MODES) + or [] + ) + except HomeAssistantError: + hvac_modes = [] fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) elif condition_type == "is_preset_mode": - if state: - preset_modes = state.attributes.get(const.ATTR_PRESET_MODES, []) - else: + try: + preset_modes = ( + get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_PRESET_MODES) + or [] + ) + except HomeAssistantError: preset_modes = [] - fields[vol.Required(const.ATTR_PRESET_MODE)] = vol.In(preset_modes) return {"extra_fields": vol.Schema(fields)} diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index df925463d4c07..6bd6f4c3e0278 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -1,10 +1,15 @@ """Provides device automations for Climate.""" from __future__ import annotations +from typing import Any + import voluptuous as vol -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, state as state_trigger, @@ -32,7 +37,7 @@ "hvac_mode_changed", } -HVAC_MODE_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +HVAC_MODE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): "hvac_mode_changed", @@ -41,7 +46,7 @@ ) CURRENT_TRIGGER_SCHEMA = vol.All( - TRIGGER_BASE_SCHEMA.extend( + DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In( @@ -58,7 +63,9 @@ TRIGGER_SCHEMA = vol.Any(HVAC_MODE_TRIGGER_SCHEMA, CURRENT_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Climate devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -108,12 +115,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_type = config[CONF_TYPE] - - if trigger_type == "hvac_mode_changed": + if (trigger_type := config[CONF_TYPE]) == "hvac_mode_changed": state_config = { state_trigger.CONF_PLATFORM: "state", state_trigger.CONF_ENTITY_ID: config[CONF_ENTITY_ID], @@ -126,7 +131,9 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config( + hass, state_config + ) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) @@ -152,18 +159,22 @@ async def async_attach_trigger( if CONF_FOR in config: numeric_state_config[CONF_FOR] = config[CONF_FOR] - numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA(numeric_state_config) + numeric_state_config = await numeric_state_trigger.async_validate_trigger_config( + hass, numeric_state_config + ) return await numeric_state_trigger.async_attach_trigger( hass, numeric_state_config, action, automation_info, platform_type="device" ) -async def async_get_trigger_capabilities(hass: HomeAssistant, config): +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" trigger_type = config[CONF_TYPE] if trigger_type == "hvac_action_changed": - return None + return {} if trigger_type == "hvac_mode_changed": return { diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 767a38b2e5744..f7e63f475ea88 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -41,8 +41,8 @@ async def call_service(service: str, keys: Iterable, data=None): data = data or {} data["entity_id"] = state.entity_id for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + if (value := state.attributes.get(key)) is not None: + data[key] = value await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index ca88896c6c251..7b9d7fe4a721a 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -4,12 +4,13 @@ set_aux_heat: name: Turn on/off auxiliary heater description: Turn auxiliary heater on/off for climate device. target: + entity: + domain: climate fields: aux_heat: name: Auxiliary heating description: New value of auxiliary heater. required: true - example: true selector: boolean: @@ -17,6 +18,8 @@ set_preset_mode: name: Set preset mode description: Set preset mode for climate device. target: + entity: + domain: climate fields: preset_mode: name: Preset mode @@ -30,11 +33,12 @@ set_temperature: name: Set temperature description: Set target temperature of climate device. target: + entity: + domain: climate fields: temperature: name: Temperature description: New target temperature for HVAC. - example: 25 selector: number: min: 0 @@ -45,7 +49,6 @@ set_temperature: name: Target temperature high description: New target high temperature for HVAC. advanced: true - example: 26 selector: number: min: 0 @@ -56,7 +59,6 @@ set_temperature: name: Target temperature low description: New target low temperature for HVAC. advanced: true - example: 20 selector: number: min: 0 @@ -66,7 +68,6 @@ set_temperature: hvac_mode: name: HVAC mode description: HVAC operation mode to set temperature to. - example: "heat" selector: select: options: @@ -82,24 +83,25 @@ set_humidity: name: Set target humidity description: Set target humidity of climate device. target: + entity: + domain: climate fields: humidity: name: Humidity description: New target humidity for climate device. required: true - example: 60 selector: number: min: 30 max: 99 - step: 1 unit_of_measurement: "%" - mode: slider set_fan_mode: name: Set fan mode description: Set fan operation for climate device. target: + entity: + domain: climate fields: fan_mode: name: Fan mode @@ -113,11 +115,12 @@ set_hvac_mode: name: Set HVAC mode description: Set HVAC operation mode for climate device. target: + entity: + domain: climate fields: hvac_mode: name: HVAC mode description: New value of operation mode. - example: "heat" selector: select: options: @@ -133,6 +136,8 @@ set_swing_mode: name: Set swing mode description: Set swing operation for climate device. target: + entity: + domain: climate fields: swing_mode: name: Swing mode @@ -146,8 +151,12 @@ turn_on: name: Turn on description: Turn climate device on. target: + entity: + domain: climate turn_off: name: Turn off description: Turn climate device off. target: + entity: + domain: climate diff --git a/homeassistant/components/climate/translations/he.json b/homeassistant/components/climate/translations/he.json index fe5380a0528b4..abf976c2b5b8f 100644 --- a/homeassistant/components/climate/translations/he.json +++ b/homeassistant/components/climate/translations/he.json @@ -1,8 +1,23 @@ { + "device_automation": { + "action_type": { + "set_hvac_mode": "\u05e9\u05e0\u05d4 \u05de\u05e6\u05d1 HVAC \u05d1-{entity_name}", + "set_preset_mode": "\u05e9\u05e0\u05d4 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05e7\u05d1\u05d5\u05e2\u05d4 \u05de\u05e8\u05d0\u05e9 \u05d1-{entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u05de\u05d5\u05d2\u05d3\u05e8 \u05dc\u05de\u05e6\u05d1 HVAC \u05e1\u05e4\u05e6\u05d9\u05e4\u05d9", + "is_preset_mode": "{entity_name} \u05de\u05d5\u05d2\u05d3\u05e8 \u05dc\u05de\u05e6\u05d1 \u05e1\u05e4\u05e6\u05d9\u05e4\u05d9 \u05d4\u05de\u05d5\u05d2\u05d3\u05e8 \u05de\u05e8\u05d0\u05e9" + }, + "trigger_type": { + "current_humidity_changed": "\u05d4\u05dc\u05d7\u05d5\u05ea \u05d4\u05e0\u05de\u05d3\u05d3\u05ea {entity_name} \u05d4\u05e9\u05ea\u05e0\u05ea\u05d4", + "current_temperature_changed": "\u05d4\u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4 \u05d4\u05e0\u05de\u05d3\u05d3\u05ea \u05e9\u05dc {entity_name} \u05d4\u05e9\u05ea\u05e0\u05ea\u05d4", + "hvac_mode_changed": "{entity_name} \u05de\u05de\u05e6\u05d1 HVAC \u05d4\u05e9\u05ea\u05e0\u05d4" + } + }, "state": { "_": { "auto": "\u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", - "cool": "\u05e7\u05e8\u05d5\u05e8", + "cool": "\u05e7\u05d9\u05e8\u05d5\u05e8", "dry": "\u05d9\u05d1\u05e9", "fan_only": "\u05de\u05d0\u05d5\u05d5\u05e8\u05e8 \u05d1\u05dc\u05d1\u05d3", "heat": "\u05d7\u05d9\u05de\u05d5\u05dd", @@ -10,5 +25,5 @@ "off": "\u05db\u05d1\u05d5\u05d9" } }, - "title": "\u05d0\u05b7\u05e7\u05dc\u05b4\u05d9\u05dd" + "title": "\u05d0\u05e7\u05dc\u05d9\u05dd" } \ No newline at end of file diff --git a/homeassistant/components/climate/translations/hu.json b/homeassistant/components/climate/translations/hu.json index 400c1af877d42..f0fe007c7c59e 100644 --- a/homeassistant/components/climate/translations/hu.json +++ b/homeassistant/components/climate/translations/hu.json @@ -2,11 +2,11 @@ "device_automation": { "action_type": { "set_hvac_mode": "F\u0171t\u00e9s- \u00e9s l\u00e9gtechnikai (HVAC) \u00fczemm\u00f3d m\u00f3dos\u00edt\u00e1sa a k\u00f6vetkez\u0151n: {entity_name}", - "set_preset_mode": "A(z) {entity_name} be\u00e1ll\u00edt\u00e1s\u00e1nak v\u00e1lt\u00e1sa" + "set_preset_mode": "{entity_name} \u00fczemm\u00f3dj\u00e1nak v\u00e1lt\u00e1sa" }, "condition_type": { "is_hvac_mode": "{entity_name} speci\u00e1lis f\u0171t\u00e9s, szell\u0151z\u00e9s \u00e9s l\u00e9gkondicion\u00e1l\u00e1s (HVAC) \u00fczemm\u00f3dra van be\u00e1ll\u00edtva", - "is_preset_mode": "A(z) {entity_name} el\u0151re be\u00e1ll\u00edtott m\u00f3dja van kiv\u00e1lasztva" + "is_preset_mode": "{entity_name} \u00fczemm\u00f3dja van kiv\u00e1lasztva" }, "trigger_type": { "current_humidity_changed": "{entity_name} m\u00e9rt p\u00e1ratartalma megv\u00e1ltozott", @@ -18,7 +18,7 @@ "_": { "auto": "Automatikus", "cool": "H\u0171t\u00e9s", - "dry": "Sz\u00e1raz", + "dry": "P\u00e1r\u00e1tlan\u00edt\u00e1s", "fan_only": "Csak ventil\u00e1tor", "heat": "F\u0171t\u00e9s", "heat_cool": "F\u0171t\u00e9s/H\u0171t\u00e9s", diff --git a/homeassistant/components/climate/translations/ja.json b/homeassistant/components/climate/translations/ja.json index 2d660b8dd5448..0c89bac48d89e 100644 --- a/homeassistant/components/climate/translations/ja.json +++ b/homeassistant/components/climate/translations/ja.json @@ -1,4 +1,19 @@ { + "device_automation": { + "action_type": { + "set_hvac_mode": "{entity_name} \u306eHVAC\u30e2\u30fc\u30c9\u3092\u5909\u66f4", + "set_preset_mode": "{entity_name} \u306e\u30d7\u30ea\u30bb\u30c3\u30c8\u3092\u5909\u66f4" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u306f\u7279\u5b9a\u306eHVAC\u30e2\u30fc\u30c9\u306b\u30bb\u30c3\u30c8\u3055\u308c\u3066\u3044\u307e\u3059", + "is_preset_mode": "{entity_name} \u306f\u7279\u5b9a\u306e\u30d7\u30ea\u30bb\u30c3\u30c8\u30e2\u30fc\u30c9\u306b\u30bb\u30c3\u30c8\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u6e2c\u5b9a\u6e7f\u5ea6\u304c\u5909\u5316\u3057\u307e\u3057\u305f", + "current_temperature_changed": "{entity_name} \u6e2c\u5b9a\u6e29\u5ea6\u304c\u5909\u5316\u3057\u307e\u3057\u305f", + "hvac_mode_changed": "{entity_name} HVAC\u30e2\u30fc\u30c9\u304c\u5909\u5316\u3057\u307e\u3057\u305f" + } + }, "state": { "_": { "auto": "\u30aa\u30fc\u30c8", @@ -6,7 +21,9 @@ "dry": "\u30c9\u30e9\u30a4", "fan_only": "\u30d5\u30a1\u30f3\u306e\u307f", "heat": "\u6696\u623f", + "heat_cool": "\u6696/\u51b7", "off": "\u30aa\u30d5" } - } + }, + "title": "\u6c17\u5019" } \ No newline at end of file diff --git a/homeassistant/components/climate/translations/tr.json b/homeassistant/components/climate/translations/tr.json index 201fec4c4b648..3e175e6f5988f 100644 --- a/homeassistant/components/climate/translations/tr.json +++ b/homeassistant/components/climate/translations/tr.json @@ -3,6 +3,15 @@ "action_type": { "set_hvac_mode": "{entity_name} \u00fczerinde HVAC modunu de\u011fi\u015ftir", "set_preset_mode": "{entity_name} \u00fczerindeki \u00f6n ayar\u0131 de\u011fi\u015ftir" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} , belirli bir HVAC moduna ayarland\u0131", + "is_preset_mode": "{entity_name} , belirli bir \u00f6n ayar moduna ayarland\u0131" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u00f6l\u00e7\u00fclen nem de\u011fi\u015fti", + "current_temperature_changed": "{entity_name} \u00f6l\u00e7\u00fclen s\u0131cakl\u0131k de\u011fi\u015fti", + "hvac_mode_changed": "{entity_name} HVAC modu de\u011fi\u015fti" } }, "state": { @@ -16,5 +25,5 @@ "off": "Kapal\u0131" } }, - "title": "\u0130klim" + "title": "\u0130klimlendirme" } \ No newline at end of file diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 038bc227fcdec..f99ac4c7b0ab3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,4 +1,6 @@ """Component to integrate the Home Assistant cloud.""" +import asyncio + from hass_nabucasa import Cloud import voluptuous as vol @@ -193,13 +195,13 @@ async def _shutdown(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + _remote_handle_prefs_updated(cloud) + async def _service_handler(service): """Handle service for cloud.""" if service.service == SERVICE_REMOTE_CONNECT: - await cloud.remote.connect() await prefs.async_update(remote_enabled=True) elif service.service == SERVICE_REMOTE_DISCONNECT: - await cloud.remote.disconnect() await prefs.async_update(remote_enabled=False) hass.helpers.service.async_register_admin_service( @@ -228,9 +230,32 @@ async def _on_connect(): cloud.iot.register_on_connect(_on_connect) - await cloud.start() + await cloud.initialize() await http_api.async_setup(hass) account_link.async_setup(hass) return True + + +@callback +def _remote_handle_prefs_updated(cloud: Cloud) -> None: + """Handle remote preferences updated.""" + cur_pref = cloud.client.prefs.remote_enabled + lock = asyncio.Lock() + + # Sync remote connection with prefs + async def remote_prefs_updated(prefs: CloudPreferences) -> None: + """Update remote status.""" + nonlocal cur_pref + + async with lock: + if prefs.remote_enabled == cur_pref: + return + + if cur_pref := prefs.remote_enabled: + await cloud.remote.connect() + else: + await cloud.remote.disconnect() + + cloud.client.prefs.async_listen_updates(remote_prefs_updated) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 4a3a2dd77f828..6dc0da82512bd 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -4,9 +4,10 @@ from typing import Any import aiohttp +from awesomeversion import AwesomeVersion from hass_nabucasa import account_link -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION +from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow, event @@ -16,6 +17,8 @@ CACHE_TIMEOUT = 3600 _LOGGER = logging.getLogger(__name__) +CURRENT_VERSION = AwesomeVersion(HA_VERSION) + @callback def async_setup(hass: HomeAssistant): @@ -30,48 +33,15 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): services = await _get_services(hass) for service in services: - if service["service"] == domain and _is_older(service["min_version"]): + if service["service"] == domain and CURRENT_VERSION >= service["min_version"]: return CloudOAuth2Implementation(hass, domain) return -@callback -def _is_older(version: str) -> bool: - """Test if a version is older than the current HA version.""" - version_parts = version.split(".") - - if len(version_parts) != 3: - return False - - try: - version_parts = [int(val) for val in version_parts] - except ValueError: - return False - - patch_number_str = "" - - for char in PATCH_VERSION: - if char.isnumeric(): - patch_number_str += char - else: - break - - try: - patch_number = int(patch_number_str) - except ValueError: - patch_number = 0 - - cur_version_parts = [MAJOR_VERSION, MINOR_VERSION, patch_number] - - return version_parts <= cur_version_parts - - async def _get_services(hass): """Get the available services.""" - services = hass.data.get(DATA_SERVICES) - - if services is not None: + if (services := hass.data.get(DATA_SERVICES)) is not None: return services try: @@ -94,7 +64,7 @@ def clear_services(_now): class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implementation): """Cloud implementation of the OAuth2 flow.""" - def __init__(self, hass: HomeAssistant, service: str): + def __init__(self, hass: HomeAssistant, service: str) -> None: """Initialize cloud OAuth2 implementation.""" self.hass = hass self.service = service @@ -143,6 +113,7 @@ async def async_resolve_external_data(self, external_data: Any) -> dict: async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" - return await account_link.async_fetch_access_token( + new_token = await account_link.async_fetch_access_token( self.hass.data[DOMAIN], self.service, token["refresh_token"] ) + return {**token, **new_token} diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 393bfdfc2cd2d..0d1bdf66c12b7 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -2,6 +2,7 @@ import asyncio from contextlib import suppress from datetime import timedelta +from http import HTTPStatus import logging import aiohttp @@ -15,9 +16,9 @@ errors as alexa_errors, state_report as alexa_state_report, ) -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_BAD_REQUEST +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, ENTITY_CATEGORIES from homeassistant.core import HomeAssistant, callback, split_entity_id -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -42,7 +43,7 @@ def __init__( cloud_user: str, prefs: CloudPreferences, cloud: Cloud, - ): + ) -> None: """Initialize the Alexa config.""" super().__init__(hass) self._config = config @@ -56,12 +57,6 @@ def __init__( 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.""" @@ -107,8 +102,18 @@ def user_identifier(self): async def async_initialize(self): """Initialize the Alexa config.""" - if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: - await async_setup_component(self.hass, ALEXA_DOMAIN, {}) + + async def hass_started(hass): + if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: + await async_setup_component(self.hass, ALEXA_DOMAIN, {}) + + start.async_at_start(self.hass, hass_started) + + self._prefs.async_listen_updates(self._async_prefs_updated) + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) def should_expose(self, entity_id): """If an entity should be exposed.""" @@ -124,13 +129,17 @@ def should_expose(self, entity_id): if entity_expose is not None: return entity_expose - default_expose = self._prefs.alexa_default_expose + entity_registry = er.async_get(self.hass) + if registry_entry := entity_registry.async_get(entity_id): + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES + else: + auxiliary_entity = False # Backwards compat - if default_expose is None: - return True + if (default_expose := self._prefs.alexa_default_expose) is None: + return not auxiliary_entity - return split_entity_id(entity_id)[0] in default_expose + return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose @callback def async_invalidate_access_token(self): @@ -145,7 +154,7 @@ async def async_get_access_token(self): resp = await cloud_api.async_alexa_access_token(self._cloud) body = await resp.json() - if resp.status == HTTP_BAD_REQUEST: + if resp.status == HTTPStatus.BAD_REQUEST: if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): if self.should_report_state: await self._prefs.async_update(alexa_report_state=False) @@ -167,6 +176,15 @@ async def async_get_access_token(self): async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" + if not self._cloud.is_logged_in: + if self.is_reporting_states: + await self.async_disable_proactive_mode() + + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + self._alexa_sync_unsub = None + return + if ALEXA_DOMAIN not in self.hass.config.components and self.enabled: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -295,7 +313,7 @@ async def _sync_helper(self, to_update, to_remove) -> bool: ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) return True @@ -327,7 +345,7 @@ async def _handle_entity_registry_updated(self, event): elif action == "remove": to_remove.append(entity_id) elif action == "update" and bool( - set(event.data["changes"]) & entity_registry.ENTITY_DESCRIBING_ATTRIBUTES + set(event.data["changes"]) & er.ENTITY_DESCRIBING_ATTRIBUTES ): to_update.append(entity_id) if "old_entity_id" in event.data: diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 0e3b20fa01159..b537c447120b8 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,10 +2,11 @@ import asyncio from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN @@ -24,41 +25,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class CloudRemoteBinary(BinarySensorEntity): """Representation of an Cloud Remote UI Connection binary sensor.""" + _attr_name = "Remote UI" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_should_poll = False + _attr_unique_id = "cloud-remote-ui-connectivity" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__(self, cloud): """Initialize the binary sensor.""" self.cloud = cloud self._unsub_dispatcher = None - @property - def name(self) -> str: - """Return the name of the binary sensor, if any.""" - return "Remote UI" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "cloud-remote-ui-connectivity" - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.cloud.remote.is_connected - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_CONNECTIVITY - @property def available(self) -> bool: """Return True if entity is available.""" return self.cloud.remote.certificate is not None - @property - def should_poll(self) -> bool: - """Return True if entity has to be polled for state.""" - return False - async def async_added_to_hass(self): """Register update dispatcher.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 6c09169ef3466..5f3abe521af75 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from http import HTTPStatus import logging from pathlib import Path from typing import Any @@ -14,7 +15,6 @@ smart_home as alexa_sh, ) from homeassistant.components.google_assistant import const as gc, smart_home as ga -from homeassistant.const import HTTP_OK from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -35,19 +35,20 @@ def __init__( websession: aiohttp.ClientSession, alexa_user_config: dict[str, Any], google_user_config: dict[str, Any], - ): + ) -> None: """Initialize client interface to Cloud.""" self._hass = hass self._prefs = prefs self._websession = websession self.google_user_config = google_user_config self.alexa_user_config = alexa_user_config - self._alexa_config = None - self._google_config = None + self._alexa_config: alexa_config.AlexaConfig | None = None + self._google_config: google_config.CloudGoogleConfig | None = None @property def base_path(self) -> Path: """Return path to base dir.""" + assert self._hass.config.config_dir is not None return Path(self._hass.config.config_dir) @property @@ -56,7 +57,7 @@ def prefs(self) -> CloudPreferences: return self._prefs @property - def loop(self) -> asyncio.BaseEventLoop: + def loop(self) -> asyncio.AbstractEventLoop: """Return client loop.""" return self._hass.loop @@ -66,7 +67,7 @@ def websession(self) -> aiohttp.ClientSession: return self._websession @property - def aiohttp_runner(self) -> aiohttp.web.AppRunner: + def aiohttp_runner(self) -> aiohttp.web.AppRunner | None: """Return client webinterface aiohttp application.""" return self._hass.http.runner @@ -108,8 +109,8 @@ async def get_google_config(self) -> google_config.CloudGoogleConfig: return self._google_config - async def logged_in(self) -> None: - """When user logs in.""" + async def cloud_started(self) -> None: + """When cloud is started.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_): @@ -148,9 +149,12 @@ async def enable_google(_): tasks.append(enable_google) if tasks: - await asyncio.gather(*[task(None) for task in tasks]) + await asyncio.gather(*(task(None) for task in tasks)) + + async def cloud_stopped(self) -> None: + """When the cloud is stopped.""" - async def cleanups(self) -> None: + async def logout_cleanups(self) -> None: """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) @@ -169,6 +173,10 @@ def dispatcher_message(self, identifier: str, data: Any = None) -> None: if identifier.startswith("remote_"): async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) + async def async_cloud_connect_update(self, connect: bool) -> None: + """Process cloud remote message to client.""" + await self._prefs.async_update(remote_enabled=connect) + async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud alexa message to client.""" cloud_user = await self._prefs.get_cloud_user() @@ -203,7 +211,7 @@ async def async_webhook_message(self, payload: dict[Any, Any]) -> dict[Any, Any] break if found is None: - return {"status": HTTP_OK} + return {"status": HTTPStatus.OK} request = MockRequest( content=payload["body"].encode("utf-8"), diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index d0417e0d38d8e..f24f172be364b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -63,13 +63,5 @@ DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" -class InvalidTrustedNetworks(Exception): - """Raised when invalid trusted networks config.""" - - -class InvalidTrustedProxies(Exception): - """Raised when invalid trusted proxies config.""" - - class RequireRelink(Exception): """The skill needs to be relinked.""" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 41f62c32c39c9..9ecd76302b7ab 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,5 +1,6 @@ """Google config for Cloud.""" import asyncio +from http import HTTPStatus import logging from hass_nabucasa import Cloud, cloud_api @@ -7,9 +8,9 @@ from homeassistant.components.google_assistant.const import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_OK +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, ENTITY_CATEGORIES from homeassistant.core import CoreState, split_entity_id -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er, start from homeassistant.setup import async_setup_component from .const import ( @@ -62,7 +63,7 @@ def secure_devices_pin(self): @property def should_report_state(self): """Return if states should be proactively reported.""" - return self._cloud.is_logged_in and self._prefs.google_report_state + return self.enabled and self._prefs.google_report_state @property def local_sdk_webhook_id(self): @@ -86,8 +87,11 @@ async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() - if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: - await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) + async def hass_started(hass): + if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: + await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) + + start.async_at_start(self.hass, hass_started) # Remove old/wrong user agent ids remove_agent_user_ids = [] @@ -101,7 +105,7 @@ async def async_initialize(self): self._prefs.async_listen_updates(self._async_prefs_updated) self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, ) @@ -123,13 +127,19 @@ def _should_expose_entity_id(self, entity_id): if entity_expose is not None: return entity_expose + entity_registry = er.async_get(self.hass) + if registry_entry := entity_registry.async_get(entity_id): + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES + else: + auxiliary_entity = False + default_expose = self._prefs.google_default_expose # Backwards compat if default_expose is None: - return True + return not auxiliary_entity - return split_entity_id(entity_id)[0] in default_expose + return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose @property def agent_user_id(self): @@ -161,7 +171,7 @@ async def async_report_state(self, message, agent_user_id: str): async def _async_request_sync_devices(self, agent_user_id: str): """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): - return HTTP_OK + return HTTPStatus.OK async with self._sync_entities_lock: resp = await cloud_api.async_google_actions_request_sync(self._cloud) @@ -169,6 +179,13 @@ async def _async_request_sync_devices(self, agent_user_id: str): async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" + if not self._cloud.is_logged_in: + if self.is_reporting_state: + self.async_disable_report_state() + if self.is_local_sdk_active: + self.async_disable_local_sdk() + return + if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) @@ -205,7 +222,7 @@ async def _handle_entity_registry_updated(self, event): # Only consider entity registry updates if info relevant for Google has changed if event.data["action"] == "update" and not bool( - set(event.data["changes"]) & entity_registry.ENTITY_DESCRIBING_ATTRIBUTES + set(event.data["changes"]) & er.ENTITY_DESCRIBING_ATTRIBUTES ): return diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e9771012379ca..cd6820572669e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,12 +1,13 @@ """The HTTP api to control the cloud integration.""" import asyncio from functools import wraps +from http import HTTPStatus import logging import aiohttp import async_timeout import attr -from hass_nabucasa import Cloud, auth, thingtalk +from hass_nabucasa import Cloud, auth, cloud_api, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol @@ -20,13 +21,6 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.websocket_api import const as ws_const -from homeassistant.const import ( - HTTP_BAD_GATEWAY, - HTTP_BAD_REQUEST, - HTTP_INTERNAL_SERVER_ERROR, - HTTP_OK, - HTTP_UNAUTHORIZED, -) from .const import ( DOMAIN, @@ -39,53 +33,19 @@ PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, - InvalidTrustedNetworks, - InvalidTrustedProxies, RequireRelink, ) _LOGGER = logging.getLogger(__name__) -WS_TYPE_STATUS = "cloud/status" -SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_STATUS} -) - - -WS_TYPE_SUBSCRIPTION = "cloud/subscription" -SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_SUBSCRIPTION} -) - - -WS_TYPE_HOOK_CREATE = "cloud/cloudhook/create" -SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_HOOK_CREATE, vol.Required("webhook_id"): str} -) - - -WS_TYPE_HOOK_DELETE = "cloud/cloudhook/delete" -SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_HOOK_DELETE, vol.Required("webhook_id"): str} -) - - _CLOUD_ERRORS = { - InvalidTrustedNetworks: ( - HTTP_INTERNAL_SERVER_ERROR, - "Remote UI not compatible with 127.0.0.1/::1 as a trusted network.", - ), - InvalidTrustedProxies: ( - HTTP_INTERNAL_SERVER_ERROR, - "Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.", - ), asyncio.TimeoutError: ( - HTTP_BAD_GATEWAY, + HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", ), aiohttp.ClientError: ( - HTTP_INTERNAL_SERVER_ERROR, + HTTPStatus.INTERNAL_SERVER_ERROR, "Error making internal request", ), } @@ -94,17 +54,11 @@ async def async_setup(hass): """Initialize the HTTP API.""" async_register_command = hass.components.websocket_api.async_register_command - async_register_command(WS_TYPE_STATUS, websocket_cloud_status, SCHEMA_WS_STATUS) - async_register_command( - WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION - ) + async_register_command(websocket_cloud_status) + async_register_command(websocket_subscription) async_register_command(websocket_update_prefs) - async_register_command( - WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE - ) - async_register_command( - WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE - ) + async_register_command(websocket_hook_create) + async_register_command(websocket_hook_delete) async_register_command(websocket_remote_connect) async_register_command(websocket_remote_disconnect) @@ -127,15 +81,15 @@ async def async_setup(hass): _CLOUD_ERRORS.update( { - auth.UserNotFound: (HTTP_BAD_REQUEST, "User does not exist."), - auth.UserNotConfirmed: (HTTP_BAD_REQUEST, "Email not confirmed."), + auth.UserNotFound: (HTTPStatus.BAD_REQUEST, "User does not exist."), + auth.UserNotConfirmed: (HTTPStatus.BAD_REQUEST, "Email not confirmed."), auth.UserExists: ( - HTTP_BAD_REQUEST, + HTTPStatus.BAD_REQUEST, "An account with the given email already exists.", ), - auth.Unauthenticated: (HTTP_UNAUTHORIZED, "Authentication failed."), + auth.Unauthenticated: (HTTPStatus.UNAUTHORIZED, "Authentication failed."), auth.PasswordChangeRequired: ( - HTTP_BAD_REQUEST, + HTTPStatus.BAD_REQUEST, "Password change required.", ), } @@ -188,7 +142,7 @@ def _process_cloud_exception(exc, where): if err_info is None: _LOGGER.exception("Unexpected error processing request for %s", where) - err_info = (HTTP_BAD_GATEWAY, f"Unexpected error: {exc}") + err_info = (HTTPStatus.BAD_GATEWAY, f"Unexpected error: {exc}") return err_info @@ -240,7 +194,7 @@ async def post(self, request): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.logout() return self.json_message("ok") @@ -266,7 +220,7 @@ async def post(self, request, data): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.auth.async_register(data["email"], data["password"]) return self.json_message("ok") @@ -285,7 +239,7 @@ async def post(self, request, data): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.auth.async_resend_email_confirm(data["email"]) return self.json_message("ok") @@ -304,12 +258,13 @@ async def post(self, request, data): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.auth.async_forgot_password(data["email"]) return self.json_message("ok") +@websocket_api.websocket_command({vol.Required("type"): "cloud/status"}) @websocket_api.async_response async def websocket_cloud_status(hass, connection, msg): """Handle request for account info. @@ -343,41 +298,23 @@ def with_cloud_auth(hass, connection, msg): @_require_cloud_login +@websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) @websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT): - response = await cloud.fetch_subscription_info() - - if response.status != HTTP_OK: - connection.send_message( - websocket_api.error_message( - msg["id"], "request_failed", "Failed to request subscription" - ) + try: + async with async_timeout.timeout(REQUEST_TIMEOUT): + data = await cloud_api.async_subscription_info(cloud) + except aiohttp.ClientError: + connection.send_error( + msg["id"], "request_failed", "Failed to request subscription" ) - - data = await response.json() - - # Check if a user is subscribed but local info is outdated - # In that case, let's refresh and reconnect - if data.get("provider") and not cloud.is_connected: - _LOGGER.debug("Found disconnected account with valid subscriotion, connecting") - await cloud.auth.async_renew_access_token() - - # Cancel reconnect in progress - if cloud.iot.state != STATE_DISCONNECTED: - await cloud.iot.disconnect() - - hass.async_create_task(cloud.iot.connect()) - - connection.send_message(websocket_api.result_message(msg["id"], data)) + else: + connection.send_result(msg["id"], data) @_require_cloud_login -@websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "cloud/update_prefs", @@ -393,6 +330,7 @@ async def websocket_subscription(hass, connection, msg): ), } ) +@websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -405,7 +343,7 @@ async def websocket_update_prefs(hass, connection, msg): if changes.get(PREF_ALEXA_REPORT_STATE): alexa_config = await cloud.client.get_alexa_config() try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await alexa_config.async_get_access_token() except asyncio.TimeoutError: connection.send_error( @@ -427,6 +365,12 @@ async def websocket_update_prefs(hass, connection, msg): @_require_cloud_login +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/cloudhook/create", + vol.Required("webhook_id"): str, + } +) @websocket_api.async_response @_ws_handle_cloud_errors async def websocket_hook_create(hass, connection, msg): @@ -437,6 +381,12 @@ async def websocket_hook_create(hass, connection, msg): @_require_cloud_login +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/cloudhook/delete", + vol.Required("webhook_id"): str, + } +) @websocket_api.async_response @_ws_handle_cloud_errors async def websocket_hook_delete(hass, connection, msg): @@ -480,35 +430,33 @@ async def _account_data(cloud): @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/remote/connect"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/remote/connect"}) async def websocket_remote_connect(hass, connection, msg): """Handle request for connect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=True) - await cloud.remote.connect() connection.send_result(msg["id"], await _account_data(cloud)) @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/remote/disconnect"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/remote/disconnect"}) async def websocket_remote_disconnect(hass, connection, msg): """Handle request for disconnect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=False) - await cloud.remote.disconnect() connection.send_result(msg["id"], await _account_data(cloud)) @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) async def google_assistant_list(hass, connection, msg): """List all google assistant entities.""" cloud = hass.data[DOMAIN] @@ -531,8 +479,6 @@ async def google_assistant_list(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login -@websocket_api.async_response -@_ws_handle_cloud_errors @websocket_api.websocket_command( { "type": "cloud/google_assistant/entities/update", @@ -543,6 +489,8 @@ async def google_assistant_list(hass, connection, msg): vol.Optional("disable_2fa"): bool, } ) +@websocket_api.async_response +@_ws_handle_cloud_errors async def google_assistant_update(hass, connection, msg): """Update google assistant config.""" cloud = hass.data[DOMAIN] @@ -559,9 +507,9 @@ async def google_assistant_update(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/alexa/entities"}) @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] @@ -584,8 +532,6 @@ async def alexa_list(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login -@websocket_api.async_response -@_ws_handle_cloud_errors @websocket_api.websocket_command( { "type": "cloud/alexa/entities/update", @@ -593,6 +539,8 @@ async def alexa_list(hass, connection, msg): vol.Optional("should_expose"): vol.Any(None, bool), } ) +@websocket_api.async_response +@_ws_handle_cloud_errors async def alexa_update(hass, connection, msg): """Update alexa entity config.""" cloud = hass.data[DOMAIN] @@ -609,14 +557,14 @@ async def alexa_update(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login -@websocket_api.async_response @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) +@websocket_api.async_response async def alexa_sync(hass, connection, msg): """Sync with Alexa.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() - with async_timeout.timeout(10): + async with async_timeout.timeout(10): try: success = await alexa_config.async_sync_entities() except alexa_errors.NoTokenAvailable: @@ -633,13 +581,13 @@ async def alexa_sync(hass, connection, msg): connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, "Unknown error") -@websocket_api.async_response @websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) +@websocket_api.async_response async def thingtalk_convert(hass, connection, msg): """Convert a query.""" cloud = hass.data[DOMAIN] - with async_timeout.timeout(10): + async with async_timeout.timeout(10): try: connection.send_result( msg["id"], await thingtalk.async_convert(cloud, msg["query"]) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d0d7ae0950570..517aa887a3036 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.43.0"], + "requirements": ["hass-nabucasa==0.50.0"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index c51d527873096..11d4ebbb17530 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,8 +1,6 @@ """Preference management for cloud.""" from __future__ import annotations -from ipaddress import ip_address - from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.core import callback @@ -34,8 +32,6 @@ PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, - InvalidTrustedNetworks, - InvalidTrustedProxies, ) STORAGE_KEY = DOMAIN @@ -54,9 +50,7 @@ def __init__(self, hass): async def async_initialize(self): """Finish initializing the preferences.""" - prefs = await self._store.async_load() - - if prefs is None: + if (prefs := await self._store.async_load()) is None: prefs = self._empty_config("") self._prefs = prefs @@ -112,14 +106,6 @@ async def async_update( if value is not UNDEFINED: prefs[key] = value - if remote_enabled is True and self._has_local_trusted_network: - prefs[PREF_ENABLE_REMOTE] = False - raise InvalidTrustedNetworks - - if remote_enabled is True and self._has_local_trusted_proxies: - prefs[PREF_ENABLE_REMOTE] = False - raise InvalidTrustedProxies - await self._save_prefs(prefs) async def async_update_google_entity_config( @@ -216,12 +202,7 @@ def as_dict(self): @property def remote_enabled(self): """Return if remote is enabled on start.""" - enabled = self._prefs.get(PREF_ENABLE_REMOTE, False) - - if not enabled: - return False - - if self._has_local_trusted_network or self._has_local_trusted_proxies: + if not self._prefs.get(PREF_ENABLE_REMOTE, False): return False return True @@ -300,54 +281,21 @@ async def get_cloud_user(self) -> str: return user.id user = await self._hass.auth.async_create_system_user( - "Home Assistant Cloud", [GROUP_ID_ADMIN] + "Home Assistant Cloud", group_ids=[GROUP_ID_ADMIN], local_only=True ) + assert user is not None await self.async_update(cloud_user=user.id) return user.id async def _load_cloud_user(self) -> User | None: """Load cloud user if available.""" - user_id = self._prefs.get(PREF_CLOUD_USER) - - if user_id is None: + if (user_id := self._prefs.get(PREF_CLOUD_USER)) is None: return None # Fetch the user. It can happen that the user no longer exists if # an image was restored without restoring the cloud prefs. return await self._hass.auth.async_get_user(user_id) - @property - def _has_local_trusted_network(self) -> bool: - """Return if we allow localhost to bypass auth.""" - local4 = ip_address("127.0.0.1") - local6 = ip_address("::1") - - for prv in self._hass.auth.auth_providers: - if prv.type != "trusted_networks": - continue - - for network in prv.trusted_networks: - if local4 in network or local6 in network: - return True - - return False - - @property - def _has_local_trusted_proxies(self) -> bool: - """Return if we allow localhost to be a proxy and use its data.""" - if not hasattr(self._hass, "http"): - return False - - local4 = ip_address("127.0.0.1") - local6 = ip_address("::1") - - if any( - local4 in nwk or local6 in nwk for nwk in self._hass.http.trusted_proxies - ): - return True - - return False - async def _save_prefs(self, prefs): """Save preferences to disk.""" self._prefs = prefs diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml index a7fb6b2f21b56..1b676ea6be96e 100644 --- a/homeassistant/components/cloud/services.yaml +++ b/homeassistant/components/cloud/services.yaml @@ -1,7 +1,9 @@ # Describes the format for available cloud services remote_connect: + name: Remote connect description: Make instance UI available outside over NabuCasa cloud remote_disconnect: + name: Remote disconnect description: Disconnect UI from NabuCasa cloud diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 357575c7bd0c6..d38a0c272a78d 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -7,10 +7,11 @@ "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", + "remote_server": "Remote Server", "alexa_enabled": "Alexa Enabled", "google_enabled": "Google Enabled", "logged_in": "Logged In", "subscription_expiration": "Subscription Expiration" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 6d700c4fb8ef8..4d8a6eab64cef 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -33,6 +33,7 @@ async def system_health_info(hass): data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled + data["remote_server"] = cloud.remote.snitun_server data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, cloud.acme_directory_server diff --git a/homeassistant/components/cloud/translations/bg.json b/homeassistant/components/cloud/translations/bg.json new file mode 100644 index 0000000000000..d6ab160fd29fb --- /dev/null +++ b/homeassistant/components/cloud/translations/bg.json @@ -0,0 +1,8 @@ +{ + "system_health": { + "info": { + "remote_server": "\u041e\u0442\u0434\u0430\u043b\u0435\u0447\u0435\u043d \u0441\u044a\u0440\u0432\u044a\u0440", + "subscription_expiration": "\u0418\u0437\u0442\u0438\u0447\u0430\u043d\u0435 \u043d\u0430 \u0430\u0431\u043e\u043d\u0430\u043c\u0435\u043d\u0442\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/ca.json b/homeassistant/components/cloud/translations/ca.json index 4e6a14cd2f021..c5fec79a89d99 100644 --- a/homeassistant/components/cloud/translations/ca.json +++ b/homeassistant/components/cloud/translations/ca.json @@ -10,6 +10,7 @@ "relayer_connected": "Encaminador connectat", "remote_connected": "Connexi\u00f3 remota establerta", "remote_enabled": "Connexi\u00f3 remota activada", + "remote_server": "Servidor remot", "subscription_expiration": "Caducitat de la subscripci\u00f3" } } diff --git a/homeassistant/components/cloud/translations/de.json b/homeassistant/components/cloud/translations/de.json index fd5598fa0268b..20ac7ff0fab29 100644 --- a/homeassistant/components/cloud/translations/de.json +++ b/homeassistant/components/cloud/translations/de.json @@ -7,9 +7,10 @@ "can_reach_cloud_auth": "Authentifizierungsserver erreichbar", "google_enabled": "Google aktiviert", "logged_in": "Angemeldet", - "relayer_connected": "Relay Verbunden", + "relayer_connected": "Relay verbunden", "remote_connected": "Remote verbunden", "remote_enabled": "Remote aktiviert", + "remote_server": "Remote-Server", "subscription_expiration": "Ablauf des Abonnements" } } diff --git a/homeassistant/components/cloud/translations/el.json b/homeassistant/components/cloud/translations/el.json new file mode 100644 index 0000000000000..923f852f036d3 --- /dev/null +++ b/homeassistant/components/cloud/translations/el.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "remote_server": "\u0391\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03bf\u03c2 \u0394\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae\u03c2" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/en.json b/homeassistant/components/cloud/translations/en.json index 34af1f57cfaa2..7577a9a51e4f2 100644 --- a/homeassistant/components/cloud/translations/en.json +++ b/homeassistant/components/cloud/translations/en.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", + "remote_server": "Remote Server", "subscription_expiration": "Subscription Expiration" } } diff --git a/homeassistant/components/cloud/translations/es.json b/homeassistant/components/cloud/translations/es.json index de05ccf527a53..f81c71e82928e 100644 --- a/homeassistant/components/cloud/translations/es.json +++ b/homeassistant/components/cloud/translations/es.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer conectado", "remote_connected": "Remoto conectado", "remote_enabled": "Remoto habilitado", + "remote_server": "Servidor remoto", "subscription_expiration": "Caducidad de la suscripci\u00f3n" } } diff --git a/homeassistant/components/cloud/translations/et.json b/homeassistant/components/cloud/translations/et.json index 19f8f40b9d5df..59c2b8c6e82fe 100644 --- a/homeassistant/components/cloud/translations/et.json +++ b/homeassistant/components/cloud/translations/et.json @@ -10,6 +10,7 @@ "relayer_connected": "Edastaja on \u00fchendatud", "remote_connected": "Kaug\u00fchendus on loodud", "remote_enabled": "Kaug\u00fchendus on lubatud", + "remote_server": "Kaugserver", "subscription_expiration": "Tellimuse aegumine" } } diff --git a/homeassistant/components/cloud/translations/fr.json b/homeassistant/components/cloud/translations/fr.json index 9bb4029fce053..76d6bce9a05f5 100644 --- a/homeassistant/components/cloud/translations/fr.json +++ b/homeassistant/components/cloud/translations/fr.json @@ -10,6 +10,7 @@ "relayer_connected": "Relais connect\u00e9", "remote_connected": "Contr\u00f4le \u00e0 distance connect\u00e9", "remote_enabled": "Contr\u00f4le \u00e0 distance activ\u00e9", + "remote_server": "Serveur distant", "subscription_expiration": "Expiration de l'abonnement" } } diff --git a/homeassistant/components/cloud/translations/he.json b/homeassistant/components/cloud/translations/he.json new file mode 100644 index 0000000000000..79b550b23e296 --- /dev/null +++ b/homeassistant/components/cloud/translations/he.json @@ -0,0 +1,10 @@ +{ + "system_health": { + "info": { + "alexa_enabled": "Alexa \u05de\u05d5\u05e4\u05e2\u05dc\u05ea", + "google_enabled": "Google \u05de\u05d5\u05e4\u05e2\u05dc", + "logged_in": "\u05de\u05d7\u05d5\u05d1\u05e8", + "remote_server": "\u05e9\u05e8\u05ea \u05de\u05e8\u05d5\u05d7\u05e7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/hu.json b/homeassistant/components/cloud/translations/hu.json index 8301806831b53..3ecfa262ed5e6 100644 --- a/homeassistant/components/cloud/translations/hu.json +++ b/homeassistant/components/cloud/translations/hu.json @@ -10,6 +10,7 @@ "relayer_connected": "K\u00f6zvet\u00edt\u0151 csatlakoztatva", "remote_connected": "T\u00e1voli csatlakoz\u00e1s", "remote_enabled": "T\u00e1voli hozz\u00e1f\u00e9r\u00e9s enged\u00e9lyezve", + "remote_server": "T\u00e1voli szerver", "subscription_expiration": "El\u0151fizet\u00e9s lej\u00e1rata" } } diff --git a/homeassistant/components/cloud/translations/id.json b/homeassistant/components/cloud/translations/id.json index 1cff542796c3c..a8f6d7b4b677c 100644 --- a/homeassistant/components/cloud/translations/id.json +++ b/homeassistant/components/cloud/translations/id.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer Terhubung", "remote_connected": "Terhubung Jarak Jauh", "remote_enabled": "Kontrol Jarak Jauh Diaktifkan", + "remote_server": "Server Daring", "subscription_expiration": "Masa Kedaluwarsa Langganan" } } diff --git a/homeassistant/components/cloud/translations/it.json b/homeassistant/components/cloud/translations/it.json index fbe13abc41e9f..5c8976d63a3c9 100644 --- a/homeassistant/components/cloud/translations/it.json +++ b/homeassistant/components/cloud/translations/it.json @@ -2,14 +2,15 @@ "system_health": { "info": { "alexa_enabled": "Alexa abilitato", - "can_reach_cert_server": "Server dei Certificati raggiungibile", + "can_reach_cert_server": "Server dei certificati raggiungibile", "can_reach_cloud": "Home Assistant Cloud raggiungibile", - "can_reach_cloud_auth": "Server di Autenticazione raggiungibile", + "can_reach_cloud_auth": "Server di autenticazione raggiungibile", "google_enabled": "Google abilitato", "logged_in": "Accesso effettuato", "relayer_connected": "Relayer connesso", "remote_connected": "Connesso in remoto", "remote_enabled": "Remoto abilitato", + "remote_server": "Server remoto", "subscription_expiration": "Scadenza abbonamento" } } diff --git a/homeassistant/components/cloud/translations/ja.json b/homeassistant/components/cloud/translations/ja.json new file mode 100644 index 0000000000000..298163c3d41c4 --- /dev/null +++ b/homeassistant/components/cloud/translations/ja.json @@ -0,0 +1,17 @@ +{ + "system_health": { + "info": { + "alexa_enabled": "Alexa\u6709\u52b9", + "can_reach_cert_server": "\u8a3c\u660e\u66f8\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u30a2\u30af\u30bb\u30b9", + "can_reach_cloud": "Home Assistant Cloud\u3078\u306e\u30a2\u30af\u30bb\u30b9", + "can_reach_cloud_auth": "\u8a8d\u8a3c\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u30a2\u30af\u30bb\u30b9", + "google_enabled": "Google\u6709\u52b9", + "logged_in": "\u30ed\u30b0\u30a4\u30f3\u6e08", + "relayer_connected": "\u63a5\u7d9a\u3055\u308c\u305f\u518d\u30ec\u30a4\u30e4\u30fc", + "remote_connected": "\u30ea\u30e2\u30fc\u30c8\u63a5\u7d9a", + "remote_enabled": "\u30ea\u30e2\u30fc\u30c8\u6709\u52b9", + "remote_server": "\u30ea\u30e2\u30fc\u30c8\u30b5\u30fc\u30d0\u30fc", + "subscription_expiration": "\u30b5\u30d6\u30b9\u30af\u30ea\u30d7\u30b7\u30e7\u30f3\u306e\u6709\u52b9\u671f\u9650" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/nl.json b/homeassistant/components/cloud/translations/nl.json index 7d02a04cd01e0..eebe8d14be52c 100644 --- a/homeassistant/components/cloud/translations/nl.json +++ b/homeassistant/components/cloud/translations/nl.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer verbonden", "remote_connected": "Op afstand verbonden", "remote_enabled": "Op afstand ingeschakeld", + "remote_server": "Externe server", "subscription_expiration": "Afloop abonnement" } } diff --git a/homeassistant/components/cloud/translations/no.json b/homeassistant/components/cloud/translations/no.json index 63779e7fa9440..e3ae7a4f766d3 100644 --- a/homeassistant/components/cloud/translations/no.json +++ b/homeassistant/components/cloud/translations/no.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer tilkoblet", "remote_connected": "Ekstern tilkobling", "remote_enabled": "Ekstern aktivert", + "remote_server": "Ekstern server", "subscription_expiration": "Abonnementets utl\u00f8p" } } diff --git a/homeassistant/components/cloud/translations/pl.json b/homeassistant/components/cloud/translations/pl.json index 1df32a14d8e0e..d8fafb78b90e8 100644 --- a/homeassistant/components/cloud/translations/pl.json +++ b/homeassistant/components/cloud/translations/pl.json @@ -4,12 +4,13 @@ "alexa_enabled": "Alexa w\u0142\u0105czona", "can_reach_cert_server": "Dost\u0119p do serwera certyfikat\u00f3w", "can_reach_cloud": "Dost\u0119p do chmury Home Assistant", - "can_reach_cloud_auth": "Dost\u0119p do serwera certyfikat\u00f3w", + "can_reach_cloud_auth": "Dost\u0119p do serwera uwierzytelniania", "google_enabled": "Asystent Google w\u0142\u0105czony", "logged_in": "Zalogowany", "relayer_connected": "Relayer pod\u0142\u0105czony", "remote_connected": "Zdalny dost\u0119p pod\u0142\u0105czony", "remote_enabled": "Zdalny dost\u0119p w\u0142\u0105czony", + "remote_server": "Zdalny serwer", "subscription_expiration": "Wyga\u015bni\u0119cie subskrypcji" } } diff --git a/homeassistant/components/cloud/translations/ru.json b/homeassistant/components/cloud/translations/ru.json index b2d8c55369bbd..aa3c34ad700e6 100644 --- a/homeassistant/components/cloud/translations/ru.json +++ b/homeassistant/components/cloud/translations/ru.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d", "remote_connected": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d", "remote_enabled": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d", + "remote_server": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0432\u0435\u0440", "subscription_expiration": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438" } } diff --git a/homeassistant/components/cloud/translations/th.json b/homeassistant/components/cloud/translations/th.json new file mode 100644 index 0000000000000..1171381d568b8 --- /dev/null +++ b/homeassistant/components/cloud/translations/th.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "remote_server": "\u0e40\u0e0b\u0e34\u0e23\u0e4c\u0e1f\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e23\u0e30\u0e22\u0e30\u0e44\u0e01\u0e25" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/tr.json b/homeassistant/components/cloud/translations/tr.json index 75d1c768bebda..5f3edb5d3f108 100644 --- a/homeassistant/components/cloud/translations/tr.json +++ b/homeassistant/components/cloud/translations/tr.json @@ -2,12 +2,15 @@ "system_health": { "info": { "alexa_enabled": "Alexa Etkin", + "can_reach_cert_server": "Sertifika Sunucusuna Ula\u015f\u0131n", "can_reach_cloud": "Home Assistant Cloud'a ula\u015f\u0131n", + "can_reach_cloud_auth": "Kimlik Do\u011frulama Sunucusuna Ula\u015f\u0131n", "google_enabled": "Google Etkin", "logged_in": "Giri\u015f Yapt\u0131", "relayer_connected": "Yeniden Katman ba\u011fl\u0131", "remote_connected": "Uzaktan Ba\u011fl\u0131", "remote_enabled": "Uzaktan Etkinle\u015ftirildi", + "remote_server": "Sunucuyu Uzaktan Kontrol et", "subscription_expiration": "Aboneli\u011fin Sona Ermesi" } } diff --git a/homeassistant/components/cloud/translations/zh-Hans.json b/homeassistant/components/cloud/translations/zh-Hans.json index eb1daf5e4f3aa..f4011e3981ef1 100644 --- a/homeassistant/components/cloud/translations/zh-Hans.json +++ b/homeassistant/components/cloud/translations/zh-Hans.json @@ -10,6 +10,7 @@ "relayer_connected": "\u901a\u8fc7\u4ee3\u7406\u8fde\u63a5", "remote_connected": "\u8fdc\u7a0b\u8fde\u63a5", "remote_enabled": "\u5df2\u542f\u7528\u8fdc\u7a0b\u63a7\u5236", + "remote_server": "\u8fdc\u7a0b\u670d\u52a1\u5668", "subscription_expiration": "\u8ba2\u9605\u5230\u671f\u65f6\u95f4" } } diff --git a/homeassistant/components/cloud/translations/zh-Hant.json b/homeassistant/components/cloud/translations/zh-Hant.json index 8b97fd51a037c..619b0dde71c49 100644 --- a/homeassistant/components/cloud/translations/zh-Hant.json +++ b/homeassistant/components/cloud/translations/zh-Hant.json @@ -10,6 +10,7 @@ "relayer_connected": "\u4e2d\u7e7c\u5df2\u9023\u7dda", "remote_connected": "\u9060\u7aef\u63a7\u5236\u5df2\u9023\u7dda", "remote_enabled": "\u9060\u7aef\u63a7\u5236\u5df2\u555f\u7528", + "remote_server": "\u9060\u7aef\u4f3a\u670d\u5668", "subscription_expiration": "\u8a02\u95b1\u5230\u671f" } } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 4d19547d30c9c..00eacf7ca5286 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -15,14 +15,10 @@ def validate_lang(value): """Validate chosen gender or language.""" - lang = value.get(CONF_LANG) - - if lang is None: + if (lang := value.get(CONF_LANG)) is None: return value - gender = value.get(CONF_GENDER) - - if gender is None: + if (gender := value.get(CONF_GENDER)) is None: gender = value[CONF_GENDER] = next( (chk_gender for chk_lang, chk_gender in MAP_VOICE if chk_lang == lang), None ) @@ -61,7 +57,7 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa Cloud speech API provider.""" - def __init__(self, cloud: Cloud, language: str, gender: str): + def __init__(self, cloud: Cloud, language: str, gender: str) -> None: """Initialize cloud provider.""" self.cloud = cloud self.name = "Cloud" diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py index 57f84b057f78d..5908a0ac8164e 100644 --- a/homeassistant/components/cloud/utils.py +++ b/homeassistant/components/cloud/utils.py @@ -8,16 +8,18 @@ def aiohttp_serialize_response(response: web.Response) -> dict[str, Any]: """Serialize an aiohttp response to a dictionary.""" - body = response.body - - if body is None: - pass + if (body := response.body) is None: + body_decoded = None elif isinstance(body, payload.StringPayload): # pylint: disable=protected-access - body = body._value.decode(body.encoding) + body_decoded = body._value.decode(body.encoding) elif isinstance(body, bytes): - body = body.decode(response.charset or "utf-8") + body_decoded = body.decode(response.charset or "utf-8") else: raise ValueError("Unknown payload encoding") - return {"status": response.status, "body": body, "headers": dict(response.headers)} + return { + "status": response.status, + "body": body_decoded, + "headers": dict(response.headers), + } diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index e461e34c9a253..dc0782aa9c066 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -14,22 +14,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -from .const import ( - CONF_RECORDS, - DATA_UNDO_UPDATE_INTERVAL, - DEFAULT_UPDATE_INTERVAL, - DOMAIN, - SERVICE_UPDATE_RECORDS, -) +from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL, DOMAIN, SERVICE_UPDATE_RECORDS _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -43,9 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: zone_id = await cfupdate.get_zone_id() - except CloudflareAuthenticationException: - _LOGGER.error("API access forbidden. Please reauthenticate") - return False + except CloudflareAuthenticationException as error: + raise ConfigEntryAuthFailed from error except CloudflareConnectionException as error: raise ConfigEntryNotReady from error @@ -64,12 +57,12 @@ async def update_records_service(call): _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) - undo_interval = async_track_time_interval(hass, update_records, update_interval) + entry.async_on_unload( + async_track_time_interval(hass, update_records, update_interval) + ) hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_UNDO_UPDATE_INTERVAL: undo_interval, - } + hass.data[DOMAIN][entry.entry_id] = {} hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service) @@ -78,7 +71,6 @@ async def update_records_service(call): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Cloudflare config entry.""" - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_INTERVAL]() hass.data[DOMAIN].pop(entry.entry_id) return True diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 364700427dae6..27a22dbc5bd09 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from pycfdns import CloudflareUpdater from pycfdns.exceptions import ( @@ -12,9 +13,10 @@ import voluptuous as vol from homeassistant.components import persistent_notification -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -85,12 +87,49 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + entry: ConfigEntry | None = None + def __init__(self): """Initialize the Cloudflare config flow.""" self.cloudflare_config = {} self.zones = None self.records = None + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Cloudflare.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with Cloudflare.""" + errors = {} + + if user_input is not None and self.entry: + _, errors = await self._async_validate_or_error(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + }, + ) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + errors=errors, + ) + async def async_step_user(self, user_input: dict | None = None): """Handle a flow initiated by the user.""" if self._async_current_entries(): @@ -134,7 +173,6 @@ async def async_step_zone(self, user_input: dict | None = None): async def async_step_records(self, user_input: dict | None = None): """Handle the picking the zone records.""" - errors = {} if user_input is not None: self.cloudflare_config.update(user_input) @@ -144,7 +182,6 @@ async def async_step_records(self, user_input: dict | None = None): return self.async_show_form( step_id="records", data_schema=_records_schema(self.records), - errors=errors, ) async def _async_validate_or_error(self, config): diff --git a/homeassistant/components/cloudflare/const.py b/homeassistant/components/cloudflare/const.py index 0bdce7b9a92cd..4952b3768b0dc 100644 --- a/homeassistant/components/cloudflare/const.py +++ b/homeassistant/components/cloudflare/const.py @@ -5,9 +5,6 @@ # Config CONF_RECORDS = "records" -# Data -DATA_UNDO_UPDATE_INTERVAL = "undo_update_interval" - # Defaults DEFAULT_UPDATE_INTERVAL = 60 # in minutes diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index c831dbeb34d92..ebb9e4b5f6285 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -2,7 +2,7 @@ "domain": "cloudflare", "name": "Cloudflare", "documentation": "https://www.home-assistant.io/integrations/cloudflare", - "requirements": ["pycfdns==1.2.1"], + "requirements": ["pycfdns==1.2.2"], "codeowners": ["@ludeeus", "@ctalkington"], "config_flow": true, "iot_class": "cloud_push" diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml index 80165700dbb03..f9465e788d89e 100644 --- a/homeassistant/components/cloudflare/services.yaml +++ b/homeassistant/components/cloudflare/services.yaml @@ -1,2 +1,3 @@ update_records: + name: Update records description: Manually trigger update to Cloudflare records diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index bdadfde480049..31df9a6234178 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -20,6 +20,12 @@ "data": { "records": "Records" } + }, + "reauth_confirm": { + "data": { + "description": "Re-authenticate with your Cloudflare account.", + "api_token": "[%key:common::config_flow::data::api_token%]" + } } }, "error": { @@ -28,6 +34,7 @@ "invalid_zone": "Invalid zone" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "unknown": "[%key:common::config_flow::error::unknown%]" } diff --git a/homeassistant/components/cloudflare/translations/bg.json b/homeassistant/components/cloudflare/translations/bg.json new file mode 100644 index 0000000000000..a34f51c1828f5 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/bg.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "invalid_zone": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430 \u0437\u043e\u043d\u0430" + }, + "flow_title": "{name}", + "step": { + "user": { + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043a\u044a\u043c Cloudflare" + }, + "zone": { + "data": { + "zone": "\u0417\u043e\u043d\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/ca.json b/homeassistant/components/cloudflare/translations/ca.json index edd31662e5607..df26eaa73bc5a 100644 --- a/homeassistant/components/cloudflare/translations/ca.json +++ b/homeassistant/components/cloudflare/translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", "unknown": "Error inesperat" }, @@ -9,8 +10,14 @@ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "invalid_zone": "Zona inv\u00e0lida" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token d'API", + "description": "Torna a autenticar-te amb el compte de Cloudflare." + } + }, "records": { "data": { "records": "Registres" diff --git a/homeassistant/components/cloudflare/translations/cs.json b/homeassistant/components/cloudflare/translations/cs.json index e20f26236be7f..8f88377860b55 100644 --- a/homeassistant/components/cloudflare/translations/cs.json +++ b/homeassistant/components/cloudflare/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace.", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, @@ -11,6 +12,11 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API token" + } + }, "records": { "data": { "records": "Z\u00e1znamy" diff --git a/homeassistant/components/cloudflare/translations/de.json b/homeassistant/components/cloudflare/translations/de.json index 21118e106bf22..98cdbe355f662 100644 --- a/homeassistant/components/cloudflare/translations/de.json +++ b/homeassistant/components/cloudflare/translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", "unknown": "Unerwarteter Fehler" }, @@ -9,8 +10,14 @@ "invalid_auth": "Ung\u00fcltige Authentifizierung", "invalid_zone": "Ung\u00fcltige Zone" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API-Token", + "description": "Authentifiziere dich erneut mit deinem Cloudflare-Konto." + } + }, "records": { "data": { "records": "Datens\u00e4tze" @@ -19,7 +26,7 @@ }, "user": { "data": { - "api_token": "API Token" + "api_token": "API-Token" }, "description": "F\u00fcr diese Integration ist ein API-Token erforderlich, der mit Zone: Zone: Lesen und Zone: DNS: Bearbeiten f\u00fcr alle Zonen in deinem Konto erstellt wurde.", "title": "Mit Cloudflare verbinden" diff --git a/homeassistant/components/cloudflare/translations/en.json b/homeassistant/components/cloudflare/translations/en.json index 3dcd60ac4a9d3..43034cfbb4a61 100644 --- a/homeassistant/components/cloudflare/translations/en.json +++ b/homeassistant/components/cloudflare/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Re-authentication was successful", "single_instance_allowed": "Already configured. Only a single configuration possible.", "unknown": "Unexpected error" }, @@ -11,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API Token", + "description": "Re-authenticate with your Cloudflare account." + } + }, "records": { "data": { "records": "Records" diff --git a/homeassistant/components/cloudflare/translations/es-419.json b/homeassistant/components/cloudflare/translations/es-419.json new file mode 100644 index 0000000000000..03b49267d1264 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta de Cloudflare." + } + }, + "records": { + "data": { + "records": "Registros" + }, + "title": "Elegir los registros que desea actualizar" + }, + "user": { + "description": "Esta integraci\u00f3n requiere un token de API creado con Zone: Zone: Read y Zone: DNS: Edit permisos para todas las zonas de su cuenta.", + "title": "Conectarse a Cloudflare" + }, + "zone": { + "data": { + "zone": "Zona" + }, + "title": "Elija la zona para actualizar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/es.json b/homeassistant/components/cloudflare/translations/es.json index 7f9fdc15dfb25..0647609e4e89a 100644 --- a/homeassistant/components/cloudflare/translations/es.json +++ b/homeassistant/components/cloudflare/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "unknown": "Error inesperado" }, @@ -11,6 +12,12 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Vuelva a autenticarse con su cuenta de Cloudflare." + } + }, "records": { "data": { "records": "Registros" diff --git a/homeassistant/components/cloudflare/translations/et.json b/homeassistant/components/cloudflare/translations/et.json index 1f4d91c71d720..779706b29a40b 100644 --- a/homeassistant/components/cloudflare/translations/et.json +++ b/homeassistant/components/cloudflare/translations/et.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Taastuvastamine \u00f5nnestus", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine.", "unknown": "Tundmatu viga" }, @@ -9,8 +10,14 @@ "invalid_auth": "Tuvastamise viga", "invalid_zone": "Sobimatu ala" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API v\u00f5ti", + "description": "Taastuvasta oma Cloudflare'i kontoga." + } + }, "records": { "data": { "records": "Kirjed" diff --git a/homeassistant/components/cloudflare/translations/fr.json b/homeassistant/components/cloudflare/translations/fr.json index be6d4c3e2b3b6..73c2d76b4fcf0 100644 --- a/homeassistant/components/cloudflare/translations/fr.json +++ b/homeassistant/components/cloudflare/translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "unknown": "Erreur inattendue" }, @@ -9,8 +10,14 @@ "invalid_auth": "Authentification invalide", "invalid_zone": "Zone invalide" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Jeton d'API", + "description": "R\u00e9-authentifiez-vous avec votre compte Cloudflare." + } + }, "records": { "data": { "records": "Enregistrements" diff --git a/homeassistant/components/cloudflare/translations/he.json b/homeassistant/components/cloudflare/translations/he.json new file mode 100644 index 0000000000000..1f53e94240c22 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/he.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_zone": "\u05d0\u05d6\u05d5\u05e8 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "data": { + "api_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" + } + }, + "records": { + "data": { + "records": "\u05e8\u05e9\u05d5\u05de\u05d5\u05ea" + } + }, + "user": { + "data": { + "api_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" + } + }, + "zone": { + "data": { + "zone": "\u05d0\u05d6\u05d5\u05e8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/hu.json b/homeassistant/components/cloudflare/translations/hu.json index fed6f22d536c7..ef2a47e2e0d6d 100644 --- a/homeassistant/components/cloudflare/translations/hu.json +++ b/homeassistant/components/cloudflare/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, @@ -9,8 +10,14 @@ "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "invalid_zone": "\u00c9rv\u00e9nytelen z\u00f3na" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API token", + "description": "Hiteles\u00edtse \u00fajra Cloudflare-fi\u00f3kj\u00e1val." + } + }, "records": { "data": { "records": "Rekordok" @@ -21,6 +28,7 @@ "data": { "api_token": "API Token" }, + "description": "Ehhez az integr\u00e1ci\u00f3hoz a Z\u00f3na: Z\u00f3na: Olvas\u00e1s \u00e9s Z\u00f3na: DNS: L\u00e9trehozott API-token sz\u00fcks\u00e9ges. A fi\u00f3k \u00f6sszes z\u00f3n\u00e1j\u00e1nak enged\u00e9lyeinek szerkeszt\u00e9se.", "title": "Csatlakoz\u00e1s a Cloudflare szolg\u00e1ltat\u00e1shoz" }, "zone": { diff --git a/homeassistant/components/cloudflare/translations/id.json b/homeassistant/components/cloudflare/translations/id.json index 98286398ea8e7..b3f49244658fd 100644 --- a/homeassistant/components/cloudflare/translations/id.json +++ b/homeassistant/components/cloudflare/translations/id.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Autentikasi ulang berhasil", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan.", "unknown": "Kesalahan yang tidak diharapkan" }, @@ -9,8 +10,14 @@ "invalid_auth": "Autentikasi tidak valid", "invalid_zone": "Zona tidak valid" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Autentikasi ulang dengan akun Cloudflare Anda." + } + }, "records": { "data": { "records": "Catatan" diff --git a/homeassistant/components/cloudflare/translations/it.json b/homeassistant/components/cloudflare/translations/it.json index 48d9acc086159..c567b1c897817 100644 --- a/homeassistant/components/cloudflare/translations/it.json +++ b/homeassistant/components/cloudflare/translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", "unknown": "Errore imprevisto" }, @@ -9,13 +10,19 @@ "invalid_auth": "Autenticazione non valida", "invalid_zone": "Zona non valida" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Esegui nuovamente l'autenticazione con l'account Cloudflare." + } + }, "records": { "data": { "records": "Record" }, - "title": "Scegliere i record da aggiornare" + "title": "Scegli i record da aggiornare" }, "user": { "data": { @@ -28,7 +35,7 @@ "data": { "zone": "Zona" }, - "title": "Scegliere la zona da aggiornare" + "title": "Scegli la zona da aggiornare" } } } diff --git a/homeassistant/components/cloudflare/translations/ja.json b/homeassistant/components/cloudflare/translations/ja.json new file mode 100644 index 0000000000000..81fdf638148bf --- /dev/null +++ b/homeassistant/components/cloudflare/translations/ja.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_zone": "\u7121\u52b9\u306a\u30be\u30fc\u30f3" + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "data": { + "api_token": "API\u30c8\u30fc\u30af\u30f3", + "description": "Cloudflare\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u518d\u8a8d\u8a3c\u3057\u307e\u3059\u3002" + } + }, + "records": { + "data": { + "records": "\u30ec\u30b3\u30fc\u30c9" + }, + "title": "\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u3059\u308b\u30ec\u30b3\u30fc\u30c9\u3092\u9078\u629e" + }, + "user": { + "data": { + "api_token": "API\u30c8\u30fc\u30af\u30f3" + }, + "description": "\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306b\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u5185\u306e\u3059\u3079\u3066\u306e\u30be\u30fc\u30f3\u306b\u5bfe\u3059\u308b\u3001 Zone:Zone:Read \u304a\u3088\u3073\u3001Zone:DNS:Edit\u306e\u6a29\u9650\u3067\u4f5c\u6210\u3055\u308c\u305fAPI\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u3067\u3059\u3002", + "title": "Cloudflare\u306b\u63a5\u7d9a" + }, + "zone": { + "data": { + "zone": "\u30be\u30fc\u30f3" + }, + "title": "\u66f4\u65b0\u3059\u308b\u30be\u30fc\u30f3\u3092\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/nl.json b/homeassistant/components/cloudflare/translations/nl.json index 94697419ff1e9..5a1bf188a29b3 100644 --- a/homeassistant/components/cloudflare/translations/nl.json +++ b/homeassistant/components/cloudflare/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Herauthenticatie was succesvol", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk.", "unknown": "Onverwachte fout" }, @@ -9,8 +10,14 @@ "invalid_auth": "Ongeldige authenticatie", "invalid_zone": "Ongeldige zone" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API-token", + "description": "Verifieer opnieuw met uw Cloudflare-account." + } + }, "records": { "data": { "records": "Records" @@ -21,7 +28,7 @@ "data": { "api_token": "API-token" }, - "description": "Voor deze integratie is een API-token vereist dat is gemaakt met Zone:Zone:Lezen en Zone:DNS:Bewerk machtigingen voor alle zones in uw account.", + "description": "Voor deze integratie is een API-token vereist dat is gemaakt met Zone:Zone:Read en Zone:DNS:Edit machtigingen voor alle zones in uw account.", "title": "Verbinden met Cloudflare" }, "zone": { diff --git a/homeassistant/components/cloudflare/translations/no.json b/homeassistant/components/cloudflare/translations/no.json index 33e4ca61f7821..1329429474ae2 100644 --- a/homeassistant/components/cloudflare/translations/no.json +++ b/homeassistant/components/cloudflare/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", "unknown": "Uventet feil" }, @@ -9,8 +10,14 @@ "invalid_auth": "Ugyldig godkjenning", "invalid_zone": "Ugyldig sone" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API-token", + "description": "Autentiser p\u00e5 nytt med Cloudflare-kontoen din." + } + }, "records": { "data": { "records": "Poster" diff --git a/homeassistant/components/cloudflare/translations/pl.json b/homeassistant/components/cloudflare/translations/pl.json index 70c7869937af7..94a87c34b2e5d 100644 --- a/homeassistant/components/cloudflare/translations/pl.json +++ b/homeassistant/components/cloudflare/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, @@ -9,8 +10,14 @@ "invalid_auth": "Niepoprawne uwierzytelnienie", "invalid_zone": "Nieprawid\u0142owa strefa" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Ponownie uwierzytelnij za pomoc\u0105 konta Cloudflare." + } + }, "records": { "data": { "records": "Rekordy" diff --git a/homeassistant/components/cloudflare/translations/ru.json b/homeassistant/components/cloudflare/translations/ru.json index 7c397faa37e7f..d4dd9db4d335e 100644 --- a/homeassistant/components/cloudflare/translations/ru.json +++ b/homeassistant/components/cloudflare/translations/ru.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, @@ -9,8 +10,14 @@ "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "invalid_zone": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0437\u043e\u043d\u0430" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "\u0422\u043e\u043a\u0435\u043d API", + "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Cloudflare" + } + }, "records": { "data": { "records": "\u0417\u0430\u043f\u0438\u0441\u0438" diff --git a/homeassistant/components/cloudflare/translations/tr.json b/homeassistant/components/cloudflare/translations/tr.json index 5d1180961f629..1f0c68d7f9028 100644 --- a/homeassistant/components/cloudflare/translations/tr.json +++ b/homeassistant/components/cloudflare/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "unknown": "Beklenmeyen hata" }, @@ -9,8 +10,14 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "invalid_zone": "Ge\u00e7ersiz b\u00f6lge" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API Anahtar\u0131", + "description": "Cloudflare hesab\u0131n\u0131zla yeniden kimlik do\u011frulamas\u0131 yap\u0131n." + } + }, "records": { "data": { "records": "Kay\u0131tlar" @@ -19,8 +26,9 @@ }, "user": { "data": { - "api_token": "API Belirteci" + "api_token": "API Anahtar\u0131" }, + "description": "Bu entegrasyon, hesab\u0131n\u0131zdaki t\u00fcm b\u00f6lgeler i\u00e7in Zone:Zone:Read ve Zone:DNS:Edit izinleriyle olu\u015fturulmu\u015f bir API Simgesi gerektirir.", "title": "Cloudflare'ye ba\u011flan\u0131n" }, "zone": { diff --git a/homeassistant/components/cloudflare/translations/zh-Hans.json b/homeassistant/components/cloudflare/translations/zh-Hans.json index 4b0a696e5fc02..8e4bd974cbb20 100644 --- a/homeassistant/components/cloudflare/translations/zh-Hans.json +++ b/homeassistant/components/cloudflare/translations/zh-Hans.json @@ -1,15 +1,25 @@ { "config": { + "abort": { + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" }, "step": { - "user": { + "reauth_confirm": { "data": { - "api_token": "API \u5bc6\u7801" + "api_token": "API Token", + "description": "\u4f7f\u7528\u60a8\u7684 Cloudflare \u5e10\u6237\u91cd\u65b0\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002" } }, + "user": { + "data": { + "api_token": "API Token" + }, + "title": "\u8fde\u63a5\u81f3 Cloudflare" + }, "zone": { "data": { "zone": "\u533a\u57df" diff --git a/homeassistant/components/cloudflare/translations/zh-Hant.json b/homeassistant/components/cloudflare/translations/zh-Hant.json index d9a05269748f5..3ee29277296eb 100644 --- a/homeassistant/components/cloudflare/translations/zh-Hant.json +++ b/homeassistant/components/cloudflare/translations/zh-Hant.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, @@ -9,8 +10,14 @@ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "invalid_zone": "\u5340\u57df\u7121\u6548" }, - "flow_title": "Cloudflare\uff1a{name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API \u6b0a\u6756", + "description": "\u91cd\u65b0\u8a8d\u8b49 Cloudflare \u5e33\u865f\u3002" + } + }, "records": { "data": { "records": "\u8a18\u9304" diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index 3968ebbe9d7fc..651c584bc4c62 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -98,6 +98,9 @@ def connect(self): class CmusDevice(MediaPlayerEntity): """Representation of a running cmus.""" + _attr_media_content_type = MEDIA_TYPE_MUSIC + _attr_supported_features = SUPPORT_CMUS + def __init__(self, device, name, server): """Initialize the CMUS device.""" @@ -106,7 +109,7 @@ def __init__(self, device, name, server): auto_name = f"cmus-{server}" else: auto_name = "cmus-local" - self._name = name or auto_name + self._attr_name = name or auto_name self.status = {} def update(self): @@ -120,80 +123,30 @@ def update(self): self._remote.connect() else: self.status = status + if self.status.get("status") == "playing": + self._attr_state = STATE_PLAYING + elif self.status.get("status") == "paused": + self._attr_state = STATE_PAUSED + else: + self._attr_state = STATE_OFF + self._attr_media_content_id = self.status.get("file") + self._attr_media_duration = self.status.get("duration") + self._attr_media_title = self.status["tag"].get("title") + self._attr_media_artist = self.status["tag"].get("artist") + self._attr_media_track = self.status["tag"].get("tracknumber") + self._attr_media_album_name = self.status["tag"].get("album") + self._attr_media_album_artist = self.status["tag"].get("albumartist") + left = self.status["set"].get("vol_left")[0] + right = self.status["set"].get("vol_right")[0] + if left != right: + volume = float(left + right) / 2 + else: + volume = left + self._attr_volume_level = int(volume) / 100 return _LOGGER.warning("Received no status from cmus") - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the media state.""" - if self.status.get("status") == "playing": - return STATE_PLAYING - if self.status.get("status") == "paused": - return STATE_PAUSED - return STATE_OFF - - @property - def media_content_id(self): - """Content ID of current playing media.""" - return self.status.get("file") - - @property - def content_type(self): - """Content type of the current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def media_duration(self): - """Duration of current playing media in seconds.""" - return self.status.get("duration") - - @property - def media_title(self): - """Title of current playing media.""" - return self.status["tag"].get("title") - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self.status["tag"].get("artist") - - @property - def media_track(self): - """Track number of current playing media, music track only.""" - return self.status["tag"].get("tracknumber") - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self.status["tag"].get("album") - - @property - def media_album_artist(self): - """Album artist of current playing media, music track only.""" - return self.status["tag"].get("albumartist") - - @property - def volume_level(self): - """Return the volume level.""" - left = self.status["set"].get("vol_left")[0] - right = self.status["set"].get("vol_right")[0] - if left != right: - volume = float(left + right) / 2 - else: - volume = left - return int(volume) / 100 - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_CMUS - def turn_off(self): """Service to send the CMUS the command to stop playing.""" self._remote.cmus.player_stop() diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index a9c6422b4c635..56be5bca57b2a 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1 +1,146 @@ -"""The co2signal component.""" +"""The CO2 Signal integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TypedDict, cast + +import CO2Signal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTRY_CODE, DOMAIN +from .util import get_extra_name + +PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +class CO2SignalData(TypedDict): + """Data field.""" + + carbonIntensity: float + fossilFuelPercentage: float + + +class CO2SignalUnit(TypedDict): + """Unit field.""" + + carbonIntensity: str + + +class CO2SignalResponse(TypedDict): + """API response.""" + + status: str + countryCode: str + data: CO2SignalData + units: CO2SignalUnit + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up CO2 Signal from a config entry.""" + coordinator = CO2SignalCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + ) + self._entry = entry + + @property + def entry_id(self) -> str: + """Return entry ID.""" + return self._entry.entry_id + + def get_extra_name(self) -> str | None: + """Return the extra name describing the location if not home.""" + return get_extra_name(self._entry.data) + + async def _async_update_data(self) -> CO2SignalResponse: + """Fetch the latest data from the source.""" + try: + data = await self.hass.async_add_executor_job( + get_data, self.hass, self._entry.data + ) + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + except CO2Error as err: + raise UpdateFailed(str(err)) from err + + return data + + +class CO2Error(HomeAssistantError): + """Base error.""" + + +class InvalidAuth(CO2Error): + """Raised when invalid authentication credentials are provided.""" + + +class APIRatelimitExceeded(CO2Error): + """Raised when the API rate limit is exceeded.""" + + +class UnknownError(CO2Error): + """Raised when an unknown error occurs.""" + + +def get_data(hass: HomeAssistant, config: dict) -> CO2SignalResponse: + """Get data from the API.""" + if CONF_COUNTRY_CODE in config: + latitude = None + longitude = None + else: + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + try: + data = CO2Signal.get_latest( + config[CONF_API_KEY], + config.get(CONF_COUNTRY_CODE), + latitude, + longitude, + wait=False, + ) + + except ValueError as err: + err_str = str(err) + + if "Invalid authentication credentials" in err_str: + raise InvalidAuth from err + if "API rate limit exceeded." in err_str: + raise APIRatelimitExceeded from err + + _LOGGER.exception("Unexpected exception") + raise UnknownError from err + + else: + if "error" in data: + raise UnknownError(data["error"]) + + if data.get("status") != "ok": + _LOGGER.exception("Unexpected response: %s", data) + raise UnknownError + + return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py new file mode 100644 index 0000000000000..036282cb3e8fc --- /dev/null +++ b/homeassistant/components/co2signal/config_flow.py @@ -0,0 +1,128 @@ +"""Config flow for Co2signal integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from . import APIRatelimitExceeded, InvalidAuth, get_data +from .const import CONF_COUNTRY_CODE, DOMAIN +from .util import get_extra_name + +TYPE_USE_HOME = "Use home location" +TYPE_SPECIFY_COORDINATES = "Specify coordinates" +TYPE_SPECIFY_COUNTRY = "Specify country code" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Co2signal.""" + + VERSION = 1 + _data: dict | None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required("location", default=TYPE_USE_HOME): vol.In( + ( + TYPE_USE_HOME, + TYPE_SPECIFY_COORDINATES, + TYPE_SPECIFY_COUNTRY, + ) + ), + vol.Required(CONF_API_KEY): cv.string, + } + ) + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + + if user_input["location"] == TYPE_SPECIFY_COORDINATES: + self._data = data + return await self.async_step_coordinates() + + if user_input["location"] == TYPE_SPECIFY_COUNTRY: + self._data = data + return await self.async_step_country() + + return await self._validate_and_create("user", data_schema, data) + + async def async_step_coordinates( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Validate coordinates.""" + data_schema = vol.Schema( + { + vol.Required( + CONF_LATITUDE, + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + ): cv.longitude, + } + ) + if user_input is None: + return self.async_show_form(step_id="coordinates", data_schema=data_schema) + + assert self._data is not None + + return await self._validate_and_create( + "coordinates", data_schema, {**self._data, **user_input} + ) + + async def async_step_country( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Validate country.""" + data_schema = vol.Schema( + { + vol.Required(CONF_COUNTRY_CODE): cv.string, + } + ) + if user_input is None: + return self.async_show_form(step_id="country", data_schema=data_schema) + + assert self._data is not None + + return await self._validate_and_create( + "country", data_schema, {**self._data, **user_input} + ) + + async def _validate_and_create( + self, step_id: str, data_schema: vol.Schema, data: dict + ) -> FlowResult: + """Validate data and show form if it is invalid.""" + errors: dict[str, str] = {} + + try: + await self.hass.async_add_executor_job(get_data, self.hass, data) + except InvalidAuth: + errors["base"] = "invalid_auth" + except APIRatelimitExceeded: + errors["base"] = "api_ratelimit" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", + data=data, + ) + + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/co2signal/const.py b/homeassistant/components/co2signal/const.py new file mode 100644 index 0000000000000..a1264acc9ff97 --- /dev/null +++ b/homeassistant/components/co2signal/const.py @@ -0,0 +1,6 @@ +"""Constants for the Co2signal integration.""" + + +DOMAIN = "co2signal" +CONF_COUNTRY_CODE = "country_code" +ATTRIBUTION = "Data provided by CO2signal" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 50ed7f6203815..1921ae4f57523 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -2,7 +2,10 @@ "domain": "co2signal", "name": "CO2 Signal", "documentation": "https://www.home-assistant.io/integrations/co2signal", - "requirements": ["co2signal==0.4.2"], + "requirements": [ + "co2signal==0.4.2" + ], "codeowners": [], - "iot_class": "cloud_polling" -} + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index c7d2a64d6b0b5..c6582ac4c8c4e 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,111 +1,104 @@ """Support for the CO2signal platform.""" -import logging - -import CO2Signal -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_TOKEN, - ENERGY_KILO_WATT_HOUR, +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import cast + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.const import ATTR_ATTRIBUTION, PERCENTAGE +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import StateType + +from . import CO2SignalCoordinator, CO2SignalResponse +from .const import ATTRIBUTION, DOMAIN + +SCAN_INTERVAL = timedelta(minutes=3) + + +@dataclass +class CO2SensorEntityDescription: + """Provide a description of a CO2 sensor.""" + + key: str + name: str + unit_of_measurement: str | None = None + # For backwards compat, allow description to override unique ID key to use + unique_id: str | None = None + + +SENSORS = ( + CO2SensorEntityDescription( + key="carbonIntensity", + name="CO2 intensity", + unique_id="co2intensity", + # No unit, it's extracted from response. + ), + CO2SensorEntityDescription( + key="fossilFuelPercentage", + name="Grid fossil fuel percentage", + unit_of_measurement=PERCENTAGE, + ), ) -import homeassistant.helpers.config_validation as cv -CONF_COUNTRY_CODE = "country_code" -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by CO2signal" - -MSG_LOCATION = ( - "Please use either coordinates or the country code. " - "For the coordinates, " - "you need to use both latitude and longitude." -) -CO2_INTENSITY_UNIT = f"CO2eq/{ENERGY_KILO_WATT_HOUR}" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_TOKEN): cv.string, - vol.Inclusive(CONF_LATITUDE, "coords", msg=MSG_LOCATION): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coords", msg=MSG_LOCATION): cv.longitude, - vol.Optional(CONF_COUNTRY_CODE): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the CO2signal sensor.""" - token = config[CONF_TOKEN] - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - country_code = config.get(CONF_COUNTRY_CODE) - - _LOGGER.debug("Setting up the sensor using the %s", country_code) - - devs = [] + coordinator: CO2SignalCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(CO2Sensor(coordinator, description) for description in SENSORS) - devs.append(CO2Sensor(token, country_code, lat, lon)) - add_entities(devs, True) - -class CO2Sensor(SensorEntity): +class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorEntity): """Implementation of the CO2Signal sensor.""" - def __init__(self, token, country_code, lat, lon): - """Initialize the sensor.""" - self._token = token - self._country_code = country_code - self._latitude = lat - self._longitude = lon - self._data = None - - if country_code is not None: - device_name = country_code - else: - device_name = f"{round(self._latitude, 2)}/{round(self._longitude, 2)}" - - self._friendly_name = f"CO2 intensity - {device_name}" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:molecule-co2" - @property - def name(self): - """Return the name of the sensor.""" - return self._friendly_name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return "mdi:molecule-co2" + def __init__( + self, coordinator: CO2SignalCoordinator, description: CO2SensorEntityDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._description = description + + name = description.name + if extra_name := coordinator.get_extra_name(): + name = f"{extra_name} - {name}" + + self._attr_name = name + self._attr_extra_state_attributes = { + "country_code": coordinator.data["countryCode"], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + self._attr_device_info = DeviceInfo( + configuration_url="https://www.electricitymap.org/", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.entry_id)}, + manufacturer="Tmrow.com", + name="CO2 signal", + ) + self._attr_unique_id = ( + f"{coordinator.entry_id}_{description.unique_id or description.key}" + ) @property - def state(self): - """Return the state of the device.""" - return self._data + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.coordinator.data["data"].get(self._description.key) is not None + ) @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return CO2_INTENSITY_UNIT + def native_value(self) -> StateType: + """Return sensor state.""" + return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc] @property - def extra_state_attributes(self): - """Return the state attributes of the last update.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - def update(self): - """Get the latest data and updates the states.""" - - _LOGGER.debug("Update data for %s", self._friendly_name) - - if self._country_code is not None: - self._data = CO2Signal.get_latest_carbon_intensity( - self._token, country_code=self._country_code - ) - else: - self._data = CO2Signal.get_latest_carbon_intensity( - self._token, latitude=self._latitude, longitude=self._longitude - ) - - self._data = round(self._data, 2) + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if self._description.unit_of_measurement: + return self._description.unit_of_measurement + return cast(str, self.coordinator.data["units"].get(self._description.key)) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json new file mode 100644 index 0000000000000..2fe5b79c90789 --- /dev/null +++ b/homeassistant/components/co2signal/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location": "Get data for", + "api_key": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Visit https://co2signal.com/ to request a token." + }, + "coordinates": { + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + }, + "country": { + "data": { + "country_code": "Country code" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "api_ratelimit": "API Ratelimit exceeded" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "api_ratelimit": "API Ratelimit exceeded" + } + } +} diff --git a/homeassistant/components/co2signal/translations/bg.json b/homeassistant/components/co2signal/translations/bg.json new file mode 100644 index 0000000000000..bb253fb6e6b40 --- /dev/null +++ b/homeassistant/components/co2signal/translations/bg.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430" + } + }, + "country": { + "data": { + "country_code": "\u041a\u043e\u0434 \u043d\u0430 \u0434\u044a\u0440\u0436\u0430\u0432\u0430\u0442\u0430" + } + }, + "user": { + "data": { + "location": "\u041f\u043e\u043b\u0443\u0447\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/ca.json b/homeassistant/components/co2signal/translations/ca.json new file mode 100644 index 0000000000000..8a9539cfa971f --- /dev/null +++ b/homeassistant/components/co2signal/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "api_ratelimit": "S'ha superat la taxa l\u00edmit d'API", + "unknown": "Error inesperat" + }, + "error": { + "api_ratelimit": "S'ha superat la taxa l\u00edmit d'API", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + }, + "country": { + "data": { + "country_code": "Codi de pa\u00eds" + } + }, + "user": { + "data": { + "api_key": "Token d'acc\u00e9s", + "location": "Obt\u00e9 dades per" + }, + "description": "Visita https://co2signal.com/ per demanar un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/cs.json b/homeassistant/components/co2signal/translations/cs.json new file mode 100644 index 0000000000000..954168d1ee21b --- /dev/null +++ b/homeassistant/components/co2signal/translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + } + }, + "country": { + "data": { + "country_code": "K\u00f3d zem\u011b" + } + }, + "user": { + "data": { + "api_key": "P\u0159\u00edstupov\u00fd token" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/de.json b/homeassistant/components/co2signal/translations/de.json new file mode 100644 index 0000000000000..e35b991566f84 --- /dev/null +++ b/homeassistant/components/co2signal/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "api_ratelimit": "API Ratelimit \u00fcberschritten", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "api_ratelimit": "API Ratelimit \u00fcberschritten", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + } + }, + "country": { + "data": { + "country_code": "L\u00e4ndercode" + } + }, + "user": { + "data": { + "api_key": "Zugangstoken", + "location": "Daten abrufen f\u00fcr" + }, + "description": "Besuche https://co2signal.com/, um ein Token anzufordern." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/en.json b/homeassistant/components/co2signal/translations/en.json new file mode 100644 index 0000000000000..3d8cc7c9d9fb5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "api_ratelimit": "API Ratelimit exceeded", + "unknown": "Unexpected error" + }, + "error": { + "api_ratelimit": "API Ratelimit exceeded", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + }, + "country": { + "data": { + "country_code": "Country code" + } + }, + "user": { + "data": { + "api_key": "Access Token", + "location": "Get data for" + }, + "description": "Visit https://co2signal.com/ to request a token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/es-419.json b/homeassistant/components/co2signal/translations/es-419.json new file mode 100644 index 0000000000000..023c867ee9b82 --- /dev/null +++ b/homeassistant/components/co2signal/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "country": { + "data": { + "country_code": "C\u00f3digo de pa\u00eds" + } + }, + "user": { + "data": { + "location": "Obtener datos para" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/es.json b/homeassistant/components/co2signal/translations/es.json new file mode 100644 index 0000000000000..921dd22a76a87 --- /dev/null +++ b/homeassistant/components/co2signal/translations/es.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API", + "unknown": "Error inesperado" + }, + "error": { + "api_ratelimit": "Excedida tasa l\u00edmite del API", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + }, + "country": { + "data": { + "country_code": "C\u00f3digo del pa\u00eds" + } + }, + "user": { + "data": { + "api_key": "Token de acceso", + "location": "Obtener datos para" + }, + "description": "Visite https://co2signal.com/ para solicitar un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/et.json b/homeassistant/components/co2signal/translations/et.json new file mode 100644 index 0000000000000..a0d8f9db27f10 --- /dev/null +++ b/homeassistant/components/co2signal/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "api_ratelimit": "API p\u00e4rigute limiit on \u00fcletatud", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "api_ratelimit": "API p\u00e4rigute limiit on \u00fcletatud", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad" + } + }, + "country": { + "data": { + "country_code": "Riigi kood" + } + }, + "user": { + "data": { + "api_key": "Juurdep\u00e4\u00e4sut\u00f5end", + "location": "Hangi andmed" + }, + "description": "Loa taotlemiseks k\u00fclasta https://co2signal.com/." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/fr.json b/homeassistant/components/co2signal/translations/fr.json new file mode 100644 index 0000000000000..1ed60fd32278a --- /dev/null +++ b/homeassistant/components/co2signal/translations/fr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "api_ratelimit": "Limite de d\u00e9bit de l\u2019API d\u00e9pass\u00e9e", + "unknown": "Erreur inattendue" + }, + "error": { + "api_ratelimit": "Limite de d\u00e9bit API d\u00e9pass\u00e9e", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + }, + "country": { + "data": { + "country_code": "Code pays" + } + }, + "user": { + "data": { + "api_key": "Jeton d'acc\u00e8s", + "location": "Obtenir des donn\u00e9es pour" + }, + "description": "Visitez https://co2signal.com/ pour demander un jeton." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/he.json b/homeassistant/components/co2signal/translations/he.json new file mode 100644 index 0000000000000..9ff327c584b8a --- /dev/null +++ b/homeassistant/components/co2signal/translations/he.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + }, + "country": { + "data": { + "country_code": "\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05de\u05d3\u05d9\u05e0\u05d4" + } + }, + "user": { + "data": { + "api_key": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/hu.json b/homeassistant/components/co2signal/translations/hu.json new file mode 100644 index 0000000000000..77dcbddb8f85a --- /dev/null +++ b/homeassistant/components/co2signal/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "api_ratelimit": "API maxim\u00e1lis lek\u00e9r\u00e9ssz\u00e1m t\u00fall\u00e9pve", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "api_ratelimit": "API maxim\u00e1lis lek\u00e9r\u00e9ssz\u00e1m t\u00fall\u00e9pve", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + }, + "country": { + "data": { + "country_code": "Orsz\u00e1g k\u00f3d" + } + }, + "user": { + "data": { + "api_key": "Hozz\u00e1f\u00e9r\u00e9si token", + "location": "Adatok lek\u00e9rdez\u00e9se a" + }, + "description": "Token k\u00e9r\u00e9s\u00e9hez l\u00e1togasson el a https://co2signal.com/ webhelyre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/id.json b/homeassistant/components/co2signal/translations/id.json new file mode 100644 index 0000000000000..e323b25db2db0 --- /dev/null +++ b/homeassistant/components/co2signal/translations/id.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "api_ratelimit": "Batas Tingkat API terlampaui", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "api_ratelimit": "Batas Tingkat API terlampaui", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Lintang", + "longitude": "Bujur" + } + }, + "country": { + "data": { + "country_code": "Kode Negara" + } + }, + "user": { + "data": { + "api_key": "Token Akses", + "location": "Dapatkan data untuk" + }, + "description": "Kunjungi https://co2signal.com/ untuk meminta token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/it.json b/homeassistant/components/co2signal/translations/it.json new file mode 100644 index 0000000000000..0db63a1e9123a --- /dev/null +++ b/homeassistant/components/co2signal/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "api_ratelimit": "Limite di frequenza API superato", + "unknown": "Errore imprevisto" + }, + "error": { + "api_ratelimit": "Limite di frequenza API superato", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitudine", + "longitude": "Logitudine" + } + }, + "country": { + "data": { + "country_code": "Prefisso internazionale" + } + }, + "user": { + "data": { + "api_key": "Token di accesso", + "location": "Ottieni dati per" + }, + "description": "Visita https://co2signal.com/ per richiedere un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/ja.json b/homeassistant/components/co2signal/translations/ja.json new file mode 100644 index 0000000000000..cd3f422022d7d --- /dev/null +++ b/homeassistant/components/co2signal/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "api_ratelimit": "API\u30ec\u30fc\u30c8\u5236\u9650\u3092\u8d85\u3048\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "api_ratelimit": "API\u30ec\u30fc\u30c8\u5236\u9650\u3092\u8d85\u3048\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + } + }, + "country": { + "data": { + "country_code": "\u56fd\u5225\u30b3\u30fc\u30c9" + } + }, + "user": { + "data": { + "api_key": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "location": "\uff5e\u306e\u30c7\u30fc\u30bf\u3092\u53d6\u5f97" + }, + "description": "\u30c8\u30fc\u30af\u30f3\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u306b\u306f\u3001https://co2signal.com/ \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/nl.json b/homeassistant/components/co2signal/translations/nl.json new file mode 100644 index 0000000000000..54a7cd110cc8d --- /dev/null +++ b/homeassistant/components/co2signal/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "api_ratelimit": "API Ratelimit overschreden", + "unknown": "Onverwachte fout" + }, + "error": { + "api_ratelimit": "API Ratelimit overschreden", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + } + }, + "country": { + "data": { + "country_code": "Landcode" + } + }, + "user": { + "data": { + "api_key": "Toegangstoken", + "location": "Gegevens ophalen voor" + }, + "description": "Ga naar https://co2signal.com/ om een token aan te vragen." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/no.json b/homeassistant/components/co2signal/translations/no.json new file mode 100644 index 0000000000000..bb56f0c136471 --- /dev/null +++ b/homeassistant/components/co2signal/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "api_ratelimit": "API Ratelimit overskredet", + "unknown": "Uventet feil" + }, + "error": { + "api_ratelimit": "API Ratelimit overskredet", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + } + }, + "country": { + "data": { + "country_code": "Landskode" + } + }, + "user": { + "data": { + "api_key": "Tilgangstoken", + "location": "Hent data for" + }, + "description": "Bes\u00f8k https://co2signal.com/ for \u00e5 be om et token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/pl.json b/homeassistant/components/co2signal/translations/pl.json new file mode 100644 index 0000000000000..3b24364918056 --- /dev/null +++ b/homeassistant/components/co2signal/translations/pl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "api_ratelimit": "Przekroczono limit interfejsu API", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "error": { + "api_ratelimit": "Przekroczono limit interfejsu API", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna" + } + }, + "country": { + "data": { + "country_code": "Kod kraju" + } + }, + "user": { + "data": { + "api_key": "Token dost\u0119pu", + "location": "Pobierz dane dla" + }, + "description": "Odwied\u017a https://co2signal.com/, aby uzyska\u0107 token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/ru.json b/homeassistant/components/co2signal/translations/ru.json new file mode 100644 index 0000000000000..c2be73b3c26d5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/ru.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "api_ratelimit": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d \u043f\u0440\u0435\u0434\u0435\u043b \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 API.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "api_ratelimit": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d \u043f\u0440\u0435\u0434\u0435\u043b \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 API.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + } + }, + "country": { + "data": { + "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b" + } + }, + "user": { + "data": { + "api_key": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "location": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u043b\u044f" + }, + "description": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430 \u0441\u0430\u0439\u0442\u0435 https://co2signal.com/." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/tr.json b/homeassistant/components/co2signal/translations/tr.json new file mode 100644 index 0000000000000..038d8e85ff5c7 --- /dev/null +++ b/homeassistant/components/co2signal/translations/tr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "api_ratelimit": "API Ratelimit a\u015f\u0131ld\u0131", + "unknown": "Beklenmeyen hata" + }, + "error": { + "api_ratelimit": "API Ratelimit a\u015f\u0131ld\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + }, + "country": { + "data": { + "country_code": "\u00dclke kodu" + } + }, + "user": { + "data": { + "api_key": "Eri\u015fim Anahtar\u0131", + "location": "Veri alma" + }, + "description": "Bir anahtar istemek i\u00e7in https://co2signal.com/ adresini ziyaret edin." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/zh-Hans.json b/homeassistant/components/co2signal/translations/zh-Hans.json new file mode 100644 index 0000000000000..b883b58c215bd --- /dev/null +++ b/homeassistant/components/co2signal/translations/zh-Hans.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", + "api_ratelimit": "API \u8c03\u7528\u9891\u7387\u8d85\u9650", + "unknown": "\u975e\u9884\u671f\u7684\u9519\u8bef" + }, + "error": { + "api_ratelimit": "API \u8c03\u7528\u9891\u7387\u8d85\u9650", + "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", + "unknown": "\u975e\u9884\u671f\u7684\u9519\u8bef" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7eac\u5ea6", + "longitude": "\u7ecf\u5ea6" + } + }, + "country": { + "data": { + "country_code": "\u56fd\u5bb6/\u5730\u533a\u4ee3\u7801" + } + }, + "user": { + "data": { + "api_key": "\u8bbf\u95ee token", + "location": "\u83b7\u53d6\u6570\u636e\u7684\u4f4d\u7f6e" + }, + "description": "\u8bf7\u8bbf\u95ee https://co2signal.com/ \u6765\u83b7\u53d6 token\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/zh-Hant.json b/homeassistant/components/co2signal/translations/zh-Hant.json new file mode 100644 index 0000000000000..39cee0da0e53d --- /dev/null +++ b/homeassistant/components/co2signal/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "api_ratelimit": "\u8d85\u904e API \u5b58\u53d6\u9650\u5236", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "api_ratelimit": "\u8d85\u904e API \u5b58\u53d6\u9650\u5236", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + } + }, + "country": { + "data": { + "country_code": "\u570b\u78bc" + } + }, + "user": { + "data": { + "api_key": "\u5b58\u53d6\u6b0a\u6756", + "location": "\u53d6\u5f97\u8cc7\u6599\uff1a" + }, + "description": "\u700f\u89bd https://co2signal.com/ \u4ee5\u7372\u5f97\u6b0a\u6756\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py new file mode 100644 index 0000000000000..af0bec34904cf --- /dev/null +++ b/homeassistant/components/co2signal/util.py @@ -0,0 +1,19 @@ +"""Utils for CO2 signal.""" +from __future__ import annotations + +from collections.abc import Mapping + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE + +from .const import CONF_COUNTRY_CODE + + +def get_extra_name(config: Mapping) -> str | None: + """Return the extra name describing the location if not home.""" + if CONF_COUNTRY_CODE in config: + return config[CONF_COUNTRY_CODE] + + if CONF_LATITUDE in config: + return f"{round(config[CONF_LATITUDE], 2)}, {round(config[CONF_LONGITUDE], 2)}" + + return None diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 9fd99e993b600..238ff1db87d3c 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -1,4 +1,6 @@ -"""Support for Coinbase.""" +"""The Coinbase integration.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,92 +8,155 @@ from coinbase.wallet.error import AuthenticationError import voluptuous as vol -from homeassistant.const import CONF_API_KEY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "coinbase" +from .const import ( + API_ACCOUNT_ID, + API_ACCOUNTS_DATA, + CONF_CURRENCIES, + CONF_EXCHANGE_BASE, + CONF_EXCHANGE_RATES, + CONF_YAML_API_TOKEN, + DOMAIN, +) -CONF_API_SECRET = "api_secret" -CONF_ACCOUNT_CURRENCIES = "account_balance_currencies" -CONF_EXCHANGE_CURRENCIES = "exchange_rate_currencies" +_LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) -DATA_COINBASE = "coinbase_cache" CONFIG_SCHEMA = vol.Schema( + cv.deprecated(DOMAIN), { DOMAIN: vol.Schema( { vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_API_SECRET): cv.string, - vol.Optional(CONF_ACCOUNT_CURRENCIES): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): vol.All( + vol.Required(CONF_YAML_API_TOKEN): cv.string, + vol.Optional(CONF_CURRENCIES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCHANGE_RATES, default=[]): vol.All( cv.ensure_list, [cv.string] ), - } + }, ) }, extra=vol.ALLOW_EXTRA, ) -def setup(hass, config): - """Set up the Coinbase component. - - Will automatically setup sensors to support - wallets discovered on the network. - """ - api_key = config[DOMAIN][CONF_API_KEY] - api_secret = config[DOMAIN][CONF_API_SECRET] - account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) - exchange_currencies = config[DOMAIN][CONF_EXCHANGE_CURRENCIES] - - hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData(api_key, api_secret) - - if not hasattr(coinbase_data, "accounts"): - return False - for account in coinbase_data.accounts.data: - if account_currencies is None or account.currency in account_currencies: - load_platform(hass, "sensor", DOMAIN, {"account": account}, config) - for currency in exchange_currencies: - if currency not in coinbase_data.exchange_rates.rates: - _LOGGER.warning("Currency %s not found", currency) - continue - native = coinbase_data.exchange_rates.currency - load_platform( - hass, - "sensor", +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Coinbase component.""" + if DOMAIN not in config: + return True + hass.async_create_task( + hass.config_entries.flow.async_init( DOMAIN, - {"native_currency": native, "exchange_currency": currency}, - config, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Coinbase from a config entry.""" + + instance = await hass.async_add_executor_job(create_and_update_instance, entry) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + hass.data.setdefault(DOMAIN, {}) + + hass.data[DOMAIN][entry.entry_id] = instance + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: + """Create and update a Coinbase Data instance.""" + client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) + base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD") + instance = CoinbaseData(client, base_rate) + instance.update() + return instance + + +async def update_listener(hass, config_entry): + """Handle options update.""" + + await hass.config_entries.async_reload(config_entry.entry_id) + + registry = entity_registry.async_get(hass) + entities = entity_registry.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + + # Remove orphaned entities + for entity in entities: + currency = entity.unique_id.split("-")[-1] + if "xe" in entity.unique_id and currency not in config_entry.options.get( + CONF_EXCHANGE_RATES + ): + registry.async_remove(entity.entity_id) + elif "wallet" in entity.unique_id and currency not in config_entry.options.get( + CONF_CURRENCIES + ): + registry.async_remove(entity.entity_id) + + +def get_accounts(client): + """Handle paginated accounts.""" + response = client.get_accounts() + accounts = response[API_ACCOUNTS_DATA] + next_starting_after = response.pagination.next_starting_after + + while next_starting_after: + response = client.get_accounts(starting_after=next_starting_after) + accounts += response[API_ACCOUNTS_DATA] + next_starting_after = response.pagination.next_starting_after + + return accounts + + class CoinbaseData: """Get the latest data and update the states.""" - def __init__(self, api_key, api_secret): + def __init__(self, client, exchange_base): """Init the coinbase data object.""" - self.client = Client(api_key, api_secret) - self.update() + self.client = client + self.accounts = None + self.exchange_base = exchange_base + self.exchange_rates = None + self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from coinbase.""" try: - self.accounts = self.client.get_accounts() - self.exchange_rates = self.client.get_exchange_rates() + self.accounts = get_accounts(self.client) + self.exchange_rates = self.client.get_exchange_rates( + currency=self.exchange_base + ) except AuthenticationError as coinbase_error: _LOGGER.error( "Authentication error connecting to coinbase: %s", coinbase_error diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py new file mode 100644 index 0000000000000..37311bbd1afb0 --- /dev/null +++ b/homeassistant/components/coinbase/config_flow.py @@ -0,0 +1,251 @@ +"""Config flow for Coinbase integration.""" +import logging + +from coinbase.wallet.client import Client +from coinbase.wallet.error import AuthenticationError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from . import get_accounts +from .const import ( + API_ACCOUNT_CURRENCY, + API_RATES, + API_RESOURCE_TYPE, + API_TYPE_VAULT, + CONF_CURRENCIES, + CONF_EXCHANGE_BASE, + CONF_EXCHANGE_RATES, + CONF_OPTIONS, + CONF_YAML_API_TOKEN, + DOMAIN, + RATES, + WALLETS, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_API_TOKEN): str, + } +) + + +def get_user_from_client(api_key, api_token): + """Get the user name from Coinbase API credentials.""" + client = Client(api_key, api_token) + user = client.get_current_user() + return user + + +async def validate_api(hass: core.HomeAssistant, data): + """Validate the credentials.""" + + try: + user = await hass.async_add_executor_job( + get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN] + ) + except AuthenticationError as error: + if "api key" in str(error): + _LOGGER.debug("Coinbase rejected API credentials due to an invalid API key") + raise InvalidKey from error + if "invalid signature" in str(error): + _LOGGER.debug( + "Coinbase rejected API credentials due to an invalid API secret" + ) + raise InvalidSecret from error + _LOGGER.debug("Coinbase rejected API credentials due to an unknown error") + raise InvalidAuth from error + except ConnectionError as error: + raise CannotConnect from error + + return {"title": user["name"]} + + +async def validate_options( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry, options +): + """Validate the requested resources are provided by API.""" + + client = hass.data[DOMAIN][config_entry.entry_id].client + + accounts = await hass.async_add_executor_job(get_accounts, client) + + accounts_currencies = [ + account[API_ACCOUNT_CURRENCY] + for account in accounts + if account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ] + available_rates = await hass.async_add_executor_job(client.get_exchange_rates) + if CONF_CURRENCIES in options: + for currency in options[CONF_CURRENCIES]: + if currency not in accounts_currencies: + raise CurrencyUnavaliable + + if CONF_EXCHANGE_RATES in options: + for rate in options[CONF_EXCHANGE_RATES]: + if rate not in available_rates[API_RATES]: + raise ExchangeRateUnavaliable + + return True + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Coinbase.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + + options = {} + + if CONF_OPTIONS in user_input: + options = user_input.pop(CONF_OPTIONS) + + try: + info = await validate_api(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidKey: + errors["base"] = "invalid_auth_key" + except InvalidSecret: + errors["base"] = "invalid_auth_secret" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=info["title"], data=user_input, options=options + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, config): + """Handle import of Coinbase config from YAML.""" + + cleaned_data = { + CONF_API_KEY: config[CONF_API_KEY], + CONF_API_TOKEN: config[CONF_YAML_API_TOKEN], + } + cleaned_data[CONF_OPTIONS] = { + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: [], + } + if CONF_CURRENCIES in config: + cleaned_data[CONF_OPTIONS][CONF_CURRENCIES] = config[CONF_CURRENCIES] + if CONF_EXCHANGE_RATES in config: + cleaned_data[CONF_OPTIONS][CONF_EXCHANGE_RATES] = config[ + CONF_EXCHANGE_RATES + ] + + return await self.async_step_user(user_input=cleaned_data) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Coinbase.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + + errors = {} + default_currencies = self.config_entry.options.get(CONF_CURRENCIES, []) + default_exchange_rates = self.config_entry.options.get(CONF_EXCHANGE_RATES, []) + default_exchange_base = self.config_entry.options.get(CONF_EXCHANGE_BASE, "USD") + + if user_input is not None: + # Pass back user selected options, even if bad + if CONF_CURRENCIES in user_input: + default_currencies = user_input[CONF_CURRENCIES] + + if CONF_EXCHANGE_RATES in user_input: + default_exchange_rates = user_input[CONF_EXCHANGE_RATES] + + if CONF_EXCHANGE_RATES in user_input: + default_exchange_base = user_input[CONF_EXCHANGE_BASE] + + try: + await validate_options(self.hass, self.config_entry, user_input) + except CurrencyUnavaliable: + errors["base"] = "currency_unavaliable" + except ExchangeRateUnavaliable: + errors["base"] = "exchange_rate_unavaliable" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CURRENCIES, + default=default_currencies, + ): cv.multi_select(WALLETS), + vol.Optional( + CONF_EXCHANGE_RATES, + default=default_exchange_rates, + ): cv.multi_select(RATES), + vol.Optional( + CONF_EXCHANGE_BASE, + default=default_exchange_base, + ): vol.In(WALLETS), + } + ), + errors=errors, + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidSecret(exceptions.HomeAssistantError): + """Error to indicate auth failed due to invalid secret.""" + + +class InvalidKey(exceptions.HomeAssistantError): + """Error to indicate auth failed due to invalid key.""" + + +class AlreadyConfigured(exceptions.HomeAssistantError): + """Error to indicate Coinbase API Key is already configured.""" + + +class CurrencyUnavaliable(exceptions.HomeAssistantError): + """Error to indicate the requested currency resource is not provided by the API.""" + + +class ExchangeRateUnavaliable(exceptions.HomeAssistantError): + """Error to indicate the requested exchange rate resource is not provided by the API.""" diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py new file mode 100644 index 0000000000000..a01db46b0959c --- /dev/null +++ b/homeassistant/components/coinbase/const.py @@ -0,0 +1,502 @@ +"""Constants used for Coinbase.""" + +CONF_CURRENCIES = "account_balance_currencies" +CONF_EXCHANGE_BASE = "exchange_base" +CONF_EXCHANGE_RATES = "exchange_rate_currencies" +CONF_OPTIONS = "options" +DOMAIN = "coinbase" + +# These are constants used by the previous YAML configuration +CONF_YAML_API_TOKEN = "api_secret" + +# Constants for data returned by Coinbase API +API_ACCOUNT_AMOUNT = "amount" +API_ACCOUNT_BALANCE = "balance" +API_ACCOUNT_CURRENCY = "currency" +API_ACCOUNT_ID = "id" +API_ACCOUNT_NATIVE_BALANCE = "native_balance" +API_ACCOUNT_NAME = "name" +API_ACCOUNTS_DATA = "data" +API_RATES = "rates" +API_RESOURCE_TYPE = "type" +API_TYPE_VAULT = "vault" + +WALLETS = { + "1INCH": "1INCH", + "AAVE": "AAVE", + "ADA": "ADA", + "AED": "AED", + "AFN": "AFN", + "ALGO": "ALGO", + "ALL": "ALL", + "AMD": "AMD", + "AMP": "AMP", + "ANG": "ANG", + "ANKR": "ANKR", + "AOA": "AOA", + "ARS": "ARS", + "ATOM": "ATOM", + "AUCTION": "AUCTION", + "AUD": "AUD", + "AWG": "AWG", + "AZN": "AZN", + "BAL": "BAL", + "BAM": "BAM", + "BAND": "BAND", + "BAT": "BAT", + "BBD": "BBD", + "BCH": "BCH", + "BDT": "BDT", + "BGN": "BGN", + "BHD": "BHD", + "BIF": "BIF", + "BMD": "BMD", + "BND": "BND", + "BNT": "BNT", + "BOB": "BOB", + "BOND": "BOND", + "BRL": "BRL", + "BSD": "BSD", + "BSV": "BSV", + "BTC": "BTC", + "BTN": "BTN", + "BWP": "BWP", + "BYN": "BYN", + "BYR": "BYR", + "BZD": "BZD", + "CAD": "CAD", + "CDF": "CDF", + "CGLD": "CGLD", + "CHF": "CHF", + "CHZ": "CHZ", + "CLF": "CLF", + "CLP": "CLP", + "CLV": "CLV", + "CNH": "CNH", + "CNY": "CNY", + "COMP": "COMP", + "COP": "COP", + "CRC": "CRC", + "CRV": "CRV", + "CTSI": "CTSI", + "CUC": "CUC", + "CVC": "CVC", + "CVE": "CVE", + "CZK": "CZK", + "DAI": "DAI", + "DASH": "DASH", + "DJF": "DJF", + "DKK": "DKK", + "DNT": "DNT", + "DOGE": "DOGE", + "DOP": "DOP", + "DOT": "DOT", + "DZD": "DZD", + "EGP": "EGP", + "ENJ": "ENJ", + "EOS": "EOS", + "ERN": "ERN", + "ETB": "ETB", + "ETC": "ETC", + "ETH": "ETH", + "ETH2": "ETH2", + "EUR": "EUR", + "FET": "FET", + "FIL": "FIL", + "FJD": "FJD", + "FKP": "FKP", + "FORTH": "FORTH", + "GBP": "GBP", + "GBX": "GBX", + "GEL": "GEL", + "GGP": "GGP", + "GHS": "GHS", + "GIP": "GIP", + "GMD": "GMD", + "GNF": "GNF", + "GRT": "GRT", + "GTC": "GTC", + "GTQ": "GTQ", + "GYD": "GYD", + "HKD": "HKD", + "HNL": "HNL", + "HRK": "HRK", + "HTG": "HTG", + "HUF": "HUF", + "ICP": "ICP", + "IDR": "IDR", + "ILS": "ILS", + "IMP": "IMP", + "INR": "INR", + "IQD": "IQD", + "ISK": "ISK", + "JEP": "JEP", + "JMD": "JMD", + "JOD": "JOD", + "JPY": "JPY", + "KEEP": "KEEP", + "KES": "KES", + "KGS": "KGS", + "KHR": "KHR", + "KMF": "KMF", + "KNC": "KNC", + "KRW": "KRW", + "KWD": "KWD", + "KYD": "KYD", + "KZT": "KZT", + "LAK": "LAK", + "LBP": "LBP", + "LINK": "LINK", + "LKR": "LKR", + "LPT": "LPT", + "LRC": "LRC", + "LRD": "LRD", + "LSL": "LSL", + "LTC": "LTC", + "LYD": "LYD", + "MAD": "MAD", + "MANA": "MANA", + "MATIC": "MATIC", + "MDL": "MDL", + "MGA": "MGA", + "MIR": "MIR", + "MKD": "MKD", + "MKR": "MKR", + "MLN": "MLN", + "MMK": "MMK", + "MNT": "MNT", + "MOP": "MOP", + "MRO": "MRO", + "MTL": "MTL", + "MUR": "MUR", + "MVR": "MVR", + "MWK": "MWK", + "MXN": "MXN", + "MYR": "MYR", + "MZN": "MZN", + "NAD": "NAD", + "NGN": "NGN", + "NIO": "NIO", + "NKN": "NKN", + "NMR": "NMR", + "NOK": "NOK", + "NPR": "NPR", + "NU": "NU", + "NZD": "NZD", + "OGN": "OGN", + "OMG": "OMG", + "OMR": "OMR", + "OXT": "OXT", + "PAB": "PAB", + "PEN": "PEN", + "PGK": "PGK", + "PHP": "PHP", + "PKR": "PKR", + "PLN": "PLN", + "POLY": "POLY", + "PYG": "PYG", + "QAR": "QAR", + "QNT": "QNT", + "RLY": "RLY", + "REN": "REN", + "REP": "REP", + "REPV2": "REPV2", + "RLC": "RLC", + "RON": "RON", + "RSD": "RSD", + "RUB": "RUB", + "RWF": "RWF", + "SAR": "SAR", + "SBD": "SBD", + "SCR": "SCR", + "SEK": "SEK", + "SGD": "SGD", + "SHIB": "SHIB", + "SHP": "SHP", + "SKL": "SKL", + "SLL": "SLL", + "SNX": "SNX", + "SOL": "SOL", + "SOS": "SOS", + "SRD": "SRD", + "SSP": "SSP", + "STD": "STD", + "STORJ": "STORJ", + "SUSHI": "SUSHI", + "SVC": "SVC", + "SZL": "SZL", + "THB": "THB", + "TJS": "TJS", + "TMM": "TMM", + "TMT": "TMT", + "TND": "TND", + "TOP": "TOP", + "TRB": "TRB", + "TRY": "TRY", + "TTD": "TTD", + "TWD": "TWD", + "TZS": "TZS", + "UAH": "UAH", + "UGX": "UGX", + "UMA": "UMA", + "UNI": "UNI", + "USD": "USD", + "USDC": "USDC", + "USDT": "USDT", + "UYU": "UYU", + "UZS": "UZS", + "VES": "VES", + "VND": "VND", + "VUV": "VUV", + "WBTC": "WBTC", + "WST": "WST", + "XAF": "XAF", + "XAG": "XAG", + "XAU": "XAU", + "XCD": "XCD", + "XDR": "XDR", + "XLM": "XLM", + "XOF": "XOF", + "XPD": "XPD", + "XPF": "XPF", + "XPT": "XPT", + "XRP": "XRP", + "XTZ": "XTZ", + "YER": "YER", + "YFI": "YFI", + "ZAR": "ZAR", + "ZEC": "ZEC", + "ZMW": "ZMW", + "ZRX": "ZRX", + "ZWL": "ZWL", +} + +RATES = { + "1INCH": "1INCH", + "AAVE": "AAVE", + "ADA": "ADA", + "AED": "AED", + "AFN": "AFN", + "ALGO": "ALGO", + "ALL": "ALL", + "AMD": "AMD", + "ANG": "ANG", + "ANKR": "ANKR", + "AOA": "AOA", + "ARS": "ARS", + "ATOM": "ATOM", + "AUCTION": "AUCTION", + "AUD": "AUD", + "AWG": "AWG", + "AZN": "AZN", + "BAL": "BAL", + "BAM": "BAM", + "BAND": "BAND", + "BAT": "BAT", + "BBD": "BBD", + "BCH": "BCH", + "BDT": "BDT", + "BGN": "BGN", + "BHD": "BHD", + "BIF": "BIF", + "BMD": "BMD", + "BND": "BND", + "BNT": "BNT", + "BOB": "BOB", + "BRL": "BRL", + "BSD": "BSD", + "BSV": "BSV", + "BTC": "BTC", + "BTN": "BTN", + "BWP": "BWP", + "BYN": "BYN", + "BYR": "BYR", + "BZD": "BZD", + "CAD": "CAD", + "CDF": "CDF", + "CGLD": "CGLD", + "CHF": "CHF", + "CLF": "CLF", + "CLP": "CLP", + "CLV": "CLV", + "CNH": "CNH", + "CNY": "CNY", + "COMP": "COMP", + "COP": "COP", + "CRC": "CRC", + "CRV": "CRV", + "CUC": "CUC", + "CVC": "CVC", + "CVE": "CVE", + "CZK": "CZK", + "DAI": "DAI", + "DASH": "DASH", + "DJF": "DJF", + "DKK": "DKK", + "DNT": "DNT", + "DOP": "DOP", + "DZD": "DZD", + "EGP": "EGP", + "ENJ": "ENJ", + "EOS": "EOS", + "ERN": "ERN", + "ETB": "ETB", + "ETC": "ETC", + "ETH": "ETH", + "ETH2": "ETH2", + "EUR": "EUR", + "FET": "FET", + "FIL": "FIL", + "FJD": "FJD", + "FKP": "FKP", + "FORTH": "FORTH", + "GBP": "GBP", + "GBX": "GBX", + "GEL": "GEL", + "GGP": "GGP", + "GHS": "GHS", + "GIP": "GIP", + "GMD": "GMD", + "GNF": "GNF", + "GRT": "GRT", + "GTQ": "GTQ", + "GYD": "GYD", + "HKD": "HKD", + "HNL": "HNL", + "HRK": "HRK", + "HTG": "HTG", + "HUF": "HUF", + "IDR": "IDR", + "ILS": "ILS", + "IMP": "IMP", + "INR": "INR", + "IQD": "IQD", + "ISK": "ISK", + "JEP": "JEP", + "JMD": "JMD", + "JOD": "JOD", + "JPY": "JPY", + "KES": "KES", + "KGS": "KGS", + "KHR": "KHR", + "KMF": "KMF", + "KNC": "KNC", + "KRW": "KRW", + "KWD": "KWD", + "KYD": "KYD", + "KZT": "KZT", + "LAK": "LAK", + "LBP": "LBP", + "LINK": "LINK", + "LKR": "LKR", + "LRC": "LRC", + "LRD": "LRD", + "LSL": "LSL", + "LTC": "LTC", + "LYD": "LYD", + "MAD": "MAD", + "MANA": "MANA", + "MATIC": "MATIC", + "MDL": "MDL", + "MGA": "MGA", + "MKD": "MKD", + "MKR": "MKR", + "MMK": "MMK", + "MNT": "MNT", + "MOP": "MOP", + "MRO": "MRO", + "MTL": "MTL", + "MUR": "MUR", + "MVR": "MVR", + "MWK": "MWK", + "MXN": "MXN", + "MYR": "MYR", + "MZN": "MZN", + "NAD": "NAD", + "NGN": "NGN", + "NIO": "NIO", + "NKN": "NKN", + "NMR": "NMR", + "NOK": "NOK", + "NPR": "NPR", + "NU": "NU", + "NZD": "NZD", + "OGN": "OGN", + "OMG": "OMG", + "OMR": "OMR", + "OXT": "OXT", + "PAB": "PAB", + "PEN": "PEN", + "PGK": "PGK", + "PHP": "PHP", + "PKR": "PKR", + "PLN": "PLN", + "POLY": "POLY", + "PYG": "PYG", + "QAR": "QAR", + "RLY": "RLY", + "REN": "REN", + "REP": "REP", + "RON": "RON", + "RSD": "RSD", + "RUB": "RUB", + "RWF": "RWF", + "SAR": "SAR", + "SBD": "SBD", + "SCR": "SCR", + "SEK": "SEK", + "SGD": "SGD", + "SHIB": "SHIB", + "SHP": "SHP", + "SKL": "SKL", + "SLL": "SLL", + "SNX": "SNX", + "SOS": "SOS", + "SRD": "SRD", + "SSP": "SSP", + "STD": "STD", + "STORJ": "STORJ", + "SUSHI": "SUSHI", + "SVC": "SVC", + "SZL": "SZL", + "THB": "THB", + "TJS": "TJS", + "TMT": "TMT", + "TND": "TND", + "TOP": "TOP", + "TRY": "TRY", + "TTD": "TTD", + "TWD": "TWD", + "TZS": "TZS", + "UAH": "UAH", + "UGX": "UGX", + "UMA": "UMA", + "UNI": "UNI", + "USD": "USD", + "USDC": "USDC", + "UYU": "UYU", + "UZS": "UZS", + "VES": "VES", + "VND": "VND", + "VUV": "VUV", + "WBTC": "WBTC", + "WST": "WST", + "XAF": "XAF", + "XAG": "XAG", + "XAU": "XAU", + "XCD": "XCD", + "XDR": "XDR", + "XLM": "XLM", + "XOF": "XOF", + "XPD": "XPD", + "XPF": "XPF", + "XPT": "XPT", + "XTZ": "XTZ", + "YER": "YER", + "YFI": "YFI", + "ZAR": "ZAR", + "ZEC": "ZEC", + "ZMW": "ZMW", + "ZRX": "ZRX", + "ZWL": "ZWL", +} diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index 4579aecdd5bc9..aa056409786a0 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -2,7 +2,12 @@ "domain": "coinbase", "name": "Coinbase", "documentation": "https://www.home-assistant.io/integrations/coinbase", - "requirements": ["coinbase==2.1.0"], - "codeowners": [], + "requirements": [ + "coinbase==2.1.0" + ], + "codeowners": [ + "@tombrien" + ], + "config_flow": true, "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index e4e4e719c9e25..011dd63b15104 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -1,6 +1,27 @@ """Support for Coinbase sensors.""" -from homeassistant.components.sensor import SensorEntity +import logging + +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo + +from .const import ( + API_ACCOUNT_AMOUNT, + API_ACCOUNT_BALANCE, + API_ACCOUNT_CURRENCY, + API_ACCOUNT_ID, + API_ACCOUNT_NAME, + API_ACCOUNT_NATIVE_BALANCE, + API_RATES, + API_RESOURCE_TYPE, + API_TYPE_VAULT, + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) ATTR_NATIVE_BALANCE = "Balance in native currency" @@ -12,43 +33,87 @@ "USD": "mdi:currency-usd", } -DEFAULT_COIN_ICON = "mdi:currency-usd-circle" +DEFAULT_COIN_ICON = "mdi:cash" ATTRIBUTION = "Data provided by coinbase.com" -DATA_COINBASE = "coinbase_cache" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Coinbase sensor platform.""" + instance = hass.data[DOMAIN][config_entry.entry_id] -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Coinbase sensors.""" - if discovery_info is None: - return - if "account" in discovery_info: - account = discovery_info["account"] - sensor = AccountSensor( - hass.data[DATA_COINBASE], account["name"], account["balance"]["currency"] - ) - if "exchange_currency" in discovery_info: - sensor = ExchangeRateSensor( - hass.data[DATA_COINBASE], - discovery_info["exchange_currency"], - discovery_info["native_currency"], - ) + entities = [] + + provided_currencies = [ + account[API_ACCOUNT_CURRENCY] + for account in instance.accounts + if account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ] + + desired_currencies = [] + + if CONF_CURRENCIES in config_entry.options: + desired_currencies = config_entry.options[CONF_CURRENCIES] + + exchange_base_currency = instance.exchange_rates[API_ACCOUNT_CURRENCY] + + for currency in desired_currencies: + if currency not in provided_currencies: + _LOGGER.warning( + "The currency %s is no longer provided by your account, please check " + "your settings in Coinbase's developer tools", + currency, + ) + continue + entities.append(AccountSensor(instance, currency)) + + if CONF_EXCHANGE_RATES in config_entry.options: + for rate in config_entry.options[CONF_EXCHANGE_RATES]: + entities.append( + ExchangeRateSensor( + instance, + rate, + exchange_base_currency, + ) + ) - add_entities([sensor], True) + async_add_entities(entities) class AccountSensor(SensorEntity): """Representation of a Coinbase.com sensor.""" - def __init__(self, coinbase_data, name, currency): + def __init__(self, coinbase_data, currency): """Initialize the sensor.""" self._coinbase_data = coinbase_data - self._name = f"Coinbase {name}" - self._state = None - self._unit_of_measurement = currency - self._native_balance = None - self._native_currency = None + self._currency = currency + for account in coinbase_data.accounts: + if ( + account[API_ACCOUNT_CURRENCY] == currency + and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ): + self._name = f"Coinbase {account[API_ACCOUNT_NAME]}" + self._id = ( + f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" + f"{account[API_ACCOUNT_CURRENCY]}" + ) + self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] + self._unit_of_measurement = account[API_ACCOUNT_CURRENCY] + self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_AMOUNT + ] + self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_CURRENCY + ] + break + self._attr_state_class = SensorStateClass.TOTAL + self._attr_device_info = DeviceInfo( + configuration_url="https://www.coinbase.com/settings/api", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._coinbase_data.user_id)}, + manufacturer="Coinbase.com", + name=f"Coinbase {self._coinbase_data.user_id[-4:]}", + ) @property def name(self): @@ -56,12 +121,17 @@ def name(self): return self._name @property - def state(self): + def unique_id(self): + """Return the Unique ID of the sensor.""" + return self._id + + @property + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement @@ -81,23 +151,42 @@ def extra_state_attributes(self): def update(self): """Get the latest state of the sensor.""" self._coinbase_data.update() - for account in self._coinbase_data.accounts["data"]: - if self._name == f"Coinbase {account['name']}": - self._state = account["balance"]["amount"] - self._native_balance = account["native_balance"]["amount"] - self._native_currency = account["native_balance"]["currency"] + for account in self._coinbase_data.accounts: + if ( + account[API_ACCOUNT_CURRENCY] == self._currency + and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ): + self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] + self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_AMOUNT + ] + self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_CURRENCY + ] + break class ExchangeRateSensor(SensorEntity): """Representation of a Coinbase.com sensor.""" - def __init__(self, coinbase_data, exchange_currency, native_currency): + def __init__(self, coinbase_data, exchange_currency, exchange_base): """Initialize the sensor.""" self._coinbase_data = coinbase_data self.currency = exchange_currency self._name = f"{exchange_currency} Exchange Rate" - self._state = None - self._unit_of_measurement = native_currency + self._id = f"coinbase-{coinbase_data.user_id}-xe-{exchange_currency}" + self._state = round( + 1 / float(self._coinbase_data.exchange_rates[API_RATES][self.currency]), 2 + ) + self._unit_of_measurement = exchange_base + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_info = DeviceInfo( + configuration_url="https://www.coinbase.com/settings/api", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._coinbase_data.user_id)}, + manufacturer="Coinbase.com", + name=f"Coinbase {self._coinbase_data.user_id[-4:]}", + ) @property def name(self): @@ -105,12 +194,17 @@ def name(self): return self._name @property - def state(self): + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._id + + @property + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement @@ -127,5 +221,6 @@ def extra_state_attributes(self): def update(self): """Get the latest state of the sensor.""" self._coinbase_data.update() - rate = self._coinbase_data.exchange_rates.rates[self.currency] - self._state = round(1 / float(rate), 2) + self._state = round( + 1 / float(self._coinbase_data.exchange_rates.rates[self.currency]), 2 + ) diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json new file mode 100644 index 0000000000000..3e0b986365c3e --- /dev/null +++ b/homeassistant/components/coinbase/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "title": "Coinbase API Key Details", + "description": "Please enter the details of your API key as provided by Coinbase.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "api_token": "API Secret" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API Key.", + "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API Secret.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "description": "Adjust Coinbase Options", + "data": { + "account_balance_currencies": "Wallet balances to report.", + "exchange_rate_currencies": "Exchange rates to report.", + "exchange_base": "Base currency for exchange rate sensors." + } + } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]", + "currency_unavaliable": "One or more of the requested currency balances is not provided by your Coinbase API.", + "exchange_rate_unavaliable": "One or more of the requested exchange rates is not provided by Coinbase." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ar.json b/homeassistant/components/coinbase/translations/ar.json new file mode 100644 index 0000000000000..3065512663184 --- /dev/null +++ b/homeassistant/components/coinbase/translations/ar.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "\u0633\u0631 API", + "exchange_rates": "\u0623\u0633\u0639\u0627\u0631 \u0627\u0644\u0635\u0631\u0641" + }, + "description": "\u064a\u0631\u062c\u0649 \u0625\u062f\u062e\u0627\u0644 \u062a\u0641\u0627\u0635\u064a\u0644 \u0645\u0641\u062a\u0627\u062d API \u0627\u0644\u062e\u0627\u0635 \u0628\u0643 \u0639\u0644\u0649 \u0627\u0644\u0646\u062d\u0648 \u0627\u0644\u0645\u0646\u0635\u0648\u0635 \u0639\u0644\u064a\u0647 \u0645\u0646 \u0642\u0628\u0644 Coinbase.", + "title": "\u062a\u0641\u0627\u0635\u064a\u0644 \u0645\u0641\u062a\u0627\u062d Coinbase API" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u0644\u0627 \u064a\u062a\u0645 \u062a\u0648\u0641\u064a\u0631 \u0648\u0627\u062d\u062f \u0623\u0648 \u0623\u0643\u062b\u0631 \u0645\u0646 \u0623\u0631\u0635\u062f\u0629 \u0627\u0644\u0639\u0645\u0644\u0627\u062a \u0627\u0644\u0645\u0637\u0644\u0648\u0628\u0629 \u0628\u0648\u0627\u0633\u0637\u0629 Coinbase API \u0627\u0644\u062e\u0627\u0635 \u0628\u0643.", + "exchange_rate_unavaliable": "\u0644\u0627 \u064a\u062a\u0645 \u062a\u0648\u0641\u064a\u0631 \u0648\u0627\u062d\u062f \u0623\u0648 \u0623\u0643\u062b\u0631 \u0645\u0646 \u0623\u0633\u0639\u0627\u0631 \u0627\u0644\u0635\u0631\u0641 \u0627\u0644\u0645\u0637\u0644\u0648\u0628\u0629 \u0645\u0646 Coinbase.", + "unknown": "\u062d\u062f\u062b \u062e\u0637\u0623 \u063a\u064a\u0631 \u0645\u062a\u0648\u0642\u0639" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "\u0623\u0631\u0635\u062f\u0629 \u0627\u0644\u0645\u062d\u0641\u0638\u0629 \u0644\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646\u0647\u0627.", + "exchange_rate_currencies": "\u0623\u0633\u0639\u0627\u0631 \u0627\u0644\u0635\u0631\u0641 \u0644\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646\u0647\u0627." + }, + "description": "\u0636\u0628\u0637 \u062e\u064a\u0627\u0631\u0627\u062a Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/bg.json b/homeassistant/components/coinbase/translations/bg.json new file mode 100644 index 0000000000000..6888f4ddf354e --- /dev/null +++ b/homeassistant/components/coinbase/translations/bg.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + } + } + }, + "options": { + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ca.json b/homeassistant/components/coinbase/translations/ca.json new file mode 100644 index 0000000000000..e01597ad3d935 --- /dev/null +++ b/homeassistant/components/coinbase/translations/ca.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_auth_key": "Coinbase ha rebutjat les credencials API per culpa d'una clau API inv\u00e0lida.", + "invalid_auth_secret": "Coinbase ha rebutjat les credencials API per culpa d'un secret d'API inv\u00e0lid.", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "api_token": "Secret API", + "currencies": "Monedes del saldo del compte", + "exchange_rates": "Tipus de canvi" + }, + "description": "Introdueix els detalls de la teva clau API tal com els proporciona Coinbase.", + "title": "Detalls de la clau API de Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "L'API de Coinbase no proporciona algun/s dels saldos de moneda que has sol\u00b7licitat.", + "exchange_rate_unavaliable": "L'API de Coinbase no proporciona algun/s dels tipus de canvi que has sol\u00b7licitat.", + "unknown": "Error inesperat" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldos de cartera a informar.", + "exchange_base": "Moneda base per als sensors de canvi de tipus.", + "exchange_rate_currencies": "Tipus de canvi a informar." + }, + "description": "Ajusta les opcions de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/cs.json b/homeassistant/components/coinbase/translations/cs.json new file mode 100644 index 0000000000000..d25e431651d45 --- /dev/null +++ b/homeassistant/components/coinbase/translations/cs.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API", + "api_token": "API Secret", + "exchange_rates": "Sm\u011bnn\u00e9 kurzy" + }, + "title": "Podrobnosti o API kl\u00ed\u010di Coinbase" + } + } + }, + "options": { + "error": { + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "init": { + "data": { + "exchange_base": "Z\u00e1kladn\u00ed m\u011bna pro senzory sm\u011bnn\u00fdch kurz\u016f." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/de.json b/homeassistant/components/coinbase/translations/de.json new file mode 100644 index 0000000000000..76b45ae999a0c --- /dev/null +++ b/homeassistant/components/coinbase/translations/de.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "invalid_auth_key": "API-Anmeldeinformationen von Coinbase aufgrund eines ung\u00fcltigen API-Schl\u00fcssels abgelehnt.", + "invalid_auth_secret": "API-Anmeldeinformationen von Coinbase aufgrund eines ung\u00fcltigen API-Geheimnisses abgelehnt.", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "api_token": "API-Geheimnis", + "currencies": "Kontostand W\u00e4hrungen", + "exchange_rates": "Wechselkurse" + }, + "description": "Bitte gib die Details deines API-Schl\u00fcssels ein, wie von Coinbase bereitgestellt.", + "title": "Coinbase API Schl\u00fcssel Details" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Eine oder mehrere der angeforderten W\u00e4hrungssalden werden von deiner Coinbase-API nicht bereitgestellt.", + "exchange_rate_unavaliable": "Einer oder mehrere der angeforderten Wechselkurse werden nicht von Coinbase bereitgestellt.", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Zu meldende Wallet-Guthaben.", + "exchange_base": "Basisw\u00e4hrung f\u00fcr Wechselkurssensoren.", + "exchange_rate_currencies": "Zu meldende Wechselkurse." + }, + "description": "Coinbase-Optionen anpassen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/en.json b/homeassistant/components/coinbase/translations/en.json new file mode 100644 index 0000000000000..023dc37298ffe --- /dev/null +++ b/homeassistant/components/coinbase/translations/en.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API Key.", + "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API Secret.", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "api_token": "API Secret", + "currencies": "Account Balance Currencies", + "exchange_rates": "Exchange Rates" + }, + "description": "Please enter the details of your API key as provided by Coinbase.", + "title": "Coinbase API Key Details" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "One or more of the requested currency balances is not provided by your Coinbase API.", + "exchange_rate_unavaliable": "One or more of the requested exchange rates is not provided by Coinbase.", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Wallet balances to report.", + "exchange_base": "Base currency for exchange rate sensors.", + "exchange_rate_currencies": "Exchange rates to report." + }, + "description": "Adjust Coinbase Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/es-419.json b/homeassistant/components/coinbase/translations/es-419.json new file mode 100644 index 0000000000000..12acea8a7df5f --- /dev/null +++ b/homeassistant/components/coinbase/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "Secreto de la API", + "exchange_rates": "Tipos de cambio" + }, + "description": "Ingrese los detalles de su clave API proporcionada por Coinbase.", + "title": "Detalles clave de la API de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/es.json b/homeassistant/components/coinbase/translations/es.json new file mode 100644 index 0000000000000..9948ef5702002 --- /dev/null +++ b/homeassistant/components/coinbase/translations/es.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "api_token": "Secreto de la API", + "currencies": "Saldo de la cuenta Monedas", + "exchange_rates": "Tipos de cambio" + }, + "description": "Por favor, introduce los detalles de tu clave API tal y como te la ha proporcionado Coinbase.", + "title": "Detalles de la clave API de Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "La API de Coinbase no proporciona uno o m\u00e1s de los saldos de divisas solicitados.", + "exchange_rate_unavaliable": "Coinbase no proporciona uno o m\u00e1s de los tipos de cambio solicitados.", + "unknown": "Error inesperado" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldos de la cartera para informar.", + "exchange_base": "Moneda base para sensores de tipo de cambio.", + "exchange_rate_currencies": "Tipos de cambio a informar." + }, + "description": "Ajustar las opciones de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/et.json b/homeassistant/components/coinbase/translations/et.json new file mode 100644 index 0000000000000..0374765fd3505 --- /dev/null +++ b/homeassistant/components/coinbase/translations/et.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "invalid_auth_key": "Coinbase l\u00fckkas API mandaadid tagasi kehtetu API-v\u00f5tme t\u00f5ttu.", + "invalid_auth_secret": "Coinbase l\u00fckkas API volitused tagasi kuna API salas\u00f5na on kehtetu.", + "unknown": "Ootamtu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti", + "api_token": "API salas\u00f5na", + "currencies": "Konto saldo valuutad", + "exchange_rates": "Vahetuskursid" + }, + "description": "Sisesta Coinbase'i pakutava API-v\u00f5tme \u00fcksikasjad.", + "title": "Coinbase'i API v\u00f5tme \u00fcksikasjad" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Coinbase'i API ei paku \u00fchte v\u00f5i mitut taotletud valuutasaldot.", + "exchange_rate_unavaliable": "\u00dchte v\u00f5i mitut taotletud vahetuskurssi Coinbase ei paku.", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Rahakoti saldod teavitamine.", + "exchange_base": "Vahetuskursiandurite baasvaluuta.", + "exchange_rate_currencies": "Vahetuskursside aruanne." + }, + "description": "Kohanda Coinbase'i valikuid" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/fr.json b/homeassistant/components/coinbase/translations/fr.json new file mode 100644 index 0000000000000..101411edabecb --- /dev/null +++ b/homeassistant/components/coinbase/translations/fr.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 d'API", + "api_token": "API secr\u00e8te", + "currencies": "Devises du solde du compte", + "exchange_rates": "Taux d'\u00e9change" + }, + "description": "Veuillez saisir les d\u00e9tails de votre cl\u00e9 API tels que fournis par Coinbase.", + "title": "D\u00e9tails de la cl\u00e9 de l'API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Un ou plusieurs des soldes de devises demand\u00e9s ne sont pas fournis par votre API Coinbase.", + "exchange_rate_unavaliable": "Un ou plusieurs des taux de change demand\u00e9s ne sont pas fournis par Coinbase.", + "unknown": "Erreur inattendue" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Soldes du portefeuille \u00e0 d\u00e9clarer.", + "exchange_base": "Devise de base pour les capteurs de taux de change.", + "exchange_rate_currencies": "Taux de change \u00e0 d\u00e9clarer." + }, + "description": "Ajuster les options de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/he.json b/homeassistant/components/coinbase/translations/he.json new file mode 100644 index 0000000000000..3446e8e5ede6a --- /dev/null +++ b/homeassistant/components/coinbase/translations/he.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + }, + "options": { + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/hu.json b/homeassistant/components/coinbase/translations/hu.json new file mode 100644 index 0000000000000..5fb22f9be3b4a --- /dev/null +++ b/homeassistant/components/coinbase/translations/hu.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "Ismeretlen hiba" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs", + "api_token": "API jelsz\u00f3", + "currencies": "Sz\u00e1mlaegyenleg-p\u00e9nznemek", + "exchange_rates": "\u00c1rfolyamok" + }, + "description": "K\u00e9rj\u00fck, adja meg API kulcs\u00e1nak adatait a Coinbase \u00e1ltal megadott m\u00f3don.", + "title": "Coinbase API kulcs r\u00e9szletei" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "A k\u00e9rt valutaegyenlegek k\u00f6z\u00fcl egyet vagy t\u00f6bbet nem biztos\u00edt a Coinbase API.", + "exchange_rate_unavaliable": "A k\u00e9rt \u00e1rfolyamok k\u00f6z\u00fcl egyet vagy t\u00f6bbet a Coinbase nem biztos\u00edt.", + "unknown": "Ismeretlen hiba" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Jelentend\u0151 p\u00e9nzt\u00e1rca egyenlegek.", + "exchange_base": "Az \u00e1rfolyam-\u00e9rz\u00e9kel\u0151k alapvalut\u00e1ja.", + "exchange_rate_currencies": "Jelentend\u0151 \u00e1rfolyamok." + }, + "description": "\u00c1ll\u00edtsa be a Coinbase opci\u00f3kat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/id.json b/homeassistant/components/coinbase/translations/id.json new file mode 100644 index 0000000000000..0f83a78044b36 --- /dev/null +++ b/homeassistant/components/coinbase/translations/id.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API", + "api_token": "Kode Rahasia API", + "currencies": "Mata Uang Saldo Akun", + "exchange_rates": "Nilai Tukar" + }, + "description": "Silakan masukkan detail kunci API Anda sesuai yang disediakan oleh Coinbase.", + "title": "Detail Kunci API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Satu atau beberapa saldo mata uang yang diminta tidak disediakan oleh API Coinbase Anda.", + "exchange_rate_unavaliable": "Satu atau beberapa nilai tukar yang diminta tidak disediakan oleh Coinbase.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldo dompet untuk dilaporkan.", + "exchange_base": "Mata uang dasar untuk sensor nilai tukar.", + "exchange_rate_currencies": "Nilai tukar untuk dilaporkan." + }, + "description": "Sesuaikan Opsi Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/it.json b/homeassistant/components/coinbase/translations/it.json new file mode 100644 index 0000000000000..dcbb2317f3731 --- /dev/null +++ b/homeassistant/components/coinbase/translations/it.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "api_token": "API segreta", + "currencies": "Valute del saldo del conto", + "exchange_rates": "Tassi di cambio" + }, + "description": "Inserisci i dettagli della tua chiave API come forniti da Coinbase.", + "title": "Dettagli della chiave API di Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Uno o pi\u00f9 saldi in valuta richiesti non sono forniti dalla tua API Coinbase.", + "exchange_rate_unavaliable": "Uno o pi\u00f9 dei tassi di cambio richiesti non sono forniti da Coinbase.", + "unknown": "Errore imprevisto" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldi del portafoglio da segnalare.", + "exchange_base": "Valuta di base per i sensori di tasso di cambio.", + "exchange_rate_currencies": "Tassi di cambio da segnalare." + }, + "description": "Regola le opzioni di Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ja.json b/homeassistant/components/coinbase/translations/ja.json new file mode 100644 index 0000000000000..b640a490a9881 --- /dev/null +++ b/homeassistant/components/coinbase/translations/ja.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_auth_key": "API\u30ad\u30fc\u304c\u7121\u52b9\u306a\u305f\u3081\u3001Coinbase\u304cAPI\u8a8d\u8a3c\u3092\u62d2\u5426\u3057\u307e\u3057\u305f\u3002", + "invalid_auth_secret": "API\u30b7\u30fc\u30af\u30ec\u30c3\u30c8\u304c\u7121\u52b9\u306a\u305f\u3081\u3001Coinbase\u304cAPI\u8a8d\u8a3c\u3092\u62d2\u5426\u3057\u307e\u3057\u305f\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "api_token": "API\u30b7\u30fc\u30af\u30ec\u30c3\u30c8", + "currencies": "\u53e3\u5ea7\u6b8b\u9ad8 \u901a\u8ca8", + "exchange_rates": "\u70ba\u66ff\u30ec\u30fc\u30c8" + }, + "description": "Coinbase\u304b\u3089\u63d0\u4f9b\u3055\u308c\u305fAPI\u30ad\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Coinbase API\u30ad\u30fc\u306e\u8a73\u7d30" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u8981\u6c42\u3055\u308c\u305f\u901a\u8ca8\u6b8b\u9ad8\u306e1\u3064\u4ee5\u4e0a\u304c\u3001Coinbase API\u306b\u3088\u3063\u3066\u63d0\u4f9b\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "exchange_rate_unavaliable": "\u8981\u6c42\u3055\u308c\u305f\u70ba\u66ff\u30ec\u30fc\u30c8\u306e1\u3064\u4ee5\u4e0a\u304cCoinbase\u306b\u3088\u3063\u3066\u63d0\u4f9b\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "\u30a6\u30a9\u30ec\u30c3\u30c8\u306e\u6b8b\u9ad8\u3092\u5831\u544a\u3059\u308b\u3002", + "exchange_base": "\u70ba\u66ff\u30ec\u30fc\u30c8\u30bb\u30f3\u30b5\u30fc\u306e\u57fa\u6e96\u901a\u8ca8\u3002", + "exchange_rate_currencies": "\u30ec\u30dd\u30fc\u30c8\u3059\u3079\u304d\u70ba\u66ff\u30ec\u30fc\u30c8" + }, + "description": "Coinbase\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/nl.json b/homeassistant/components/coinbase/translations/nl.json new file mode 100644 index 0000000000000..2eebb52601561 --- /dev/null +++ b/homeassistant/components/coinbase/translations/nl.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "invalid_auth_key": "API-referenties geweigerd door Coinbase vanwege een ongeldige API-sleutel.", + "invalid_auth_secret": "API-gegevens geweigerd door Coinbase vanwege een ongeldig API-secret.", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "api_token": "API-geheim", + "currencies": "Valuta's van rekeningsaldo", + "exchange_rates": "Wisselkoersen" + }, + "description": "Voer de gegevens van uw API-sleutel in zoals verstrekt door Coinbase.", + "title": "Coinbase API Sleutel Details" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Een of meer van de gevraagde valutasaldi worden niet geleverd door uw Coinbase API.", + "exchange_rate_unavaliable": "Een of meer van de gevraagde wisselkoersen worden niet door Coinbase verstrekt.", + "unknown": "Onverwachte fout" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Wallet-saldi om te rapporteren.", + "exchange_base": "Basisvaluta voor wisselkoerssensoren.", + "exchange_rate_currencies": "Wisselkoersen om te rapporteren." + }, + "description": "Coinbase-opties aanpassen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/no.json b/homeassistant/components/coinbase/translations/no.json new file mode 100644 index 0000000000000..78cf46d717a8d --- /dev/null +++ b/homeassistant/components/coinbase/translations/no.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "api_token": "API-hemmelighet", + "currencies": "Valutaer for kontosaldo", + "exchange_rates": "Valutakurser" + }, + "description": "Vennligst skriv inn detaljene for API-n\u00f8kkelen din som gitt av Coinbase.", + "title": "Detaljer for Coinbase API-n\u00f8kkel" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "En eller flere av de forespurte valutasaldoene leveres ikke av Coinbase API.", + "exchange_rate_unavaliable": "En eller flere av de forespurte valutakursene leveres ikke av Coinbase.", + "unknown": "Uventet feil" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Lommeboksaldoer som skal rapporteres.", + "exchange_base": "Standardvaluta for valutakurssensorer.", + "exchange_rate_currencies": "Valutakurser som skal rapporteres." + }, + "description": "Juster Coinbase-alternativer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/pl.json b/homeassistant/components/coinbase/translations/pl.json new file mode 100644 index 0000000000000..8c269432b31cb --- /dev/null +++ b/homeassistant/components/coinbase/translations/pl.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "api_token": "Sekretne API", + "currencies": "Waluty salda konta", + "exchange_rates": "Kursy wymiany" + }, + "description": "Wprowad\u017a dane swojego klucza API podane przez Coinbase.", + "title": "Szczeg\u00f3\u0142y klucza API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Jeden lub wi\u0119cej \u017c\u0105danych sald walutowych nie jest dostarczanych przez interfejs API Coinbase.", + "exchange_rate_unavaliable": "Jeden lub wi\u0119cej z \u017c\u0105danych kurs\u00f3w wymiany nie jest dostarczany przez Coinbase.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Salda portfela do zg\u0142oszenia.", + "exchange_base": "Waluta bazowa dla czujnik\u00f3w kurs\u00f3w walut.", + "exchange_rate_currencies": "Kursy walut do zg\u0142oszenia." + }, + "description": "Dostosuj opcje Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ru.json b/homeassistant/components/coinbase/translations/ru.json new file mode 100644 index 0000000000000..dcbd0995d03c7 --- /dev/null +++ b/homeassistant/components/coinbase/translations/ru.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_auth_key": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 API \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u044b Coinbase \u0438\u0437-\u0437\u0430 \u043d\u0435\u0432\u0435\u0440\u043d\u043e\u0433\u043e \u043a\u043b\u044e\u0447\u0430 API.", + "invalid_auth_secret": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 API \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u044b Coinbase \u0438\u0437-\u0437\u0430 \u043d\u0435\u0432\u0435\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043a\u0440\u0435\u0442\u0430 API.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "api_token": "\u0421\u0435\u043a\u0440\u0435\u0442 API", + "currencies": "\u041e\u0441\u0442\u0430\u0442\u043e\u043a \u0432\u0430\u043b\u044e\u0442\u044b \u043d\u0430 \u0441\u0447\u0435\u0442\u0435", + "exchange_rates": "\u041e\u0431\u043c\u0435\u043d\u043d\u044b\u0435 \u043a\u0443\u0440\u0441\u044b" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0412\u0430\u0448\u0435\u0433\u043e \u043a\u043b\u044e\u0447\u0430 API Coinbase.", + "title": "\u041a\u043b\u044e\u0447 API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u041e\u0434\u0438\u043d \u0438\u043b\u0438 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0445 \u043e\u0441\u0442\u0430\u0442\u043a\u043e\u0432 \u0432\u0430\u043b\u044e\u0442\u044b \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u0412\u0430\u0448\u0438\u043c API Coinbase.", + "exchange_rate_unavaliable": "Coinbase \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 \u043e\u0434\u0438\u043d \u0438\u043b\u0438 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0445 \u043e\u0431\u043c\u0435\u043d\u043d\u044b\u0445 \u043a\u0443\u0440\u0441\u043e\u0432.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "\u0411\u0430\u043b\u0430\u043d\u0441\u044b \u043a\u043e\u0448\u0435\u043b\u044c\u043a\u0430 \u0434\u043b\u044f \u043e\u0442\u0447\u0435\u0442\u043d\u043e\u0441\u0442\u0438.", + "exchange_base": "\u0411\u0430\u0437\u043e\u0432\u0430\u044f \u0432\u0430\u043b\u044e\u0442\u0430 \u0434\u043b\u044f \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u043e\u0431\u043c\u0435\u043d\u043d\u043e\u0433\u043e \u043a\u0443\u0440\u0441\u0430.", + "exchange_rate_currencies": "\u041a\u0443\u0440\u0441\u044b \u0432\u0430\u043b\u044e\u0442 \u0434\u043b\u044f \u043e\u0442\u0447\u0435\u0442\u043d\u043e\u0441\u0442\u0438." + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/tr.json b/homeassistant/components/coinbase/translations/tr.json new file mode 100644 index 0000000000000..1bb4a6eca4325 --- /dev/null +++ b/homeassistant/components/coinbase/translations/tr.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_auth_key": "Ge\u00e7ersiz bir API Anahtar\u0131 nedeniyle Coinbase taraf\u0131ndan reddedilen API kimlik bilgileri.", + "invalid_auth_secret": "Ge\u00e7ersiz bir API Gizlilik nedeniyle Coinbase taraf\u0131ndan reddedilen API kimlik bilgileri.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "api_token": "API Gizli Anahtar\u0131", + "currencies": "Hesap Bakiyesi Para Birimleri", + "exchange_rates": "D\u00f6viz Kurlar\u0131" + }, + "description": "L\u00fctfen API anahtar\u0131n\u0131z\u0131n ayr\u0131nt\u0131lar\u0131n\u0131 Coinbase taraf\u0131ndan sa\u011flanan \u015fekilde girin.", + "title": "Coinbase API Anahtar Ayr\u0131nt\u0131lar\u0131" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u0130stenen para birimi bakiyelerinden biri veya daha fazlas\u0131 Coinbase API'niz taraf\u0131ndan sa\u011flanm\u0131yor.", + "exchange_rate_unavaliable": "\u0130stenen d\u00f6viz kurlar\u0131ndan biri veya daha fazlas\u0131 Coinbase taraf\u0131ndan sa\u011flanm\u0131yor.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Rapor edilecek c\u00fczdan bakiyeleri.", + "exchange_base": "D\u00f6viz kuru sens\u00f6rleri i\u00e7in temel para birimi.", + "exchange_rate_currencies": "Raporlanacak d\u00f6viz kurlar\u0131." + }, + "description": "Coinbase Se\u00e7eneklerini Ayarlay\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/zh-Hans.json b/homeassistant/components/coinbase/translations/zh-Hans.json new file mode 100644 index 0000000000000..1a5eaa19decf8 --- /dev/null +++ b/homeassistant/components/coinbase/translations/zh-Hans.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", + "unknown": "\u975e\u9884\u671f\u7684\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u94a5", + "api_token": "API Token", + "currencies": "\u8d26\u6237\u4f59\u989d", + "exchange_rates": "\u6c47\u7387" + }, + "description": "\u8bf7\u8f93\u5165\u7531 Coinbase \u63d0\u4f9b\u7684 API \u5bc6\u94a5\u4fe1\u606f", + "title": "Coinbase API \u5bc6\u94a5\u8be6\u60c5" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Coinbase \u65e0\u6cd5\u63d0\u4f9b\u5176\u8bbe\u5b9a\u7684\u6c47\u7387\u4fe1\u606f", + "exchange_rate_unavaliable": "Coinbase \u65e0\u6cd5\u63d0\u4f9b\u5176\u8bbe\u5b9a\u7684\u6c47\u7387\u4fe1\u606f", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "init": { + "description": "\u8c03\u6574 Coinbase \u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/zh-Hant.json b/homeassistant/components/coinbase/translations/zh-Hant.json new file mode 100644 index 0000000000000..03b9333fef12e --- /dev/null +++ b/homeassistant/components/coinbase/translations/zh-Hant.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_auth_key": "API \u91d1\u9470\u7121\u6548\u3001Coinbase \u62d2\u7d55\u6191\u8b49\u3002", + "invalid_auth_secret": "API \u79c1\u9470\u7121\u6548\u3001Coinbase \u62d2\u7d55\u6191\u8b49\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "api_key": "API \u91d1\u9470", + "api_token": "API \u79c1\u9470", + "currencies": "\u5e33\u6236\u9918\u984d\u8ca8\u5e63", + "exchange_rates": "\u532f\u7387" + }, + "description": "\u8acb\u8f38\u5165\u7531 Coinbase \u63d0\u4f9b\u7684 API \u91d1\u9470\u8cc7\u8a0a\u3002", + "title": "Coinbase API \u91d1\u9470\u8cc7\u6599" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Coinbase API \u672a\u63d0\u4f9b\u4e00\u500b\u6216\u591a\u500b\u6240\u8981\u6c42\u7684\u8ca8\u5e63\u9918\u984d\u3002", + "exchange_rate_unavaliable": "Coinbase \u672a\u63d0\u4f9b\u4e00\u500b\u6216\u591a\u500b\u6240\u8981\u6c42\u7684\u532f\u7387\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "\u5e33\u6236\u9918\u984d\u56de\u5831\u503c\u3002", + "exchange_base": "\u532f\u7387\u611f\u6e2c\u5668\u57fa\u6e96\u8ca8\u5e63\u3002", + "exchange_rate_currencies": "\u532f\u7387\u56de\u5831\u503c\u3002" + }, + "description": "\u8abf\u6574 Coinbase \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index ddd2ae967e4a5..4d8118483b676 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -9,12 +9,6 @@ from colorthief import ColorThief import voluptuous as vol -from homeassistant.components.color_extractor.const import ( - ATTR_PATH, - ATTR_URL, - DOMAIN, - SERVICE_TURN_ON, -) from homeassistant.components.light import ( ATTR_RGB_COLOR, DOMAIN as LIGHT_DOMAIN, @@ -24,6 +18,8 @@ from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv +from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON + _LOGGER = logging.getLogger(__name__) # Extend the existing light.turn_on service schema @@ -117,7 +113,7 @@ async def async_extract_color_from_url(url): try: session = aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await session.get(url) except (asyncio.TimeoutError, aiohttp.ClientError) as err: diff --git a/homeassistant/components/color_extractor/services.yaml b/homeassistant/components/color_extractor/services.yaml index 00438dc9aa17f..be278a5905942 100644 --- a/homeassistant/components/color_extractor/services.yaml +++ b/homeassistant/components/color_extractor/services.yaml @@ -4,6 +4,8 @@ turn_on: Set the light RGB to the predominant color found in the image provided by URL or file path. target: + entity: + domain: light fields: color_extract_url: name: URL diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 5d4ec6eec1337..080b31036d9fd 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -1,4 +1,6 @@ """Support for ComEd Hourly Pricing data.""" +from __future__ import annotations + import asyncio from datetime import timedelta import json @@ -8,7 +10,11 @@ import async_timeout import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,12 +31,22 @@ CONF_MONITORED_FEEDS = "monitored_feeds" CONF_SENSOR_TYPE = "type" -SENSOR_TYPES = { - CONF_FIVE_MINUTE: ["ComEd 5 Minute Price", "c"], - CONF_CURRENT_HOUR_AVERAGE: ["ComEd Current Hour Average Price", "c"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CONF_FIVE_MINUTE, + name="ComEd 5 Minute Price", + native_unit_of_measurement="c", + ), + SensorEntityDescription( + key=CONF_CURRENT_HOUR_AVERAGE, + name="ComEd Current Hour Average Price", + native_unit_of_measurement="c", + ), +) -TYPES_SCHEMA = vol.In(SENSOR_TYPES) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + +TYPES_SCHEMA = vol.In(SENSOR_KEYS) SENSORS_SCHEMA = vol.Schema( { @@ -48,77 +64,57 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the ComEd Hourly Pricing sensor.""" websession = async_get_clientsession(hass) - dev = [] - - for variable in config[CONF_MONITORED_FEEDS]: - dev.append( - ComedHourlyPricingSensor( - hass.loop, - websession, - variable[CONF_SENSOR_TYPE], - variable[CONF_OFFSET], - variable.get(CONF_NAME), - ) + + entities = [ + ComedHourlyPricingSensor( + websession, + variable[CONF_OFFSET], + variable.get(CONF_NAME), + description, ) + for variable in config[CONF_MONITORED_FEEDS] + for description in SENSOR_TYPES + if description.key == variable[CONF_SENSOR_TYPE] + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class ComedHourlyPricingSensor(SensorEntity): """Implementation of a ComEd Hourly Pricing sensor.""" - def __init__(self, loop, websession, sensor_type, offset, name): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__(self, websession, offset, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.loop = loop + self.entity_description = description self.websession = websession if name: - self._name = name - else: - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type + self._attr_name = name self.offset = offset - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} async def async_update(self): """Get the ComEd Hourly Pricing data from the web service.""" try: - if self.type == CONF_FIVE_MINUTE or self.type == CONF_CURRENT_HOUR_AVERAGE: + sensor_type = self.entity_description.key + if sensor_type in (CONF_FIVE_MINUTE, CONF_CURRENT_HOUR_AVERAGE): url_string = _RESOURCE - if self.type == CONF_FIVE_MINUTE: + if sensor_type == CONF_FIVE_MINUTE: url_string += "?type=5minutefeed" else: url_string += "?type=currenthouraverage" - with async_timeout.timeout(60): + async with async_timeout.timeout(60): response = await self.websession.get(url_string) # The API responds with MIME type 'text/html' text = await response.text() data = json.loads(text) - self._state = round(float(data[0]["price"]) + self.offset, 2) + self._attr_native_value = round( + float(data[0]["price"]) + self.offset, 2 + ) else: - self._state = None + self._attr_native_value = None except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Could not get data from ComEd API: %s", err) diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 2a13283738828..22e39e373e4bb 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -90,7 +90,6 @@ class ComfoConnectBridge: def __init__(self, hass, bridge, name, token, friendly_name, pin): """Initialize the ComfoConnect bridge.""" - self.data = {} self.name = name self.hass = hass self.unique_id = bridge.uuid.hex() diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 53bc242ba2ff0..0cd1738a94ad7 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -3,6 +3,7 @@ import logging import math +from typing import Any from pycomfoconnect import ( CMD_FAN_MODE_AWAY, @@ -38,18 +39,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ComfoConnect fan platform.""" ccb = hass.data[DOMAIN] - add_entities([ComfoConnectFan(ccb.name, ccb)], True) + add_entities([ComfoConnectFan(ccb)], True) class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" - def __init__(self, name, ccb: ComfoConnectBridge) -> None: + current_speed = None + + def __init__(self, ccb: ComfoConnectBridge) -> None: """Initialize the ComfoConnect fan.""" self._ccb = ccb - self._name = name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register for sensor updates.""" _LOGGER.debug("Registering for fan speed") self.async_on_remove( @@ -68,7 +70,7 @@ def _handle_update(self, value): _LOGGER.debug( "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value ) - self._ccb.data[SENSOR_FAN_SPEED_MODE] = value + self.current_speed = value self.schedule_update_ha_state() @property @@ -84,7 +86,7 @@ def unique_id(self): @property def name(self): """Return the name of the fan.""" - return self._name + return self._ccb.name @property def icon(self): @@ -99,10 +101,9 @@ def supported_features(self) -> int: @property def percentage(self) -> int | None: """Return the current speed percentage.""" - speed = self._ccb.data.get(SENSOR_FAN_SPEED_MODE) - if speed is None: + if self.current_speed is None: return None - return ranged_value_to_percentage(SPEED_RANGE, speed) + return ranged_value_to_percentage(SPEED_RANGE, self.current_speed) @property def speed_count(self) -> int: @@ -110,28 +111,30 @@ def speed_count(self) -> int: return int_states_in_range(SPEED_RANGE) def turn_on( - self, speed: str = None, percentage=None, preset_mode=None, **kwargs + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs, ) -> None: """Turn on the fan.""" - self.set_percentage(percentage) + if percentage is None: + self.set_percentage(1) # Set fan speed to low + else: + self.set_percentage(percentage) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn off the fan (to away).""" self.set_percentage(0) - def set_percentage(self, percentage: int): + def set_percentage(self, percentage: int) -> None: """Set fan speed percentage.""" _LOGGER.debug("Changing fan speed percentage to %s", percentage) - if percentage is None: - cmd = CMD_FAN_MODE_LOW - elif percentage == 0: + if percentage == 0: cmd = CMD_FAN_MODE_AWAY else: speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) cmd = CMD_MAPPING[speed] self._ccb.comfoconnect.cmd_rmi_request(cmd) - - # Update current mode - self.schedule_update_ha_state() diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 728bc13b76bdb..2503fb275830c 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -1,4 +1,5 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" +from dataclasses import dataclass import logging from pycomfoconnect import ( @@ -26,16 +27,15 @@ ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_ID, CONF_RESOURCES, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, @@ -72,187 +72,216 @@ _LOGGER = logging.getLogger(__name__) -ATTR_LABEL = "label" -ATTR_MULTIPLIER = "multiplier" -ATTR_UNIT = "unit" -SENSOR_TYPES = { - ATTR_CURRENT_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Inside Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_EXTRACT, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_CURRENT_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Inside Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_EXTRACT, - }, - ATTR_CURRENT_RMOT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Current RMOT", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_CURRENT_RMOT, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_OUTSIDE_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Outside Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_OUTDOOR, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_OUTSIDE_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Outside Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_OUTDOOR, - }, - ATTR_SUPPLY_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Supply Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_SUPPLY, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_SUPPLY_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Supply Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_SUPPLY, - }, - ATTR_SUPPLY_FAN_SPEED: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply Fan Speed", - ATTR_UNIT: "rpm", - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_SPEED, - }, - ATTR_SUPPLY_FAN_DUTY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply Fan Duty", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_DUTY, - }, - ATTR_EXHAUST_FAN_SPEED: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust Fan Speed", - ATTR_UNIT: "rpm", - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_SPEED, - }, - ATTR_EXHAUST_FAN_DUTY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust Fan Duty", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_DUTY, - }, - ATTR_EXHAUST_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Exhaust Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_EXHAUST, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_EXHAUST_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Exhaust Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_EXHAUST, - }, - ATTR_AIR_FLOW_SUPPLY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply airflow", - ATTR_UNIT: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, - }, - ATTR_AIR_FLOW_EXHAUST: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust airflow", - ATTR_UNIT: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, - }, - ATTR_BYPASS_STATE: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Bypass State", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:camera-iris", - ATTR_ID: SENSOR_BYPASS_STATE, - }, - ATTR_DAYS_TO_REPLACE_FILTER: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Days to replace filter", - ATTR_UNIT: TIME_DAYS, - ATTR_ICON: "mdi:calendar", - ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, - }, - ATTR_POWER_CURRENT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LABEL: "Power usage", - ATTR_UNIT: POWER_WATT, - ATTR_ICON: None, - ATTR_ID: SENSOR_POWER_CURRENT, - }, - ATTR_POWER_TOTAL: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LABEL: "Power total", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_ICON: None, - ATTR_ID: SENSOR_POWER_TOTAL, - }, - ATTR_PREHEATER_POWER_CURRENT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LABEL: "Preheater power usage", - ATTR_UNIT: POWER_WATT, - ATTR_ICON: None, - ATTR_ID: SENSOR_PREHEATER_POWER_CURRENT, - }, - ATTR_PREHEATER_POWER_TOTAL: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LABEL: "Preheater power total", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_ICON: None, - ATTR_ID: SENSOR_PREHEATER_POWER_TOTAL, - }, -} +@dataclass +class ComfoconnectRequiredKeysMixin: + """Mixin for required keys.""" + + sensor_id: int + + +@dataclass +class ComfoconnectSensorEntityDescription( + SensorEntityDescription, ComfoconnectRequiredKeysMixin +): + """Describes Comfoconnect sensor entity.""" + + multiplier: float = 1 + + +SENSOR_TYPES = ( + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Inside temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_EXTRACT, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + name="Inside humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_EXTRACT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_RMOT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Current RMOT", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_CURRENT_RMOT, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_OUTSIDE_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Outside temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_OUTDOOR, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_OUTSIDE_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + name="Outside humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_OUTDOOR, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Supply temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_SUPPLY, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + name="Supply humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_SUPPLY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_FAN_SPEED, + state_class=SensorStateClass.MEASUREMENT, + name="Supply fan speed", + native_unit_of_measurement="rpm", + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_SPEED, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_FAN_DUTY, + state_class=SensorStateClass.MEASUREMENT, + name="Supply fan duty", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_DUTY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_FAN_SPEED, + state_class=SensorStateClass.MEASUREMENT, + name="Exhaust fan speed", + native_unit_of_measurement="rpm", + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_SPEED, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_FAN_DUTY, + state_class=SensorStateClass.MEASUREMENT, + name="Exhaust fan duty", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_DUTY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Exhaust temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_EXHAUST, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + name="Exhaust humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_EXHAUST, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_AIR_FLOW_SUPPLY, + state_class=SensorStateClass.MEASUREMENT, + name="Supply airflow", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_FLOW, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_AIR_FLOW_EXHAUST, + state_class=SensorStateClass.MEASUREMENT, + name="Exhaust airflow", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_FLOW, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_BYPASS_STATE, + state_class=SensorStateClass.MEASUREMENT, + name="Bypass state", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:camera-iris", + sensor_id=SENSOR_BYPASS_STATE, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_DAYS_TO_REPLACE_FILTER, + name="Days to replace filter", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:calendar", + sensor_id=SENSOR_DAYS_TO_REPLACE_FILTER, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_POWER_CURRENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + name="Power usage", + native_unit_of_measurement=POWER_WATT, + sensor_id=SENSOR_POWER_CURRENT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_POWER_TOTAL, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + name="Energy total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=SENSOR_POWER_TOTAL, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_PREHEATER_POWER_CURRENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + name="Preheater power usage", + native_unit_of_measurement=POWER_WATT, + sensor_id=SENSOR_PREHEATER_POWER_CURRENT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_PREHEATER_POWER_TOTAL, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + name="Preheater energy total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=SENSOR_PREHEATER_POWER_TOTAL, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In([desc.key for desc in SENSOR_TYPES])] ) } ) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ComfoConnect fan platform.""" + """Set up the ComfoConnect sensor platform.""" ccb = hass.data[DOMAIN] - sensors = [] - for resource in config[CONF_RESOURCES]: - sensors.append( - ComfoConnectSensor( - name=f"{ccb.name} {SENSOR_TYPES[resource][ATTR_LABEL]}", - ccb=ccb, - sensor_type=resource, - ) - ) + sensors = [ + ComfoConnectSensor(ccb=ccb, description=description) + for description in SENSOR_TYPES + if description.key in config[CONF_RESOURCES] + ] add_entities(sensors, True) @@ -260,76 +289,47 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ComfoConnectSensor(SensorEntity): """Representation of a ComfoConnect sensor.""" - def __init__(self, name, ccb: ComfoConnectBridge, sensor_type) -> None: + _attr_should_poll = False + entity_description: ComfoconnectSensorEntityDescription + + def __init__( + self, + ccb: ComfoConnectBridge, + description: ComfoconnectSensorEntityDescription, + ) -> None: """Initialize the ComfoConnect sensor.""" self._ccb = ccb - self._sensor_type = sensor_type - self._sensor_id = SENSOR_TYPES[self._sensor_type][ATTR_ID] - self._name = name + self.entity_description = description + self._attr_name = f"{ccb.name} {description.name}" + self._attr_unique_id = f"{ccb.unique_id}-{description.key}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register for sensor updates.""" _LOGGER.debug( - "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id + "Registering for sensor %s (%d)", + self.entity_description.key, + self.entity_description.sensor_id, ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format( + self.entity_description.sensor_id + ), self._handle_update, ) ) await self.hass.async_add_executor_job( - self._ccb.comfoconnect.register_sensor, self._sensor_id + self._ccb.comfoconnect.register_sensor, self.entity_description.sensor_id ) def _handle_update(self, value): """Handle update callbacks.""" _LOGGER.debug( "Handle update for sensor %s (%d): %s", - self._sensor_type, - self._sensor_id, + self.entity_description.key, + self.entity_description.sensor_id, value, ) - self._ccb.data[self._sensor_id] = round( - value * SENSOR_TYPES[self._sensor_type].get(ATTR_MULTIPLIER, 1), 2 - ) + self._attr_native_value = round(value * self.entity_description.multiplier, 2) self.schedule_update_ha_state() - - @property - def state(self): - """Return the state of the entity.""" - try: - return self._ccb.data[self._sensor_id] - except KeyError: - return None - - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self._ccb.unique_id}-{self._sensor_type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return SENSOR_TYPES[self._sensor_type][ATTR_ICON] - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] - - @property - def device_class(self): - """Return the device_class.""" - return SENSOR_TYPES[self._sensor_type][ATTR_DEVICE_CLASS] diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 10c5a16f60b37..43e05a429b625 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -84,12 +84,12 @@ def name(self): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/command_line/services.yaml b/homeassistant/components/command_line/services.yaml index de010ba8b850d..f4cec42686063 100644 --- a/homeassistant/components/command_line/services.yaml +++ b/homeassistant/components/command_line/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all command_line entities diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index ae6c1c0c925f9..fae4cdbcc6bf4 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -13,6 +13,7 @@ CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_FRIENDLY_NAME, + CONF_ICON_TEMPLATE, CONF_SWITCHES, CONF_VALUE_TEMPLATE, ) @@ -31,6 +32,7 @@ vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } ) @@ -54,6 +56,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if value_template is not None: value_template.hass = hass + icon_template = device_config.get(CONF_ICON_TEMPLATE) + if icon_template is not None: + icon_template.hass = hass + switches.append( CommandSwitch( hass, @@ -62,6 +68,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_config[CONF_COMMAND_ON], device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), + icon_template, value_template, device_config[CONF_COMMAND_TIMEOUT], ) @@ -85,6 +92,7 @@ def __init__( command_on, command_off, command_state, + icon_template, value_template, timeout, ): @@ -96,6 +104,7 @@ def __init__( self._command_on = command_on self._command_off = command_off self._command_state = command_state + self._icon_template = icon_template self._value_template = value_template self._timeout = timeout @@ -152,6 +161,10 @@ def update(self): """Update device state.""" if self._command_state: payload = str(self._query_state()) + if self._icon_template: + self._attr_icon = self._icon_template.render_with_possible_json_value( + payload + ) if self._value_template: payload = self._value_template.render_with_possible_json_value(payload) self._state = payload.lower() == "true" diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 9c4cd3449a920..315d1b705df5e 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.20.2"], + "requirements": ["numpy==1.21.4"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 35ca07ce52215..110326bc1b28e 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -107,7 +107,7 @@ def should_poll(self): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -123,15 +123,14 @@ def extra_state_attributes(self): return ret @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @callback def _async_compensation_sensor_state_listener(self, event): """Handle sensor state changes.""" - new_state = event.data.get("new_state") - if new_state is None: + if (new_state := event.data.get("new_state")) is None: return if self._unit_of_measurement is None and self._source_attribute is None: diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index f502e805c853d..a936c199e618b 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -7,7 +7,9 @@ import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.components.alarm_control_panel import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, +) from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, @@ -33,7 +35,7 @@ SCAN_INTERVAL = datetime.timedelta(seconds=10) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index 7fd1d9748b337..fac16c834d9ac 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -7,12 +7,9 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, - DEVICE_CLASSES, + DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_HOST, CONF_PORT @@ -31,7 +28,7 @@ SCAN_INTERVAL = datetime.timedelta(seconds=10) -ZONE_TYPES_SCHEMA = vol.Schema({cv.positive_int: vol.In(DEVICE_CLASSES)}) +ZONE_TYPES_SCHEMA = vol.Schema({cv.positive_int: BINARY_SENSOR_DEVICE_CLASSES_SCHEMA}) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -89,14 +86,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def get_opening_type(zone): """Return the result of the type guessing from name.""" if "MOTION" in zone["name"]: - return DEVICE_CLASS_MOTION + return BinarySensorDeviceClass.MOTION if "KEY" in zone["name"]: - return DEVICE_CLASS_SAFETY + return BinarySensorDeviceClass.SAFETY if "SMOKE" in zone["name"]: - return DEVICE_CLASS_SMOKE + return BinarySensorDeviceClass.SMOKE if "WATER" in zone["name"]: return "water" - return DEVICE_CLASS_OPENING + return BinarySensorDeviceClass.OPENING class Concord232ZoneSensor(BinarySensorEntity): diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 7d07710a4d00c..ff7b1e4d4cd7e 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,20 +1,17 @@ """Component to configure Home Assistant via an API.""" import asyncio +from http import HTTPStatus import importlib import os import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CONF_ID, - EVENT_COMPONENT_LOADED, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, -) +from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import ATTR_COMPONENT +from homeassistant.util.file import write_utf8_file_atomic from homeassistant.util.yaml import dump, load_yaml DOMAIN = "config" @@ -25,7 +22,6 @@ "automation", "config_entries", "core", - "customize", "device_registry", "entity_registry", "group", @@ -125,7 +121,7 @@ async def get(self, request, config_key): value = self._get_value(hass, current, config_key) if value is None: - return self.json_message("Resource not found", HTTP_NOT_FOUND) + return self.json_message("Resource not found", HTTPStatus.NOT_FOUND) return self.json(value) @@ -134,12 +130,12 @@ async def post(self, request, config_key): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message(f"Key malformed: {err}", HTTP_BAD_REQUEST) + return self.json_message(f"Key malformed: {err}", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -151,7 +147,9 @@ async def post(self, request, config_key): else: self.data_schema(data) except (vol.Invalid, HomeAssistantError) as err: - return self.json_message(f"Message malformed: {err}", HTTP_BAD_REQUEST) + return self.json_message( + f"Message malformed: {err}", HTTPStatus.BAD_REQUEST + ) path = hass.config.path(self.path) @@ -177,7 +175,7 @@ async def delete(self, request, config_key): path = hass.config.path(self.path) if value is None: - return self.json_message("Resource not found", HTTP_NOT_FOUND) + return self.json_message("Resource not found", HTTPStatus.BAD_REQUEST) self._delete_value(hass, current, config_key) await hass.async_add_executor_job(_write, path, current) @@ -228,9 +226,7 @@ def _get_value(self, hass, data, config_key): def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(hass, data, config_key) - - if value is None: + if (value := self._get_value(hass, data, config_key)) is None: value = {CONF_ID: config_key} data.append(value) @@ -256,6 +252,5 @@ def _write(path, data): """Write YAML helper.""" # Do it before opening file. If dump causes error it will now not # truncate the file. - data = dump(data) - with open(path, "w", encoding="utf-8") as outfile: - outfile.write(data) + contents = dump(data) + write_utf8_file_atomic(path, contents) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index f40ed7834e3d6..09cd1c1c8ce9a 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -2,124 +2,102 @@ import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.decorators import ( - async_response, - require_admin, -) from homeassistant.core import callback -from homeassistant.helpers.area_registry import async_get_registry - -WS_TYPE_LIST = "config/area_registry/list" -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_LIST} -) - -WS_TYPE_CREATE = "config/area_registry/create" -SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_CREATE, vol.Required("name"): str} -) - -WS_TYPE_DELETE = "config/area_registry/delete" -SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_DELETE, vol.Required("area_id"): str} -) - -WS_TYPE_UPDATE = "config/area_registry/update" -SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_UPDATE, - vol.Required("area_id"): str, - vol.Required("name"): str, - } -) +from homeassistant.helpers.area_registry import async_get async def async_setup(hass): """Enable the Area Registry views.""" - hass.components.websocket_api.async_register_command( - WS_TYPE_LIST, websocket_list_areas, SCHEMA_WS_LIST - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_CREATE, websocket_create_area, SCHEMA_WS_CREATE - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE, websocket_delete_area, SCHEMA_WS_DELETE - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE, websocket_update_area, SCHEMA_WS_UPDATE - ) + hass.components.websocket_api.async_register_command(websocket_list_areas) + hass.components.websocket_api.async_register_command(websocket_create_area) + hass.components.websocket_api.async_register_command(websocket_delete_area) + hass.components.websocket_api.async_register_command(websocket_update_area) return True -@async_response -async def websocket_list_areas(hass, connection, msg): +@websocket_api.websocket_command({vol.Required("type"): "config/area_registry/list"}) +@callback +def websocket_list_areas(hass, connection, msg): """Handle list areas command.""" - registry = await async_get_registry(hass) - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - {"name": entry.name, "area_id": entry.id} - for entry in registry.async_list_areas() - ], - ) + registry = async_get(hass) + connection.send_result( + msg["id"], + [_entry_dict(entry) for entry in registry.async_list_areas()], ) -@require_admin -@async_response -async def websocket_create_area(hass, connection, msg): +@websocket_api.websocket_command( + { + vol.Required("type"): "config/area_registry/create", + vol.Required("name"): str, + vol.Optional("picture"): vol.Any(str, None), + } +) +@websocket_api.require_admin +@callback +def websocket_create_area(hass, connection, msg): """Create area command.""" - registry = await async_get_registry(hass) + registry = async_get(hass) + + data = dict(msg) + data.pop("type") + data.pop("id") + try: - entry = registry.async_create(msg["name"]) + entry = registry.async_create(**data) except ValueError as err: - connection.send_message( - websocket_api.error_message(msg["id"], "invalid_info", str(err)) - ) + connection.send_error(msg["id"], "invalid_info", str(err)) else: - connection.send_message( - websocket_api.result_message(msg["id"], _entry_dict(entry)) - ) + connection.send_result(msg["id"], _entry_dict(entry)) -@require_admin -@async_response -async def websocket_delete_area(hass, connection, msg): +@websocket_api.websocket_command( + { + vol.Required("type"): "config/area_registry/delete", + vol.Required("area_id"): str, + } +) +@websocket_api.require_admin +@callback +def websocket_delete_area(hass, connection, msg): """Delete area command.""" - registry = await async_get_registry(hass) + registry = async_get(hass) try: registry.async_delete(msg["area_id"]) except KeyError: - connection.send_message( - websocket_api.error_message( - msg["id"], "invalid_info", "Area ID doesn't exist" - ) - ) + connection.send_error(msg["id"], "invalid_info", "Area ID doesn't exist") else: connection.send_message(websocket_api.result_message(msg["id"], "success")) -@require_admin -@async_response -async def websocket_update_area(hass, connection, msg): +@websocket_api.websocket_command( + { + vol.Required("type"): "config/area_registry/update", + vol.Required("area_id"): str, + vol.Optional("name"): str, + vol.Optional("picture"): vol.Any(str, None), + } +) +@websocket_api.require_admin +@callback +def websocket_update_area(hass, connection, msg): """Handle update area websocket command.""" - registry = await async_get_registry(hass) + registry = async_get(hass) + + data = dict(msg) + data.pop("type") + data.pop("id") try: - entry = registry.async_update(msg["area_id"], msg["name"]) + entry = registry.async_update(**data) except ValueError as err: - connection.send_message( - websocket_api.error_message(msg["id"], "invalid_info", str(err)) - ) + connection.send_error(msg["id"], "invalid_info", str(err)) else: - connection.send_message( - websocket_api.result_message(msg["id"], _entry_dict(entry)) - ) + connection.send_result(msg["id"], _entry_dict(entry)) @callback def _entry_dict(entry): """Convert entry to API format.""" - return {"area_id": entry.id, "name": entry.name} + return {"area_id": entry.id, "name": entry.name, "picture": entry.picture} diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 54d992466f9c3..c46ef78b0b250 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -48,9 +48,7 @@ async def websocket_delete(hass, connection, msg): ) return - user = await hass.auth.async_get_user(msg["user_id"]) - - if not user: + if not (user := await hass.auth.async_get_user(msg["user_id"])): connection.send_message( websocket_api.error_message(msg["id"], "not_found", "User not found") ) @@ -68,11 +66,14 @@ async def websocket_delete(hass, connection, msg): vol.Required("type"): "config/auth/create", vol.Required("name"): str, vol.Optional("group_ids"): [str], + vol.Optional("local_only"): bool, } ) async def websocket_create(hass, connection, msg): """Create a user.""" - user = await hass.auth.async_create_user(msg["name"], msg.get("group_ids")) + user = await hass.auth.async_create_user( + msg["name"], group_ids=msg.get("group_ids"), local_only=msg.get("local_only") + ) connection.send_message( websocket_api.result_message(msg["id"], {"user": _user_info(user)}) @@ -88,13 +89,12 @@ async def websocket_create(hass, connection, msg): vol.Optional("name"): str, vol.Optional("is_active"): bool, vol.Optional("group_ids"): [str], + vol.Optional("local_only"): bool, } ) async def websocket_update(hass, connection, msg): """Update a user.""" - user = await hass.auth.async_get_user(msg.pop("user_id")) - - if not user: + if not (user := await hass.auth.async_get_user(msg.pop("user_id"))): connection.send_message( websocket_api.error_message( msg["id"], websocket_api.const.ERR_NOT_FOUND, "User not found" @@ -150,6 +150,7 @@ def _user_info(user): "name": user.name, "is_owner": user.is_owner, "is_active": user.is_active, + "local_only": user.local_only, "system_generated": user.system_generated, "group_ids": [group.id for group in user.groups], "credentials": [{"type": c.auth_provider_type} for c in user.credentials], diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index a8421c4c0f640..590ab4bff1a8b 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -31,9 +31,8 @@ async def async_setup(hass): async def websocket_create(hass, connection, msg): """Create credentials and attach to a user.""" provider = auth_ha.async_get_provider(hass) - user = await hass.auth.async_get_user(msg["user_id"]) - if user is None: + if (user := await hass.auth.async_get_user(msg["user_id"])) is None: connection.send_error(msg["id"], "not_found", "User not found") return @@ -103,8 +102,7 @@ async def websocket_delete(hass, connection, msg): @websocket_api.async_response async def websocket_change_password(hass, connection, msg): """Change current user password.""" - user = connection.user - if user is None: + if (user := connection.user) is None: connection.send_error(msg["id"], "user_not_found", "User not found") return @@ -150,9 +148,7 @@ async def websocket_admin_change_password(hass, connection, msg): if not connection.user.is_owner: raise Unauthorized(context=connection.context(msg)) - user = await hass.auth.async_get_user(msg["user_id"]) - - if user is None: + if (user := await hass.auth.async_get_user(msg["user_id"])) is None: connection.send_error(msg["id"], "user_not_found", "User not found") return diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 11c39089f35c8..a30d65f598256 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,4 +1,8 @@ """Http views to control the config manager.""" +from __future__ import annotations + +from http import HTTPStatus + import aiohttp.web_exceptions import voluptuous as vol @@ -6,8 +10,7 @@ from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, @@ -31,8 +34,6 @@ async def async_setup(hass): hass.components.websocket_api.async_register_command(config_entry_disable) hass.components.websocket_api.async_register_command(config_entry_update) hass.components.websocket_api.async_register_command(config_entries_progress) - hass.components.websocket_api.async_register_command(system_options_list) - hass.components.websocket_api.async_register_command(system_options_update) hass.components.websocket_api.async_register_command(ignore_config_flow) return True @@ -69,7 +70,7 @@ async def delete(self, request, entry_id): try: result = await hass.config_entries.async_remove(entry_id) except config_entries.UnknownEntry: - return self.json_message("Invalid entry specified", HTTP_NOT_FOUND) + return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) return self.json(result) @@ -90,9 +91,9 @@ async def post(self, request, entry_id): try: result = await hass.config_entries.async_reload(entry_id) except config_entries.OperationNotAllowed: - return self.json_message("Entry cannot be reloaded", HTTP_FORBIDDEN) + return self.json_message("Entry cannot be reloaded", HTTPStatus.FORBIDDEN) except config_entries.UnknownEntry: - return self.json_message("Invalid entry specified", HTTP_NOT_FOUND) + return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) return self.json({"require_restart": not result}) @@ -116,6 +117,7 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): async def get(self, request): """Not implemented.""" + # pylint: disable=no-self-use raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) # pylint: disable=arguments-differ @@ -231,31 +233,23 @@ def config_entries_progress(hass, connection, msg): ) -@websocket_api.require_admin -@websocket_api.async_response -@websocket_api.websocket_command( - {"type": "config_entries/system_options/list", "entry_id": str} -) -async def system_options_list(hass, connection, msg): - """List all system options for a config entry.""" - entry_id = msg["entry_id"] - entry = hass.config_entries.async_get_entry(entry_id) - - if entry: - connection.send_result(msg["id"], entry.system_options.as_dict()) - - -def send_entry_not_found(connection, msg_id): +def send_entry_not_found( + connection: websocket_api.ActiveConnection, msg_id: int +) -> None: """Send Config entry not found error.""" connection.send_error( msg_id, websocket_api.const.ERR_NOT_FOUND, "Config entry not found" ) -def get_entry(hass, connection, entry_id, msg_id): +def get_entry( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + entry_id: str, + msg_id: int, +) -> config_entries.ConfigEntry | None: """Get entry, send error message if it doesn't exist.""" - entry = hass.config_entries.async_get_entry(entry_id) - if entry is None: + if (entry := hass.config_entries.async_get_entry(entry_id)) is None: send_entry_not_found(connection, msg_id) return entry @@ -264,13 +258,15 @@ def get_entry(hass, connection, entry_id, msg_id): @websocket_api.async_response @websocket_api.websocket_command( { - "type": "config_entries/system_options/update", + "type": "config_entries/update", "entry_id": str, - vol.Optional("disable_new_entities"): bool, + vol.Optional("title"): str, + vol.Optional("pref_disable_new_entities"): bool, + vol.Optional("pref_disable_polling"): bool, } ) -async def system_options_update(hass, connection, msg): - """Update config entry system options.""" +async def config_entry_update(hass, connection, msg): + """Update config entry.""" changes = dict(msg) changes.pop("id") changes.pop("type") @@ -280,28 +276,25 @@ async def system_options_update(hass, connection, msg): if entry is None: return - hass.config_entries.async_update_entry(entry, system_options=changes) - connection.send_result(msg["id"], entry.system_options.as_dict()) + old_disable_polling = entry.pref_disable_polling + hass.config_entries.async_update_entry(entry, **changes) -@websocket_api.require_admin -@websocket_api.async_response -@websocket_api.websocket_command( - {"type": "config_entries/update", "entry_id": str, vol.Optional("title"): str} -) -async def config_entry_update(hass, connection, msg): - """Update config entry.""" - changes = dict(msg) - changes.pop("id") - changes.pop("type") - changes.pop("entry_id") + result = { + "config_entry": entry_json(entry), + "require_restart": False, + } - entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) - if entry is None: - return + if ( + old_disable_polling != entry.pref_disable_polling + and entry.state is config_entries.ConfigEntryState.LOADED + ): + if not await hass.config_entries.async_reload(entry.entry_id): + result["require_restart"] = ( + entry.state is config_entries.ConfigEntryState.FAILED_UNLOAD + ) - hass.config_entries.async_update_entry(entry, **changes) - connection.send_result(msg["id"], entry_json(entry)) + connection.send_result(msg["id"], result) @websocket_api.require_admin @@ -311,7 +304,8 @@ async def config_entry_update(hass, connection, msg): "type": "config_entries/disable", "entry_id": str, # We only allow setting disabled_by user via API. - "disabled_by": vol.Any(config_entries.DISABLED_USER, None), + # No Enum support like this in voluptuous, use .value + "disabled_by": vol.Any(config_entries.ConfigEntryDisabler.USER.value, None), } ) async def config_entry_disable(hass, connection, msg): @@ -373,21 +367,21 @@ async def ignore_config_flow(hass, connection, msg): def entry_json(entry: config_entries.ConfigEntry) -> dict: """Return JSON value of a config entry.""" handler = config_entries.HANDLERS.get(entry.domain) - supports_options = ( - # Guard in case handler is no longer registered (custom component etc) - handler is not None - # pylint: disable=comparison-with-callable - and handler.async_get_options_flow - != config_entries.ConfigFlow.async_get_options_flow + # work out if handler has support for options flow + supports_options = handler is not None and handler.async_supports_options_flow( + entry ) + return { "entry_id": entry.entry_id, "domain": entry.domain, "title": entry.title, "source": entry.source, - "state": entry.state, + "state": entry.state.value, "supports_options": supports_options, "supports_unload": entry.supports_unload, + "pref_disable_new_entities": entry.pref_disable_new_entities, + "pref_disable_polling": entry.pref_disable_polling, "disabled_by": entry.disabled_by, "reason": entry.reason, } diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 89f4edc95d65f..a6b39e556aaf3 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -44,8 +44,9 @@ async def post(self, request): vol.Optional("unit_system"): cv.unit_system, vol.Optional("location_name"): str, vol.Optional("time_zone"): cv.time_zone, - vol.Optional("external_url"): vol.Any(cv.url, None), - vol.Optional("internal_url"): vol.Any(cv.url, None), + vol.Optional("external_url"): vol.Any(cv.url_no_path, None), + vol.Optional("internal_url"): vol.Any(cv.url_no_path, None), + vol.Optional("currency"): cv.currency, } ) async def websocket_update_config(hass, connection, msg): @@ -89,4 +90,7 @@ async def websocket_detect_config(hass, connection, msg): if location_info.time_zone: info["time_zone"] = location_info.time_zone + if location_info.currency: + info["currency"] = location_info.currency + connection.send_result(msg["id"], info) diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py deleted file mode 100644 index 3b1122fc3a57b..0000000000000 --- a/homeassistant/components/config/customize.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Provide configuration end points for Customize.""" -from homeassistant.components.homeassistant import SERVICE_RELOAD_CORE_CONFIG -from homeassistant.config import DATA_CUSTOMIZE -from homeassistant.core import DOMAIN -import homeassistant.helpers.config_validation as cv - -from . import EditKeyBasedConfigView - -CONFIG_PATH = "customize.yaml" - - -async def async_setup(hass): - """Set up the Customize config API.""" - - async def hook(action, config_key): - """post_write_hook for Config View that reloads groups.""" - await hass.services.async_call(DOMAIN, SERVICE_RELOAD_CORE_CONFIG) - - hass.http.register_view( - CustomizeConfigView( - "customize", "config", CONFIG_PATH, cv.entity_id, dict, post_write_hook=hook - ) - ) - - return True - - -class CustomizeConfigView(EditKeyBasedConfigView): - """Configure a list of entries.""" - - def _get_value(self, hass, data, config_key): - """Get value.""" - customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} - return {"global": customize, "local": data.get(config_key, {})} - - def _write_value(self, hass, data, config_key, new_value): - """Set value.""" - data[config_key] = new_value - - state = hass.states.get(config_key) - state_attributes = dict(state.attributes) - state_attributes.update(new_value) - hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 4363fbbbe4da3..f553e5d5401c1 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -7,7 +7,10 @@ require_admin, ) from homeassistant.core import callback -from homeassistant.helpers.device_registry import DISABLED_USER, async_get_registry +from homeassistant.helpers.device_registry import ( + DeviceEntryDisabler, + async_get_registry, +) WS_TYPE_LIST = "config/device_registry/list" SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( @@ -22,7 +25,8 @@ vol.Optional("area_id"): vol.Any(str, None), vol.Optional("name_by_user"): vol.Any(str, None), # We only allow setting disabled_by user via API. - vol.Optional("disabled_by"): vol.Any(DISABLED_USER, None), + # No Enum support like this in voluptuous, use .value + vol.Optional("disabled_by"): vol.Any(DeviceEntryDisabler.USER.value, None), } ) @@ -67,17 +71,19 @@ async def websocket_update_device(hass, connection, msg): def _entry_dict(entry): """Convert entry to API format.""" return { + "area_id": entry.area_id, + "configuration_url": entry.configuration_url, "config_entries": list(entry.config_entries), "connections": list(entry.connections), + "disabled_by": entry.disabled_by, + "entry_type": entry.entry_type, + "id": entry.id, + "identifiers": list(entry.identifiers), "manufacturer": entry.manufacturer, "model": entry.model, + "name_by_user": entry.name_by_user, "name": entry.name, "sw_version": entry.sw_version, - "entry_type": entry.entry_type, - "id": entry.id, - "identifiers": list(entry.identifiers), + "hw_version": entry.hw_version, "via_device_id": entry.via_device_id, - "area_id": entry.area_id, - "name_by_user": entry.name_by_user, - "disabled_by": entry.disabled_by, } diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 43196acf3195e..d42c5be08fc3e 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -10,7 +10,10 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_registry import DISABLED_USER, async_get_registry +from homeassistant.helpers.entity_registry import ( + RegistryEntryDisabler, + async_get_registry, +) async def async_setup(hass): @@ -50,9 +53,8 @@ async def websocket_get_entity(hass, connection, msg): Async friendly. """ registry = await async_get_registry(hass) - entry = registry.entities.get(msg["entity_id"]) - if entry is None: + if (entry := registry.entities.get(msg["entity_id"])) is None: connection.send_message( websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") ) @@ -70,12 +72,18 @@ async def websocket_get_entity(hass, connection, msg): vol.Required("type"): "config/entity_registry/update", vol.Required("entity_id"): cv.entity_id, # If passed in, we update value. Passing None will remove old value. - vol.Optional("name"): vol.Any(str, None), - vol.Optional("icon"): vol.Any(str, None), vol.Optional("area_id"): vol.Any(str, None), + vol.Optional("device_class"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("name"): vol.Any(str, None), vol.Optional("new_entity_id"): str, # We only allow setting disabled_by user via API. - vol.Optional("disabled_by"): vol.Any(DISABLED_USER, None), + vol.Optional("disabled_by"): vol.Any( + None, + vol.All( + vol.Coerce(RegistryEntryDisabler), RegistryEntryDisabler.USER.value + ), + ), } ) async def websocket_update_entity(hass, connection, msg): @@ -93,7 +101,7 @@ async def websocket_update_entity(hass, connection, msg): changes = {} - for key in ("name", "icon", "area_id", "disabled_by"): + for key in ("area_id", "device_class", "disabled_by", "icon", "name"): if key in msg: changes[key] = msg[key] @@ -169,13 +177,14 @@ async def websocket_remove_entity(hass, connection, msg): def _entry_dict(entry): """Convert entry to API format.""" return { + "area_id": entry.area_id, "config_entry_id": entry.config_entry_id, "device_id": entry.device_id, - "area_id": entry.area_id, "disabled_by": entry.disabled_by, + "entity_category": entry.entity_category, "entity_id": entry.entity_id, - "name": entry.name, "icon": entry.icon, + "name": entry.name, "platform": entry.platform, } @@ -184,8 +193,10 @@ def _entry_dict(entry): def _entry_ext_dict(entry): """Convert entry to API format.""" data = _entry_dict(entry) - data["original_name"] = entry.original_name + data["capabilities"] = entry.capabilities + data["device_class"] = entry.device_class + data["original_device_class"] = entry.original_device_class data["original_icon"] = entry.original_icon + data["original_name"] = entry.original_name data["unique_id"] = entry.unique_id - data["capabilities"] = entry.capabilities return data diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index dd1bf1f08e20f..63b7bdf986817 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,12 +1,12 @@ """Provide configuration end points for Z-Wave.""" from collections import deque +from http import HTTPStatus import logging from aiohttp.web import Response from homeassistant.components.http import HomeAssistantView from homeassistant.components.zwave import DEVICE_CONFIG_SCHEMA_ENTRY, const -from homeassistant.const import HTTP_ACCEPTED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_OK import homeassistant.core as ha import homeassistant.helpers.config_validation as cv @@ -51,7 +51,7 @@ async def get(self, request): try: lines = int(request.query.get("lines", 0)) except ValueError: - return Response(text="Invalid datetime", status=HTTP_BAD_REQUEST) + return Response(text="Invalid datetime", status=HTTPStatus.BAD_REQUEST) hass = request.app["hass"] response = await hass.async_add_executor_job(self._get_log, hass, lines) @@ -61,7 +61,7 @@ async def get(self, request): def _get_log(self, hass, lines): """Retrieve the logfile content.""" logfilepath = hass.config.path(OZW_LOG_FILENAME) - with open(logfilepath) as logfile: + with open(logfilepath, encoding="utf8") as logfile: data = (line.rstrip() for line in logfile) if lines == 0: loglines = list(data) @@ -80,12 +80,13 @@ class ZWaveConfigWriteView(HomeAssistantView): def post(self, request): """Save cache configuration to zwcfg_xxxxx.xml.""" hass = request.app["hass"] - network = hass.data.get(const.DATA_NETWORK) - if network is None: - return self.json_message("No Z-Wave network data found", HTTP_NOT_FOUND) + if (network := hass.data.get(const.DATA_NETWORK)) is None: + return self.json_message( + "No Z-Wave network data found", HTTPStatus.NOT_FOUND + ) _LOGGER.info("Z-Wave configuration written to file") network.write_config() - return self.json_message("Z-Wave configuration saved to file", HTTP_OK) + return self.json_message("Z-Wave configuration saved to file") class ZWaveNodeValueView(HomeAssistantView): @@ -129,9 +130,8 @@ def get(self, request, node_id): nodeid = int(node_id) hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) - node = network.nodes.get(nodeid) - if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + if (node := network.nodes.get(nodeid)) is None: + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) groupdata = node.groups groups = {} for key, value in groupdata.items(): @@ -156,9 +156,8 @@ def get(self, request, node_id): nodeid = int(node_id) hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) - node = network.nodes.get(nodeid) - if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + if (node := network.nodes.get(nodeid)) is None: + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) config = {} for value in node.get_values( class_id=const.COMMAND_CLASS_CONFIGURATION @@ -187,9 +186,8 @@ def get(self, request, node_id): nodeid = int(node_id) hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) - node = network.nodes.get(nodeid) - if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + if (node := network.nodes.get(nodeid)) is None: + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) usercodes = {} if not node.has_command_class(const.COMMAND_CLASS_USER_CODE): return self.json(usercodes) @@ -218,9 +216,8 @@ async def get(self, request, node_id): def _fetch_protection(): """Get protection data.""" - node = network.nodes.get(nodeid) - if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + if (node := network.nodes.get(nodeid)) is None: + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) protection_options = {} if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json(protection_options) @@ -247,16 +244,16 @@ def _set_protection(): selection = protection_data["selection"] value_id = int(protection_data[const.ATTR_VALUE_ID]) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json_message( - "No protection commandclass on this node", HTTP_NOT_FOUND + "No protection commandclass on this node", HTTPStatus.NOT_FOUND ) state = node.set_protection(value_id, selection) if not state: return self.json_message( - "Protection setting did not complete", HTTP_ACCEPTED + "Protection setting did not complete", HTTPStatus.ACCEPTED ) - return self.json_message("Protection setting succsessfully set", HTTP_OK) + return self.json_message("Protection setting successfully set") return await hass.async_add_executor_job(_set_protection) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index e988e58f76bdf..b94483122d08c 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -65,9 +65,7 @@ def async_request_config( if description_image is not None: description += f"\n\n![Description image]({description_image})" - instance = hass.data.get(_KEY_INSTANCE) - - if instance is None: + if (instance := hass.data.get(_KEY_INSTANCE)) is None: instance = hass.data[_KEY_INSTANCE] = Configurator(hass) request_id = instance.async_request_config( @@ -166,10 +164,10 @@ def async_request_config( data.update( { key: value - for key, value in [ + for key, value in ( (ATTR_DESCRIPTION, description), (ATTR_SUBMIT_CAPTION, submit_caption), - ] + ) if value is not None } ) @@ -207,6 +205,7 @@ def async_request_done(self, request_id): # it shortly after so that it is deleted when the client updates. self.hass.states.async_set(entity_id, STATE_CONFIGURED) + @async_callback def deferred_remove(event: Event): """Remove the request state.""" self.hass.states.async_remove(entity_id, context=event.context) diff --git a/homeassistant/components/configurator/translations/he.json b/homeassistant/components/configurator/translations/he.json index 7cc7aad41d736..aeff95ca5ce0c 100644 --- a/homeassistant/components/configurator/translations/he.json +++ b/homeassistant/components/configurator/translations/he.json @@ -1,9 +1,9 @@ { "state": { "_": { - "configure": "\u05d4\u05d2\u05d3\u05e8", - "configured": "\u05d4\u05d5\u05d2\u05d3\u05e8" + "configure": "\u05d4\u05d2\u05d3\u05e8\u05d4", + "configured": "\u05de\u05d5\u05d2\u05d3\u05e8" } }, - "title": "\u05e7\u05d5\u05e0\u05e4\u05d9\u05d2\u05d5\u05e8\u05d8\u05d5\u05e8" + "title": "\u05e7\u05d5\u05d1\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/it.json b/homeassistant/components/configurator/translations/it.json index b8610b76d9d31..3e17f84d1c87b 100644 --- a/homeassistant/components/configurator/translations/it.json +++ b/homeassistant/components/configurator/translations/it.json @@ -1,7 +1,7 @@ { "state": { "_": { - "configure": "Configurare", + "configure": "Configura", "configured": "Configurato" } }, diff --git a/homeassistant/components/configurator/translations/ja.json b/homeassistant/components/configurator/translations/ja.json index 44c6ef349c003..aedc05d5f1cc9 100644 --- a/homeassistant/components/configurator/translations/ja.json +++ b/homeassistant/components/configurator/translations/ja.json @@ -4,5 +4,6 @@ "configure": "\u8a2d\u5b9a", "configured": "\u8a2d\u5b9a\u6e08\u307f" } - } + }, + "title": "\u30b3\u30f3\u30d5\u30a3\u30ae\u30e5\u30ec\u30fc\u30bf\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 01958ef3453ff..ee2f51303f27d 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -1,4 +1,6 @@ """The Control4 integration.""" +from __future__ import annotations + import json import logging @@ -14,10 +16,12 @@ CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -38,10 +42,10 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["light"] +PLATFORMS = [Platform.LIGHT] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Control4 from a config entry.""" hass.data.setdefault(DOMAIN, {}) entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) @@ -83,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): _, model, mac_address = controller_unique_id.split("_", 3) entry_data[CONF_DIRECTOR_MODEL] = model.upper() - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller_unique_id)}, @@ -117,7 +121,7 @@ async def update_listener(hass, config_entry): await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -145,20 +149,19 @@ class Control4Entity(CoordinatorEntity): def __init__( self, entry_data: dict, - entry: ConfigEntry, coordinator: DataUpdateCoordinator, name: str, idx: int, - device_name: str, - device_manufacturer: str, - device_model: str, + device_name: str | None, + device_manufacturer: str | None, + device_model: str | None, device_id: int, - ): + ) -> None: """Initialize a Control4 entity.""" super().__init__(coordinator) - self.entry = entry self.entry_data = entry_data - self._name = name + self._attr_name = name + self._attr_unique_id = str(idx) self._idx = idx self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] self._device_name = device_name @@ -167,23 +170,12 @@ def __init__( self._device_id = device_id @property - def name(self): - """Return name of entity.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._idx - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return info of parent Control4 device of entity.""" - return { - "config_entry_id": self.entry.entry_id, - "identifiers": {(DOMAIN, self._device_id)}, - "name": self._device_name, - "manufacturer": self._device_manufacturer, - "model": self._device_model, - "via_device": (DOMAIN, self._controller_unique_id), - } + return DeviceInfo( + identifiers={(DOMAIN, str(self._device_id))}, + manufacturer=self._device_manufacturer, + model=self._device_model, + name=self._device_name, + via_device=(DOMAIN, self._controller_unique_id), + ) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 1f476054e3101..2cf1ca845f760 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -96,9 +96,9 @@ async def async_step_user(self, user_input=None): if user_input is not None: hub = Control4Validator( - user_input["host"], - user_input["username"], - user_input["password"], + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], self.hass, ) try: @@ -123,9 +123,9 @@ async def async_step_user(self, user_input=None): return self.async_create_entry( title=controller_unique_id, data={ - CONF_HOST: user_input["host"], - CONF_USERNAME: user_input["username"], - CONF_PASSWORD: user_input["password"], + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_CONTROLLER_UNIQUE_ID: controller_unique_id, }, ) @@ -144,7 +144,7 @@ def async_get_options_flow(config_entry): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Control4.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index f8c94c6a93283..b2e5f6b43cf38 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -1,4 +1,6 @@ """Platform for Control4 Lights.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -120,7 +122,6 @@ async def async_update_data_dimmer(): entity_list.append( Control4Light( entry_data, - entry, item_coordinator, item_name, item_id, @@ -141,20 +142,18 @@ class Control4Light(Control4Entity, LightEntity): def __init__( self, entry_data: dict, - entry: ConfigEntry, coordinator: DataUpdateCoordinator, name: str, idx: int, - device_name: str, - device_manufacturer: str, - device_model: str, + device_name: str | None, + device_manufacturer: str | None, + device_model: str | None, device_id: int, is_dimmer: bool, - ): + ) -> None: """Initialize Control4 light entity.""" super().__init__( entry_data, - entry, coordinator, name, idx, diff --git a/homeassistant/components/control4/translations/bg.json b/homeassistant/components/control4/translations/bg.json new file mode 100644 index 0000000000000..cda7728121953 --- /dev/null +++ b/homeassistant/components/control4/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/de.json b/homeassistant/components/control4/translations/de.json index e50e2499320d4..4c9ef9abf1168 100644 --- a/homeassistant/components/control4/translations/de.json +++ b/homeassistant/components/control4/translations/de.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "IP-Addresse", + "host": "IP-Adresse", "password": "Passwort", "username": "Benutzername" }, diff --git a/homeassistant/components/control4/translations/he.json b/homeassistant/components/control4/translations/he.json new file mode 100644 index 0000000000000..c7f019363fff4 --- /dev/null +++ b/homeassistant/components/control4/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/hu.json b/homeassistant/components/control4/translations/hu.json index 68cb4fe23a9d3..5d41eb09a8476 100644 --- a/homeassistant/components/control4/translations/hu.json +++ b/homeassistant/components/control4/translations/hu.json @@ -14,6 +14,16 @@ "host": "IP c\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rj\u00fck, adja meg Control4-fi\u00f3kj\u00e1nak adatait \u00e9s a helyi vez\u00e9rl\u0151 IP-c\u00edm\u00e9t." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9sek k\u00f6z\u00f6tti m\u00e1sodpercek" } } } diff --git a/homeassistant/components/control4/translations/ja.json b/homeassistant/components/control4/translations/ja.json new file mode 100644 index 0000000000000..ae0824a804f36 --- /dev/null +++ b/homeassistant/components/control4/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Control4\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u8a73\u7d30\u3068\u3001\u30ed\u30fc\u30ab\u30eb\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694\u306e\u79d2\u6570" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/tr.json b/homeassistant/components/control4/translations/tr.json index aed7e564a760b..fce14cec68fac 100644 --- a/homeassistant/components/control4/translations/tr.json +++ b/homeassistant/components/control4/translations/tr.json @@ -11,9 +11,19 @@ "step": { "user": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen Control4 hesap ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 ve yerel denetleyicinizin IP adresini girin." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "G\u00fcncellemeler aras\u0131ndaki saniyeler" } } } diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f8534d99935ee..401d240957ea9 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -1,4 +1,5 @@ """Support for functionality to have conversations with Home Assistant.""" +from http import HTTPStatus import logging import re @@ -7,7 +8,6 @@ from homeassistant import core from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR from homeassistant.helpers import config_validation as cv, intent from homeassistant.loader import bind_hass @@ -146,7 +146,7 @@ async def post(self, request, data): "message": str(err), }, }, - status_code=HTTP_INTERNAL_SERVER_ERROR, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, ) return self.json(intent_result) @@ -154,8 +154,7 @@ async def post(self, request, data): async def _get_agent(hass: core.HomeAssistant) -> AbstractConversationAgent: """Get the active conversation agent.""" - agent = hass.data.get(DATA_AGENT) - if agent is None: + if (agent := hass.data.get(DATA_AGENT)) is None: agent = hass.data[DATA_AGENT] = DefaultAgent(hass) await agent.async_initialize(hass.data.get(DATA_CONFIG)) return agent diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 56cf4aecdeaeb..251058c7edd92 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -17,10 +17,12 @@ def attribution(self): async def async_get_onboarding(self): """Get onboard data.""" + # pylint: disable=no-self-use return None async def async_set_onboarding(self, shown): """Set onboard data.""" + # pylint: disable=no-self-use return True @abstractmethod diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index a98f685ea1d44..405a4f818f458 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -53,7 +53,7 @@ def async_register(hass, intent_type, utterances): class DefaultAgent(AbstractConversationAgent): """Default agent for conversation agent.""" - def __init__(self, hass: core.HomeAssistant): + def __init__(self, hass: core.HomeAssistant) -> None: """Initialize the default agent.""" self.hass = hass @@ -66,9 +66,7 @@ async def async_initialize(self, config): intents = self.hass.data.setdefault(DOMAIN, {}) for intent_type, utterances in config.get("intents", {}).items(): - conf = intents.get(intent_type) - - if conf is None: + if (conf := intents.get(intent_type)) is None: conf = intents[intent_type] = [] conf.extend(create_matcher(utterance) for utterance in utterances) @@ -120,9 +118,7 @@ async def async_process( for intent_type, matchers in intents.items(): for matcher in matchers: - match = matcher.match(text) - - if not match: + if not (match := matcher.match(text)): continue return await intent.async_handle( diff --git a/homeassistant/components/conversation/translations/he.json b/homeassistant/components/conversation/translations/he.json index eeccec319afe7..63cfb10abe8ca 100644 --- a/homeassistant/components/conversation/translations/he.json +++ b/homeassistant/components/conversation/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05e9\u05c2\u05b4\u05d9\u05d7\u05b8\u05d4" + "title": "\u05e9\u05d9\u05d7\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/ja.json b/homeassistant/components/conversation/translations/ja.json new file mode 100644 index 0000000000000..c8dbdcf8f1fc2 --- /dev/null +++ b/homeassistant/components/conversation/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u4f1a\u8a71" +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index e6cf6f362777a..fc0040bf24511 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -4,7 +4,7 @@ from pycoolmasternet_async import CoolMasterNet from homeassistant.components.climate import SCAN_INTERVAL -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -12,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["climate"] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass, entry): @@ -24,7 +24,7 @@ async def async_setup_entry(hass, entry): info = await coolmaster.info() if not info: raise ConfigEntryNotReady - except (OSError, ConnectionRefusedError, TimeoutError) as error: + except OSError as error: raise ConfigEntryNotReady() from error coordinator = CoolmasterDataUpdateCoordinator(hass, coolmaster) hass.data.setdefault(DOMAIN, {}) @@ -64,5 +64,5 @@ async def _async_update_data(self): """Fetch data from Coolmaster.""" try: return await self._coolmaster.status() - except (OSError, ConnectionRefusedError, TimeoutError) as error: + except OSError as error: raise UpdateFailed from error diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index 7077854a768e2..015a68ae18e8a 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -15,6 +15,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SUPPORTED_MODES, DATA_COORDINATOR, DATA_INFO, DOMAIN @@ -73,15 +74,15 @@ def _handle_coordinator_update(self): super()._handle_coordinator_update() @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "CoolAutomation", - "model": "CoolMasterNet", - "sw_version": self._info["version"], - } + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="CoolAutomation", + model="CoolMasterNet", + name=self.name, + sw_version=self._info["version"], + ) @property def unique_id(self): @@ -120,8 +121,7 @@ def target_temperature(self): def hvac_mode(self): """Return hvac target hvac state.""" mode = self._unit.mode - is_on = self._unit.is_on - if not is_on: + if not self._unit.is_on: return HVAC_MODE_OFF return CM_TO_HA_STATE[mode] @@ -143,8 +143,7 @@ def fan_modes(self): async def async_set_temperature(self, **kwargs): """Set new target temperatures.""" - temp = kwargs.get(ATTR_TEMPERATURE) - if temp is not None: + if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: _LOGGER.debug("Setting temp of %s to %s", self.unique_id, str(temp)) self._unit = await self._unit.set_thermostat(temp) self.async_write_ha_state() diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py index 1091c24ea315b..6a5c517fc85c7 100644 --- a/homeassistant/components/coolmaster/config_flow.py +++ b/homeassistant/components/coolmaster/config_flow.py @@ -51,7 +51,7 @@ async def async_step_user(self, user_input=None): result = await _validate_connection(self.hass, host) if not result: errors["base"] = "no_units" - except (OSError, ConnectionRefusedError, TimeoutError): + except OSError: errors["base"] = "cannot_connect" if errors: diff --git a/homeassistant/components/coolmaster/translations/bg.json b/homeassistant/components/coolmaster/translations/bg.json index 079082c01cf00..72b8df6634d42 100644 --- a/homeassistant/components/coolmaster/translations/bg.json +++ b/homeassistant/components/coolmaster/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "no_units": "\u041d\u0435 \u0431\u044f\u0445\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u0447\u043d\u0438/\u0432\u0435\u043d\u0442\u0438\u043b\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0441\u0438\u0441\u0442\u0435\u043c\u0438 \u043d\u0430 \u0437\u0430\u0434\u0430\u0434\u0435\u043d\u0438\u044f CoolMasterNet \u0430\u0434\u0440\u0435\u0441." }, "step": { diff --git a/homeassistant/components/coolmaster/translations/es-419.json b/homeassistant/components/coolmaster/translations/es-419.json index 9073238aa91cc..0c41a0dbfe5d9 100644 --- a/homeassistant/components/coolmaster/translations/es-419.json +++ b/homeassistant/components/coolmaster/translations/es-419.json @@ -6,6 +6,11 @@ "step": { "user": { "data": { + "cool": "Soporta el modo de enfriamiento", + "dry": "Soporta el modo seco", + "fan_only": "Soporta el modo solo ventilador", + "heat": "Soporta el modo de calor", + "heat_cool": "Soporta el modo autom\u00e1tico de calor/fr\u00edo", "host": "Host", "off": "Puede ser apagado" }, diff --git a/homeassistant/components/coolmaster/translations/he.json b/homeassistant/components/coolmaster/translations/he.json new file mode 100644 index 0000000000000..1fd17b101347b --- /dev/null +++ b/homeassistant/components/coolmaster/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "no_units": "\u05dc\u05d0 \u05d4\u05d9\u05ea\u05d4 \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05de\u05e6\u05d5\u05d0 \u05d9\u05d7\u05d9\u05d3\u05d5\u05ea HVAC \u05d1\u05de\u05d0\u05e8\u05d7 CoolMasterNet." + }, + "step": { + "user": { + "data": { + "fan_only": "\u05ea\u05de\u05d9\u05db\u05d4 \u05d1\u05de\u05e6\u05d1 \u05de\u05d0\u05d5\u05d5\u05e8\u05e8 \u05d1\u05dc\u05d1\u05d3", + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json index cf688d6fdeb63..3770d56ee8967 100644 --- a/homeassistant/components/coolmaster/translations/hu.json +++ b/homeassistant/components/coolmaster/translations/hu.json @@ -1,13 +1,21 @@ { "config": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "no_units": "Nem tal\u00e1lhat\u00f3 HVAC egys\u00e9g a CoolMasterNet gazdag\u00e9pben." }, "step": { "user": { "data": { - "host": "Hoszt" - } + "cool": "T\u00e1mogatott a h\u0171t\u00e9si m\u00f3d(ok)", + "dry": "P\u00e1r\u00e1tlan\u00edt\u00e1s ", + "fan_only": "T\u00e1mogaott csak ventil\u00e1tor m\u00f3d(ok)", + "heat": "T\u00e1mogatott f\u0171t\u00e9si m\u00f3d(ok)", + "heat_cool": "T\u00e1mogatott f\u0171t\u00e9si/h\u0171t\u00e9si m\u00f3d(ok)", + "host": "C\u00edm", + "off": "Ki lehet kapcsolni" + }, + "title": "\u00c1ll\u00edtsa be a CoolMasterNet kapcsolat r\u00e9szleteit." } } } diff --git a/homeassistant/components/coolmaster/translations/it.json b/homeassistant/components/coolmaster/translations/it.json index 9e1342257fb5c..2a0cf07f5eda9 100644 --- a/homeassistant/components/coolmaster/translations/it.json +++ b/homeassistant/components/coolmaster/translations/it.json @@ -15,7 +15,7 @@ "host": "Host", "off": "Pu\u00f2 essere spento" }, - "title": "Impostare i dettagli della connessione CoolMasterNet." + "title": "Imposta i dettagli della connessione CoolMasterNet." } } } diff --git a/homeassistant/components/coolmaster/translations/ja.json b/homeassistant/components/coolmaster/translations/ja.json new file mode 100644 index 0000000000000..fd9b5952b9a21 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "no_units": "CoolMasterNet\u306e\u30db\u30b9\u30c8\u306bHVAC\u30e6\u30cb\u30c3\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "cool": "\u30af\u30fc\u30eb\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "dry": "\u30c9\u30e9\u30a4\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "fan_only": "\u30d5\u30a1\u30f3\u306e\u307f\u306e\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "heat": "\u30d2\u30fc\u30c8\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "heat_cool": "\u81ea\u52d5\u6696\u623f(\u52a0\u71b1)/\u30af\u30fc\u30eb\u30e2\u30fc\u30c9\u5bfe\u5fdc", + "host": "\u30db\u30b9\u30c8", + "off": "\u30aa\u30d5\u306b\u3067\u304d\u307e\u3059" + }, + "title": "CoolMasterNet\u306e\u63a5\u7d9a\u60c5\u5831\u3092\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/tr.json b/homeassistant/components/coolmaster/translations/tr.json index 4848a34362cc3..8950dfa02202f 100644 --- a/homeassistant/components/coolmaster/translations/tr.json +++ b/homeassistant/components/coolmaster/translations/tr.json @@ -1,14 +1,21 @@ { "config": { "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "no_units": "CoolMasterNet ana bilgisayar\u0131nda herhangi bir HVAC birimi bulunamad\u0131." }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "cool": "So\u011fuk modu destekler", + "dry": "Kuru modu destekler", + "fan_only": "Yaln\u0131zca fan modunu destekler", + "heat": "Is\u0131tma modunu destekler", + "heat_cool": "Otomatik \u0131s\u0131tma/so\u011futma modunu destekler", + "host": "Sunucu", "off": "Kapat\u0131labilir" - } + }, + "title": "CoolMasterNet ba\u011flant\u0131 ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 ayarlay\u0131n." } } } diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index c855137fcbf6e..27085c88ef27a 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -6,15 +6,17 @@ import coronavirus from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Coronavirus component.""" # Make sure coordinator is initialized. await get_coordinator(hass) @@ -65,7 +67,7 @@ async def get_coordinator( return hass.data[DOMAIN] async def async_get_cases(): - with async_timeout.timeout(10): + async with async_timeout.timeout(10): return { case.country: case for case in await coronavirus.get_cases( diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json index 08a88d1b8269e..87410d8b5727b 100644 --- a/homeassistant/components/coronavirus/manifest.json +++ b/homeassistant/components/coronavirus/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coronavirus", "requirements": ["coronavirus==1.1.1"], - "codeowners": ["@home_assistant/core"], + "codeowners": ["@home-assistant/core"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index 472b8bc8d1ce3..14f597299cf22 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -27,17 +27,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class CoronavirusSensor(CoordinatorEntity, SensorEntity): """Sensor representing corona virus data.""" - name = None - unique_id = None + _attr_native_unit_of_measurement = "people" def __init__(self, coordinator, country, info_type): """Initialize coronavirus sensor.""" super().__init__(coordinator) + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_icon = SENSORS[info_type] + self._attr_unique_id = f"{country}-{info_type}" if country == OPTION_WORLDWIDE: - self.name = f"Worldwide Coronavirus {info_type}" + self._attr_name = f"Worldwide Coronavirus {info_type}" else: - self.name = f"{coordinator.data[country].country} Coronavirus {info_type}" - self.unique_id = f"{country}-{info_type}" + self._attr_name = ( + f"{coordinator.data[country].country} Coronavirus {info_type}" + ) + self.country = country self.info_type = info_type @@ -49,31 +53,15 @@ def available(self): ) @property - def state(self): + def native_value(self): """State of the sensor.""" if self.country == OPTION_WORLDWIDE: sum_cases = 0 for case in self.coordinator.data.values(): - value = getattr(case, self.info_type) - if value is None: + if (value := getattr(case, self.info_type)) is None: continue sum_cases += value return sum_cases return getattr(self.coordinator.data[self.country], self.info_type) - - @property - def icon(self): - """Return the icon.""" - return SENSORS[self.info_type] - - @property - def unit_of_measurement(self): - """Return unit of measurement.""" - return "people" - - @property - def extra_state_attributes(self): - """Return device attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/coronavirus/translations/de.json b/homeassistant/components/coronavirus/translations/de.json index 45eaff6420015..24da7b952eac4 100644 --- a/homeassistant/components/coronavirus/translations/de.json +++ b/homeassistant/components/coronavirus/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Land ist bereits konfiguriert.", + "already_configured": "Der Dienst ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "step": { @@ -9,7 +9,7 @@ "data": { "country": "Land" }, - "title": "W\u00e4hlen Sie ein Land aus, das \u00fcberwacht werden soll" + "title": "W\u00e4hle ein Land aus, das \u00fcberwacht werden soll" } } } diff --git a/homeassistant/components/coronavirus/translations/fr.json b/homeassistant/components/coronavirus/translations/fr.json index 21a72d80f61ce..26b2937a8aede 100644 --- a/homeassistant/components/coronavirus/translations/fr.json +++ b/homeassistant/components/coronavirus/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/coronavirus/translations/he.json b/homeassistant/components/coronavirus/translations/he.json new file mode 100644 index 0000000000000..5ac1be49cfb3a --- /dev/null +++ b/homeassistant/components/coronavirus/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "country": "\u05de\u05d3\u05d9\u05e0\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/hu.json b/homeassistant/components/coronavirus/translations/hu.json index 631454ec04582..9b79c82a0146f 100644 --- a/homeassistant/components/coronavirus/translations/hu.json +++ b/homeassistant/components/coronavirus/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem siker\u00fclt csatlakozni" }, "step": { "user": { diff --git a/homeassistant/components/coronavirus/translations/id.json b/homeassistant/components/coronavirus/translations/id.json index e2626d16abb95..f6bef10f8c0eb 100644 --- a/homeassistant/components/coronavirus/translations/id.json +++ b/homeassistant/components/coronavirus/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Layanan sudah dikonfigurasi" + "already_configured": "Layanan sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" }, "step": { "user": { diff --git a/homeassistant/components/coronavirus/translations/it.json b/homeassistant/components/coronavirus/translations/it.json index fb6825893349e..c429a070e3439 100644 --- a/homeassistant/components/coronavirus/translations/it.json +++ b/homeassistant/components/coronavirus/translations/it.json @@ -9,7 +9,7 @@ "data": { "country": "Nazione" }, - "title": "Scegliere una Nazione da monitorare" + "title": "Scegli una nazione da monitorare" } } } diff --git a/homeassistant/components/coronavirus/translations/ja.json b/homeassistant/components/coronavirus/translations/ja.json new file mode 100644 index 0000000000000..ef8059fc1b58f --- /dev/null +++ b/homeassistant/components/coronavirus/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "country": "\u56fd" + }, + "title": "\u76e3\u8996\u3059\u308b\u56fd\u3092\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/tr.json b/homeassistant/components/coronavirus/translations/tr.json index b608d60f82406..118f8997d1f95 100644 --- a/homeassistant/components/coronavirus/translations/tr.json +++ b/homeassistant/components/coronavirus/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { "user": { diff --git a/homeassistant/components/coronavirus/translations/zh-Hans.json b/homeassistant/components/coronavirus/translations/zh-Hans.json index 5bb92ac117226..6348ac4089632 100644 --- a/homeassistant/components/coronavirus/translations/zh-Hans.json +++ b/homeassistant/components/coronavirus/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002" + "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "step": { "user": { diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index ecb405a81cd0c..d035d658206d6 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -173,7 +173,7 @@ async def _update_data(self, data: dict, update_data: dict) -> dict: class Counter(RestoreEntity): """Representation of a counter.""" - def __init__(self, config: dict): + def __init__(self, config: dict) -> None: """Initialize a counter.""" self._config: dict = config self._state: int | None = config[CONF_INITIAL] @@ -240,14 +240,15 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() # __init__ will set self._state to self._initial, only override # if needed. - if self._config[CONF_RESTORE]: - state = await self.async_get_last_state() - if state is not None: - self._state = self.compute_next_state(int(state.state)) - self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) - self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) - self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) - self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) + if ( + self._config[CONF_RESTORE] + and (state := await self.async_get_last_state()) is not None + ): + self._state = self.compute_next_state(int(state.state)) + self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) + self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) + self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) + self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) @callback def async_decrement(self) -> None: diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py index 0ced9bad06d6a..2029321c43070 100644 --- a/homeassistant/components/counter/reproduce_state.py +++ b/homeassistant/components/counter/reproduce_state.py @@ -30,9 +30,7 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - cur_state = hass.states.get(state.entity_id) - - if cur_state is None: + if (cur_state := hass.states.get(state.entity_id)) is None: _LOGGER.warning("Unable to find entity %s", state.entity_id) return diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index 4dd427c1fa11b..1930ba0d45bc0 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -4,26 +4,33 @@ decrement: name: Decrement description: Decrement a counter. target: + entity: + domain: counter increment: name: Increment description: Increment a counter. target: + entity: + domain: counter reset: name: Reset description: Reset a counter. target: + entity: + domain: counter configure: name: Configure description: Change counter parameters. target: + entity: + domain: counter fields: minimum: name: Minimum description: New minimum value for the counter or None to remove minimum. - example: 0 selector: number: min: -9223372036854775807 @@ -32,7 +39,6 @@ configure: maximum: name: Maximum description: New maximum value for the counter or None to remove maximum. - example: 100 selector: number: min: -9223372036854775807 @@ -41,7 +47,6 @@ configure: step: name: Step description: New value for step. - example: 2 selector: number: min: 1 @@ -50,7 +55,6 @@ configure: initial: name: Initial description: New value for initial. - example: 6 selector: number: min: 0 @@ -59,7 +63,6 @@ configure: value: name: Value description: New state value. - example: 3 selector: number: min: 0 diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 034beb7f9db41..506a93461fe8f 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -1,4 +1,7 @@ """Support for Cover devices.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -6,6 +9,8 @@ import voluptuous as vol +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, @@ -22,11 +27,12 @@ STATE_OPEN, STATE_OPENING, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass @@ -39,31 +45,38 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" -# Refer to the cover dev docs for device class descriptions -DEVICE_CLASS_AWNING = "awning" -DEVICE_CLASS_BLIND = "blind" -DEVICE_CLASS_CURTAIN = "curtain" -DEVICE_CLASS_DAMPER = "damper" -DEVICE_CLASS_DOOR = "door" -DEVICE_CLASS_GARAGE = "garage" -DEVICE_CLASS_GATE = "gate" -DEVICE_CLASS_SHADE = "shade" -DEVICE_CLASS_SHUTTER = "shutter" -DEVICE_CLASS_WINDOW = "window" - -DEVICE_CLASSES = [ - DEVICE_CLASS_AWNING, - DEVICE_CLASS_BLIND, - DEVICE_CLASS_CURTAIN, - DEVICE_CLASS_DAMPER, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_GATE, - DEVICE_CLASS_SHADE, - DEVICE_CLASS_SHUTTER, - DEVICE_CLASS_WINDOW, -] -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + +class CoverDeviceClass(StrEnum): + """Device class for cover.""" + + # Refer to the cover dev docs for device class descriptions + AWNING = "awning" + BLIND = "blind" + CURTAIN = "curtain" + DAMPER = "damper" + DOOR = "door" + GARAGE = "garage" + GATE = "gate" + SHADE = "shade" + SHUTTER = "shutter" + WINDOW = "window" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) + +# DEVICE_CLASS* below are deprecated as of 2021.12 +# use the CoverDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] +DEVICE_CLASS_AWNING = CoverDeviceClass.AWNING.value +DEVICE_CLASS_BLIND = CoverDeviceClass.BLIND.value +DEVICE_CLASS_CURTAIN = CoverDeviceClass.CURTAIN.value +DEVICE_CLASS_DAMPER = CoverDeviceClass.DAMPER.value +DEVICE_CLASS_DOOR = CoverDeviceClass.DOOR.value +DEVICE_CLASS_GARAGE = CoverDeviceClass.GARAGE.value +DEVICE_CLASS_GATE = CoverDeviceClass.GATE.value +DEVICE_CLASS_SHADE = CoverDeviceClass.SHADE.value +DEVICE_CLASS_SHUTTER = CoverDeviceClass.SHUTTER.value +DEVICE_CLASS_WINDOW = CoverDeviceClass.WINDOW.value SUPPORT_OPEN = 1 SUPPORT_CLOSE = 2 @@ -154,44 +167,76 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class CoverEntityDescription(EntityDescription): + """A class that describes cover entities.""" + + device_class: CoverDeviceClass | str | None = None class CoverEntity(Entity): """Base class for cover entities.""" + entity_description: CoverEntityDescription + _attr_current_cover_position: int | None = None + _attr_current_cover_tilt_position: int | None = None + _attr_device_class: CoverDeviceClass | str | None + _attr_is_closed: bool | None + _attr_is_closing: bool | None = None + _attr_is_opening: bool | None = None + _attr_state: None = None + + _cover_is_last_toggle_direction_open = True + @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. """ + return self._attr_current_cover_position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. None is unknown, 0 is closed, 100 is fully open. """ + return self._attr_current_cover_tilt_position + + @property + def device_class(self) -> CoverDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None @property - def state(self): + @final + def state(self) -> str | None: """Return the state of the cover.""" if self.is_opening: + self._cover_is_last_toggle_direction_open = True return STATE_OPENING if self.is_closing: + self._cover_is_last_toggle_direction_open = False return STATE_CLOSING - closed = self.is_closed - - if closed is None: + if (closed := self.is_closed) is None: return None return STATE_CLOSED if closed else STATE_OPEN @@ -202,19 +247,20 @@ def state_attributes(self): """Return the state attributes.""" data = {} - current = self.current_cover_position - if current is not None: - data[ATTR_CURRENT_POSITION] = self.current_cover_position + if (current := self.current_cover_position) is not None: + data[ATTR_CURRENT_POSITION] = current - current_tilt = self.current_cover_tilt_position - if current_tilt is not None: - data[ATTR_CURRENT_TILT_POSITION] = self.current_cover_tilt_position + if (current_tilt := self.current_cover_tilt_position) is not None: + data[ATTR_CURRENT_TILT_POSITION] = current_tilt return data @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" + if self._attr_supported_features is not None: + return self._attr_supported_features + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP if self.current_cover_position is not None: @@ -231,17 +277,19 @@ def supported_features(self): return supported_features @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return if the cover is opening or not.""" + return self._attr_is_opening @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" + return self._attr_is_closing @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" - raise NotImplementedError() + return self._attr_is_closed def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" @@ -261,17 +309,23 @@ async def async_close_cover(self, **kwargs): def toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" - if self.is_closed: - self.open_cover(**kwargs) - else: - self.close_cover(**kwargs) + fns = { + "open": self.open_cover, + "close": self.close_cover, + "stop": self.stop_cover, + } + function = self._get_toggle_function(fns) + function(**kwargs) async def async_toggle(self, **kwargs): """Toggle the entity.""" - if self.is_closed: - await self.async_open_cover(**kwargs) - else: - await self.async_close_cover(**kwargs) + fns = { + "open": self.async_open_cover, + "close": self.async_close_cover, + "stop": self.async_stop_cover, + } + function = self._get_toggle_function(fns) + await function(**kwargs) def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -339,14 +393,13 @@ async def async_toggle_tilt(self, **kwargs): else: await self.async_close_cover_tilt(**kwargs) - -class CoverDevice(CoverEntity): - """Representation of a cover (for backwards compatibility).""" - - def __init_subclass__(cls, **kwargs): - """Print deprecation warning.""" - super().__init_subclass__(**kwargs) - _LOGGER.warning( - "CoverDevice is deprecated, modify %s to extend CoverEntity", - cls.__name__, - ) + def _get_toggle_function(self, fns): + if SUPPORT_STOP | self.supported_features and ( + self.is_closing or self.is_opening + ): + return fns["stop"] + if self.is_closed: + return fns["open"] + if self._cover_is_last_toggle_direction_open: + return fns["close"] + return fns["open"] diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 74eef8102dfd6..debb2368cf252 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -5,7 +5,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, @@ -21,6 +20,8 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.typing import ConfigType from . import ( ATTR_POSITION, @@ -58,7 +59,9 @@ ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Cover devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] @@ -68,84 +71,39 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) - if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: - continue - - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = get_supported_features(hass, entry.entity_id) # Add actions for each entity that belongs to this integration + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + if supported_features & SUPPORT_SET_POSITION: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "set_position", - } - ) + actions.append({**base_action, CONF_TYPE: "set_position"}) else: if supported_features & SUPPORT_OPEN: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "open", - } - ) + actions.append({**base_action, CONF_TYPE: "open"}) if supported_features & SUPPORT_CLOSE: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "close", - } - ) + actions.append({**base_action, CONF_TYPE: "close"}) if supported_features & SUPPORT_STOP: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "stop", - } - ) + actions.append({**base_action, CONF_TYPE: "stop"}) if supported_features & SUPPORT_SET_TILT_POSITION: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "set_tilt_position", - } - ) + actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) else: if supported_features & SUPPORT_OPEN_TILT: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "open_tilt", - } - ) + actions.append({**base_action, CONF_TYPE: "open_tilt"}) if supported_features & SUPPORT_CLOSE_TILT: - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "close_tilt", - } - ) + actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" if config[CONF_TYPE] not in POSITION_ACTION_TYPES: return {} diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 2943f589f7b8d..cca608187a27a 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -1,13 +1,10 @@ """Provides device automations for Cover.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, CONF_ABOVE, CONF_BELOW, CONF_CONDITION, @@ -21,13 +18,9 @@ STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - condition, - config_validation as cv, - entity_registry, - template, -) +from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import ( @@ -38,6 +31,8 @@ SUPPORT_SET_TILT_POSITION, ) +# mypy: disallow-any-generics + POSITION_CONDITION_TYPES = {"is_position", "is_tilt_position"} STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} @@ -67,86 +62,44 @@ CONDITION_SCHEMA = vol.Any(POSITION_CONDITION_SCHEMA, STATE_CONDITION_SCHEMA) -async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device conditions for Cover devices.""" registry = await entity_registry.async_get_registry(hass) - conditions: list[dict[str, Any]] = [] + conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device for entry in entity_registry.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) - if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: - continue - - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = get_supported_features(hass, entry.entity_id) supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) # Add conditions for each entity that belongs to this integration + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + if supports_open_close: - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_open", - } - ) - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_closed", - } - ) - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_opening", - } - ) - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_closing", - } - ) + conditions += [ + {**base_condition, CONF_TYPE: cond} for cond in STATE_CONDITION_TYPES + ] if supported_features & SUPPORT_SET_POSITION: - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_position", - } - ) + conditions.append({**base_condition, CONF_TYPE: "is_position"}) if supported_features & SUPPORT_SET_TILT_POSITION: - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_tilt_position", - } - ) + conditions.append({**base_condition, CONF_TYPE: "is_tilt_position"}) return conditions -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" if config[CONF_TYPE] not in ["is_position", "is_tilt_position"]: return {} @@ -167,12 +120,9 @@ async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] in STATE_CONDITION_TYPES: if config[CONF_TYPE] == "is_open": state = STATE_OPEN @@ -190,22 +140,19 @@ def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: return test_is_state if config[CONF_TYPE] == "is_position": - position = "current_position" + position_attr = "current_position" if config[CONF_TYPE] == "is_tilt_position": - position = "current_tilt_position" + position_attr = "current_tilt_position" min_pos = config.get(CONF_ABOVE) max_pos = config.get(CONF_BELOW) - value_template = template.Template( # type: ignore - f"{{{{ state.attributes.{position} }}}}" - ) @callback - def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate template based if-condition.""" - value_template.hass = hass - + def check_numeric_state( + hass: HomeAssistant, variables: TemplateVarsType = None + ) -> bool: + """Return whether the criteria are met.""" return condition.async_numeric_state( - hass, config[ATTR_ENTITY_ID], max_pos, min_pos, value_template + hass, config[ATTR_ENTITY_ID], max_pos, min_pos, attribute=position_attr ) - return template_if + return check_numeric_state diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 9b94833bb2964..f960fcdcce690 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -1,16 +1,20 @@ """Provides device automations for Cover.""" from __future__ import annotations +from typing import Any + import voluptuous as vol -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, state as state_trigger, ) from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, CONF_ABOVE, CONF_BELOW, CONF_DEVICE_ID, @@ -27,6 +31,7 @@ ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType from . import ( @@ -41,7 +46,7 @@ STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} POSITION_TRIGGER_SCHEMA = vol.All( - TRIGGER_BASE_SCHEMA.extend( + DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(POSITION_TRIGGER_TYPES), @@ -56,7 +61,7 @@ cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), ) -STATE_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +STATE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(STATE_TRIGGER_TYPES), @@ -67,7 +72,9 @@ TRIGGER_SCHEMA = vol.Any(POSITION_TRIGGER_SCHEMA, STATE_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Cover devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -77,11 +84,7 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: if entry.domain != DOMAIN: continue - state = hass.states.get(entry.entity_id) - if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: - continue - - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = get_supported_features(hass, entry.entity_id) supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) # Add triggers for each entity that belongs to this integration @@ -118,7 +121,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" if config[CONF_TYPE] not in POSITION_TRIGGER_TYPES: return { @@ -145,7 +150,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] in STATE_TRIGGER_TYPES: @@ -165,7 +170,9 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config( + hass, state_config + ) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) @@ -185,7 +192,9 @@ async def async_attach_trigger( CONF_ABOVE: min_pos, CONF_VALUE_TEMPLATE: value_template, } - numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA(numeric_state_config) + numeric_state_config = await numeric_state_trigger.async_validate_trigger_config( + hass, numeric_state_config + ) return await numeric_state_trigger.async_attach_trigger( hass, numeric_state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index c96b9ec5acc67..59846627890f4 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -6,12 +6,6 @@ import logging from typing import Any -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, - ATTR_POSITION, - ATTR_TILT_POSITION, -) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -27,7 +21,13 @@ ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN +from . import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -42,9 +42,7 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - cur_state = hass.states.get(state.entity_id) - - if cur_state is None: + if (cur_state := hass.states.get(state.entity_id)) is None: _LOGGER.warning("Unable to find entity %s", state.entity_id) return diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 1419a5f48edea..2f8e20464f3f3 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -4,74 +4,88 @@ open_cover: name: Open description: Open all or specified cover. target: + entity: + domain: cover close_cover: name: Close description: Close all or specified cover. target: + entity: + domain: cover toggle: name: Toggle description: Toggle a cover open/closed. target: + entity: + domain: cover set_cover_position: name: Set position description: Move to specific position all or specified cover. target: + entity: + domain: cover fields: position: name: Position description: Position of the cover required: true - example: 30 selector: number: min: 0 max: 100 - step: 1 unit_of_measurement: "%" - mode: slider stop_cover: name: Stop description: Stop all or specified cover. target: + entity: + domain: cover open_cover_tilt: name: Open tilt description: Open all or specified cover tilt. target: + entity: + domain: cover close_cover_tilt: name: Close tilt description: Close all or specified cover tilt. target: + entity: + domain: cover toggle_cover_tilt: name: Toggle tilt description: Toggle a cover tilt open/closed. target: + entity: + domain: cover set_cover_tilt_position: name: Set tilt position description: Move to specific position all or specified cover tilt. target: + entity: + domain: cover fields: tilt_position: name: Tilt position description: Tilt position of the cover. required: true - example: 30 selector: number: min: 0 max: 100 - step: 1 unit_of_measurement: "%" - mode: slider stop_cover_tilt: name: Stop tilt description: Stop all or specified cover. target: + entity: + domain: cover diff --git a/homeassistant/components/cover/translations/es-419.json b/homeassistant/components/cover/translations/es-419.json index c6f9f7db7dd18..d2a1aebaa1ddc 100644 --- a/homeassistant/components/cover/translations/es-419.json +++ b/homeassistant/components/cover/translations/es-419.json @@ -6,7 +6,8 @@ "open": "Abrir {entity_name}", "open_tilt": "Abrir la inclinaci\u00f3n de {entity_name}", "set_position": "Establecer la posici\u00f3n de {entity_name}", - "set_tilt_position": "Establecer la posici\u00f3n de inclinaci\u00f3n {entity_name}" + "set_tilt_position": "Establecer la posici\u00f3n de inclinaci\u00f3n {entity_name}", + "stop": "Detener {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e1 cerrado", diff --git a/homeassistant/components/cover/translations/he.json b/homeassistant/components/cover/translations/he.json index ebc7d39b450b7..66f6b9f3bbe6b 100644 --- a/homeassistant/components/cover/translations/he.json +++ b/homeassistant/components/cover/translations/he.json @@ -1,11 +1,38 @@ { + "device_automation": { + "action_type": { + "close": "\u05e1\u05d2\u05d9\u05e8\u05ea {entity_name}", + "close_tilt": "\u05e1\u05d2\u05d9\u05e8\u05ea \u05d4\u05d8\u05d9\u05d4 \u05e9\u05dc {entity_name}", + "open": "\u05e4\u05ea\u05d9\u05d7\u05ea {entity_name}", + "open_tilt": "\u05e4\u05ea\u05d9\u05d7\u05ea \u05d4\u05d8\u05d9\u05d9\u05ea {entity_name}", + "set_position": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05de\u05d9\u05e7\u05d5\u05dd {entity_name}", + "set_tilt_position": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05d8\u05d9\u05d9\u05ea {entity_name}", + "stop": "\u05e2\u05e6\u05d5\u05e8 {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} \u05e1\u05d2\u05d5\u05e8", + "is_closing": "{entity_name} \u05e0\u05e1\u05d2\u05e8", + "is_open": "{entity_name} \u05e4\u05ea\u05d5\u05d7", + "is_opening": "{entity_name} \u05e0\u05e4\u05ea\u05d7", + "is_position": "\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05e0\u05d5\u05db\u05d7\u05d9 {entity_name} \u05d4\u05d5\u05d0", + "is_tilt_position": "\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05d4\u05d8\u05d9\u05d4 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9 {entity_name} \u05d4\u05d5\u05d0" + }, + "trigger_type": { + "closed": "{entity_name} \u05e1\u05d2\u05d5\u05e8", + "closing": "{entity_name} \u05e0\u05e1\u05d2\u05e8", + "opened": "{entity_name} \u05e0\u05e4\u05ea\u05d7", + "opening": "\u05e4\u05ea\u05d9\u05d7\u05ea {entity_name}", + "position": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05de\u05d9\u05e7\u05d5\u05dd", + "tilt_position": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05d8\u05d9\u05d4" + } + }, "state": { "_": { - "closed": "\u05e0\u05e1\u05d2\u05e8", + "closed": "\u05e1\u05d2\u05d5\u05e8", "closing": "\u05e1\u05d5\u05d2\u05e8", "open": "\u05e4\u05ea\u05d5\u05d7", "opening": "\u05e4\u05d5\u05ea\u05d7", - "stopped": "\u05e2\u05e6\u05d5\u05e8" + "stopped": "\u05e2\u05e6\u05e8" } }, "title": "\u05d5\u05d9\u05dc\u05d5\u05df" diff --git a/homeassistant/components/cover/translations/hu.json b/homeassistant/components/cover/translations/hu.json index 87bd1c241c667..2155907cae259 100644 --- a/homeassistant/components/cover/translations/hu.json +++ b/homeassistant/components/cover/translations/hu.json @@ -29,10 +29,10 @@ "state": { "_": { "closed": "Z\u00e1rva", - "closing": "Z\u00e1r\u00e1s", + "closing": "Z\u00e1r\u00f3dik", "open": "Nyitva", - "opening": "Nyit\u00e1s", - "stopped": "Meg\u00e1ll\u00edtva" + "opening": "Ny\u00edlik", + "stopped": "Meg\u00e1llt" } }, "title": "Bor\u00edt\u00f3" diff --git a/homeassistant/components/cover/translations/ja.json b/homeassistant/components/cover/translations/ja.json index 859240315bfb7..0542852a2d3cb 100644 --- a/homeassistant/components/cover/translations/ja.json +++ b/homeassistant/components/cover/translations/ja.json @@ -1,8 +1,39 @@ { + "device_automation": { + "action_type": { + "close": "\u30af\u30ed\u30fc\u30ba {entity_name}", + "close_tilt": "\u30af\u30ed\u30fc\u30ba {entity_name} \u50be\u304d", + "open": "\u30aa\u30fc\u30d7\u30f3 {entity_name}", + "open_tilt": "\u30aa\u30fc\u30d7\u30f3 {entity_name} \u50be\u304d", + "set_position": "{entity_name} \u4f4d\u7f6e\u306e\u8a2d\u5b9a", + "set_tilt_position": "{entity_name} \u50be\u659c\u4f4d\u7f6e\u306e\u8a2d\u5b9a", + "stop": "\u505c\u6b62 {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} \u306f\u9589\u3058\u3066\u3044\u307e\u3059", + "is_closing": "{entity_name} \u304c\u7d42\u4e86\u3057\u3066\u3044\u307e\u3059", + "is_open": "{entity_name} \u304c\u958b\u3044\u3066\u3044\u307e\u3059", + "is_opening": "{entity_name} \u304c\u958b\u3044\u3066\u3044\u307e\u3059(is opening)", + "is_position": "\u73fe\u5728\u306e {entity_name} \u4f4d\u7f6e", + "is_tilt_position": "\u73fe\u5728\u306e {entity_name} \u50be\u659c\u4f4d\u7f6e" + }, + "trigger_type": { + "closed": "{entity_name} \u30af\u30ed\u30fc\u30ba\u30c9", + "closing": "{entity_name} \u304c\u7d42\u4e86", + "opened": "{entity_name} \u304c\u958b\u304b\u308c\u307e\u3057\u305f", + "opening": "{entity_name} \u304c\u958b\u304f(Opening)", + "position": "{entity_name} \u4f4d\u7f6e\u306e\u5909\u5316", + "tilt_position": "{entity_name} \u50be\u659c\u4f4d\u7f6e\u306e\u5909\u5316" + } + }, "state": { "_": { "closed": "\u9589\u9396", - "opening": "\u6249" + "closing": "\u9589\u3058\u3066\u3044\u307e\u3059", + "open": "\u30aa\u30fc\u30d7\u30f3", + "opening": "\u30aa\u30fc\u30d7\u30cb\u30f3\u30b0", + "stopped": "\u505c\u6b62" } - } + }, + "title": "\u30ab\u30d0\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/cover/translations/nl.json b/homeassistant/components/cover/translations/nl.json index 8b1ca3c3500f7..c3998187c4f7b 100644 --- a/homeassistant/components/cover/translations/nl.json +++ b/homeassistant/components/cover/translations/nl.json @@ -6,7 +6,7 @@ "open": "Open {entity_name}", "open_tilt": "Open de kanteling {entity_name}", "set_position": "Stel de positie van {entity_name} in", - "set_tilt_position": "Stel de {entity_name} kantelpositie in", + "set_tilt_position": "Stel de kantelpositie van {entity_name} in", "stop": "Stop {entity_name}" }, "condition_type": { @@ -35,5 +35,5 @@ "stopped": "Gestopt" } }, - "title": "Bedekking" + "title": "Rolluik" } \ No newline at end of file diff --git a/homeassistant/components/cover/translations/tr.json b/homeassistant/components/cover/translations/tr.json index f042233a6d128..7e32eb9e846e2 100644 --- a/homeassistant/components/cover/translations/tr.json +++ b/homeassistant/components/cover/translations/tr.json @@ -2,12 +2,33 @@ "device_automation": { "action_type": { "close": "{entity_name} kapat", - "open": "{entity_name} a\u00e7\u0131n" + "close_tilt": "{entity_name} e\u011fimini kapat", + "open": "{entity_name} a\u00e7\u0131n", + "open_tilt": "{entity_name} e\u011fimini a\u00e7", + "set_position": "{entity_name} konumunu ayarla", + "set_tilt_position": "{entity_name} e\u011fim konumunu ayarla", + "stop": "{entity_name} durdur" + }, + "condition_type": { + "is_closed": "{entity_name} kapat\u0131ld\u0131", + "is_closing": "{entity_name} kapan\u0131yor", + "is_open": "{entity_name} a\u00e7\u0131k", + "is_opening": "{entity_name} a\u00e7\u0131l\u0131yor", + "is_position": "Ge\u00e7erli {entity_name} konumu:", + "is_tilt_position": "Ge\u00e7erli {entity_name} e\u011fim konumu:" + }, + "trigger_type": { + "closed": "{entity_name} kapat\u0131ld\u0131", + "closing": "{entity_name} kapan\u0131yor", + "opened": "{entity_name} a\u00e7\u0131ld\u0131", + "opening": "{entity_name} a\u00e7\u0131l\u0131yor", + "position": "{entity_name} konum de\u011fi\u015fiklikleri", + "tilt_position": "{entity_name} e\u011fim konumu de\u011fi\u015fiklikleri" } }, "state": { "_": { - "closed": "Kapal\u0131", + "closed": "Kapand\u0131", "closing": "Kapan\u0131yor", "open": "A\u00e7\u0131k", "opening": "A\u00e7\u0131l\u0131yor", diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py index a784fbd2f8982..7bd2be96030fe 100644 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_API_KEY, CONF_CLIENT_ID, CONF_HOST @@ -17,7 +17,7 @@ GRANT_TYPE = "client_credentials" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_CLIENT_ID): cv.string, diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 019383446944f..c34ea939de790 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -43,12 +43,12 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return FREQUENCY_GIGAHERTZ diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py new file mode 100644 index 0000000000000..92b2f4de5ca2d --- /dev/null +++ b/homeassistant/components/crownstone/__init__.py @@ -0,0 +1,25 @@ +"""Integration for Crownstone.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .entry_manager import CrownstoneEntryManager + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Initiate setup for a Crownstone config entry.""" + manager = CrownstoneEntryManager(hass, entry) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager + + return await manager.async_setup() + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload() + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py new file mode 100644 index 0000000000000..7c0ea4fd27d59 --- /dev/null +++ b/homeassistant/components/crownstone/config_flow.py @@ -0,0 +1,260 @@ +"""Flow handler for Crownstone.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from crownstone_cloud import CrownstoneCloud +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant.components import usb +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowHandler, FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_USB_MANUAL_PATH, + CONF_USB_PATH, + CONF_USB_SPHERE, + CONF_USB_SPHERE_OPTION, + CONF_USE_USB_OPTION, + DOMAIN, + DONT_USE_USB, + MANUAL_PATH, + REFRESH_LIST, +) +from .helpers import list_ports_as_str + +CONFIG_FLOW = "config_flow" +OPTIONS_FLOW = "options_flow" + + +class BaseCrownstoneFlowHandler(FlowHandler): + """Represent the base flow for Crownstone.""" + + cloud: CrownstoneCloud + + def __init__( + self, flow_type: str, create_entry_cb: Callable[..., FlowResult] + ) -> None: + """Set up flow instance.""" + self.flow_type = flow_type + self.create_entry_callback = create_entry_cb + self.usb_path: str | None = None + self.usb_sphere_id: str | None = None + + async def async_step_usb_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Set up a Crownstone USB dongle.""" + list_of_ports = await self.hass.async_add_executor_job( + serial.tools.list_ports.comports + ) + if self.flow_type == CONFIG_FLOW: + ports_as_string = list_ports_as_str(list_of_ports) + else: + ports_as_string = list_ports_as_str(list_of_ports, False) + + if user_input is not None: + selection = user_input[CONF_USB_PATH] + + if selection == DONT_USE_USB: + return self.create_entry_callback() + if selection == MANUAL_PATH: + return await self.async_step_usb_manual_config() + if selection != REFRESH_LIST: + if self.flow_type == OPTIONS_FLOW: + index = ports_as_string.index(selection) + else: + index = ports_as_string.index(selection) - 1 + + selected_port: ListPortInfo = list_of_ports[index] + self.usb_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, selected_port.device + ) + return await self.async_step_usb_sphere_config() + + return self.async_show_form( + step_id="usb_config", + data_schema=vol.Schema( + {vol.Required(CONF_USB_PATH): vol.In(ports_as_string)} + ), + ) + + async def async_step_usb_manual_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manually enter Crownstone USB dongle path.""" + if user_input is None: + return self.async_show_form( + step_id="usb_manual_config", + data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}), + ) + + self.usb_path = user_input[CONF_USB_MANUAL_PATH] + return await self.async_step_usb_sphere_config() + + async def async_step_usb_sphere_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select a Crownstone sphere that the USB operates in.""" + spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} + # no need to select if there's only 1 option + sphere_id: str | None = None + if len(spheres) == 1: + sphere_id = next(iter(spheres.values())) + + if user_input is None and sphere_id is None: + return self.async_show_form( + step_id="usb_sphere_config", + data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(spheres.keys())}), + ) + + if sphere_id: + self.usb_sphere_id = sphere_id + elif user_input: + self.usb_sphere_id = spheres[user_input[CONF_USB_SPHERE]] + + return self.create_entry_callback() + + +class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain=DOMAIN): + """Handle a config flow for Crownstone.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> CrownstoneOptionsFlowHandler: + """Return the Crownstone options.""" + return CrownstoneOptionsFlowHandler(config_entry) + + def __init__(self) -> None: + """Initialize the flow.""" + super().__init__(CONFIG_FLOW, self.async_create_new_entry) + self.login_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + ) + + self.cloud = CrownstoneCloud( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_error: + if auth_error.type == "LOGIN_FAILED": + errors["base"] = "invalid_auth" + elif auth_error.type == "LOGIN_FAILED_EMAIL_NOT_VERIFIED": + errors["base"] = "account_not_verified" + except CrownstoneUnknownError: + errors["base"] = "unknown_error" + + # show form again, with the errors + if errors: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + await self.async_set_unique_id(self.cloud.cloud_data.user_id) + self._abort_if_unique_id_configured() + + self.login_info = user_input + return await self.async_step_usb_config() + + def async_create_new_entry(self) -> FlowResult: + """Create a new entry.""" + return super().async_create_entry( + title=f"Account: {self.login_info[CONF_EMAIL]}", + data={ + CONF_EMAIL: self.login_info[CONF_EMAIL], + CONF_PASSWORD: self.login_info[CONF_PASSWORD], + }, + options={CONF_USB_PATH: self.usb_path, CONF_USB_SPHERE: self.usb_sphere_id}, + ) + + +class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): + """Handle Crownstone options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize Crownstone options.""" + super().__init__(OPTIONS_FLOW, self.async_create_new_entry) + self.entry = config_entry + self.updated_options = config_entry.options.copy() + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Crownstone options.""" + self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud + + spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} + usb_path = self.entry.options.get(CONF_USB_PATH) + usb_sphere = self.entry.options.get(CONF_USB_SPHERE) + + options_schema = vol.Schema( + {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} + ) + if usb_path is not None and len(spheres) > 1: + options_schema = options_schema.extend( + { + vol.Optional( + CONF_USB_SPHERE_OPTION, + default=self.cloud.cloud_data.data[usb_sphere].name, + ): vol.In(spheres.keys()) + } + ) + + if user_input is not None: + if user_input[CONF_USE_USB_OPTION] and usb_path is None: + return await self.async_step_usb_config() + if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: + self.updated_options[CONF_USB_PATH] = None + self.updated_options[CONF_USB_SPHERE] = None + elif ( + CONF_USB_SPHERE_OPTION in user_input + and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere + ): + sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] + self.updated_options[CONF_USB_SPHERE] = sphere_id + + return self.async_create_new_entry() + + return self.async_show_form(step_id="init", data_schema=options_schema) + + def async_create_new_entry(self) -> FlowResult: + """Create a new entry.""" + # these attributes will only change when a usb was configured + if self.usb_path is not None and self.usb_sphere_id is not None: + self.updated_options[CONF_USB_PATH] = self.usb_path + self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id + + return super().async_create_entry(title="", data=self.updated_options) diff --git a/homeassistant/components/crownstone/const.py b/homeassistant/components/crownstone/const.py new file mode 100644 index 0000000000000..a362435b9ce03 --- /dev/null +++ b/homeassistant/components/crownstone/const.py @@ -0,0 +1,44 @@ +"""Constants for the crownstone integration.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.const import Platform + +# Platforms +DOMAIN: Final = "crownstone" +PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] + +# Listeners +SSE_LISTENERS: Final = "sse_listeners" +UART_LISTENERS: Final = "uart_listeners" + +# Unique ID suffixes +CROWNSTONE_SUFFIX: Final = "crownstone" + +# Signals (within integration) +SIG_CROWNSTONE_STATE_UPDATE: Final = "crownstone.crownstone_state_update" +SIG_CROWNSTONE_UPDATE: Final = "crownstone.crownstone_update" +SIG_UART_STATE_CHANGE: Final = "crownstone.uart_state_change" + +# Config flow +CONF_USB_PATH: Final = "usb_path" +CONF_USB_MANUAL_PATH: Final = "usb_manual_path" +CONF_USB_SPHERE: Final = "usb_sphere" +# Options flow +CONF_USE_USB_OPTION: Final = "use_usb_option" +CONF_USB_SPHERE_OPTION: Final = "usb_sphere_option" +# USB config list entries +DONT_USE_USB: Final = "Don't use USB" +REFRESH_LIST: Final = "Refresh list" +MANUAL_PATH: Final = "Enter manually" + +# Crownstone entity +CROWNSTONE_INCLUDE_TYPES: Final[dict[str, str]] = { + "PLUG": "Plug", + "BUILTIN": "Built-in", + "BUILTIN_ONE": "Built-in One", +} + +# Crownstone USB Dongle +CROWNSTONE_USB: Final = "CROWNSTONE_USB" diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/devices.py new file mode 100644 index 0000000000000..ead2c54a58e00 --- /dev/null +++ b/homeassistant/components/crownstone/devices.py @@ -0,0 +1,38 @@ +"""Base classes for Crownstone devices.""" +from __future__ import annotations + +from crownstone_cloud.cloud_models.crownstones import Crownstone + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN + + +class CrownstoneBaseEntity(Entity): + """Base entity class for Crownstone devices.""" + + _attr_should_poll = False + + def __init__(self, device: Crownstone) -> None: + """Initialize the device.""" + self.device = device + + @property + def cloud_id(self) -> str: + """ + Return the unique identifier for this device. + + Used as device ID and to generate unique entity ID's. + """ + return str(self.device.cloud_id) + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self.cloud_id)}, + manufacturer="Crownstone", + model=CROWNSTONE_INCLUDE_TYPES[self.device.type], + name=self.device.name, + sw_version=self.device.sw_version, + ) diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py new file mode 100644 index 0000000000000..b1963462adc70 --- /dev/null +++ b/homeassistant/components/crownstone/entry_manager.py @@ -0,0 +1,189 @@ +"""Manager to set up IO with Crownstone devices for a config entry.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from crownstone_cloud import CrownstoneCloud +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +from crownstone_sse import CrownstoneSSEAsync +from crownstone_uart import CrownstoneUart, UartEventBus +from crownstone_uart.Exceptions import UartException + +from homeassistant.components import persistent_notification +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_USB_PATH, + CONF_USB_SPHERE, + DOMAIN, + PLATFORMS, + SSE_LISTENERS, + UART_LISTENERS, +) +from .helpers import get_port +from .listeners import setup_sse_listeners, setup_uart_listeners + +_LOGGER = logging.getLogger(__name__) + + +class CrownstoneEntryManager: + """Manage a Crownstone config entry.""" + + uart: CrownstoneUart | None = None + cloud: CrownstoneCloud + sse: CrownstoneSSEAsync + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the hub.""" + self.hass = hass + self.config_entry = config_entry + self.listeners: dict[str, Any] = {} + self.usb_sphere_id: str | None = None + + async def async_setup(self) -> bool: + """ + Set up a Crownstone config entry. + + Returns True if the setup was successful. + """ + email = self.config_entry.data[CONF_EMAIL] + password = self.config_entry.data[CONF_PASSWORD] + + self.cloud = CrownstoneCloud( + email=email, + password=password, + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_err: + _LOGGER.error( + "Auth error during login with type: %s and message: %s", + auth_err.type, + auth_err.message, + ) + return False + except CrownstoneUnknownError as unknown_err: + _LOGGER.error("Unknown error during login") + raise ConfigEntryNotReady from unknown_err + + # A new clientsession is created because the default one does not cleanup on unload + self.sse = CrownstoneSSEAsync( + email=email, + password=password, + access_token=self.cloud.access_token, + websession=aiohttp_client.async_create_clientsession(self.hass), + ) + # Listen for events in the background, without task tracking + asyncio.create_task(self.async_process_events(self.sse)) + setup_sse_listeners(self) + + # Set up a Crownstone USB only if path exists + if self.config_entry.options[CONF_USB_PATH] is not None: + await self.async_setup_usb() + + # Save the sphere where the USB is located + # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple + self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] + + self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) + + # HA specific listeners + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(_async_update_listener) + ) + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown) + ) + + return True + + async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None: + """Asynchronous iteration of Crownstone SSE events.""" + async with sse_client as client: + async for event in client: + if event is not None: + async_dispatcher_send(self.hass, f"{DOMAIN}_{event.type}", event) + + async def async_setup_usb(self) -> None: + """Attempt setup of a Crownstone usb dongle.""" + # Trace by-id symlink back to the serial port + serial_port = await self.hass.async_add_executor_job( + get_port, self.config_entry.options[CONF_USB_PATH] + ) + if serial_port is None: + return + + self.uart = CrownstoneUart() + # UartException is raised when serial controller fails to open + try: + await self.uart.initialize_usb(serial_port) + except UartException: + self.uart = None + # Set entry options for usb to null + updated_options = self.config_entry.options.copy() + updated_options[CONF_USB_PATH] = None + updated_options[CONF_USB_SPHERE] = None + # Ensure that the user can configure an USB again from options + self.hass.config_entries.async_update_entry( + self.config_entry, options=updated_options + ) + # Show notification to ensure the user knows the cloud is now used + persistent_notification.async_create( + self.hass, + f"Setup of Crownstone USB dongle was unsuccessful on port {serial_port}.\n \ + Crownstone Cloud will be used to switch Crownstones.\n \ + Please check if your port is correct and set up the USB again from integration options.", + "Crownstone", + "crownstone_usb_dongle_setup", + ) + return + + setup_uart_listeners(self) + + async def async_unload(self) -> bool: + """Unload the current config entry.""" + # Authentication failed + if self.cloud.cloud_data is None: + return True + + self.sse.close_client() + for sse_unsub in self.listeners[SSE_LISTENERS]: + sse_unsub() + + if self.uart: + self.uart.stop() + for subscription_id in self.listeners[UART_LISTENERS]: + UartEventBus.unsubscribe(subscription_id) + + unload_ok = await self.hass.config_entries.async_unload_platforms( + self.config_entry, PLATFORMS + ) + + if unload_ok: + self.hass.data[DOMAIN].pop(self.config_entry.entry_id) + + return unload_ok + + @callback + def on_shutdown(self, _: Event) -> None: + """Close all IO connections.""" + self.sse.close_client() + if self.uart: + self.uart.stop() + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py new file mode 100644 index 0000000000000..58b4dcdba47e1 --- /dev/null +++ b/homeassistant/components/crownstone/helpers.py @@ -0,0 +1,59 @@ +"""Helper functions for the Crownstone integration.""" +from __future__ import annotations + +import os + +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant.components import usb + +from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST + + +def list_ports_as_str( + serial_ports: list[ListPortInfo], no_usb_option: bool = True +) -> list[str]: + """ + Represent currently available serial ports as string. + + Adds option to not use usb on top of the list, + option to use manual path or refresh list at the end. + """ + ports_as_string: list[str] = [] + + if no_usb_option: + ports_as_string.append(DONT_USE_USB) + + for port in serial_ports: + ports_as_string.append( + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None, + f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None, + ) + ) + ports_as_string.append(MANUAL_PATH) + ports_as_string.append(REFRESH_LIST) + + return ports_as_string + + +def get_port(dev_path: str) -> str | None: + """Get the port that the by-id link points to.""" + # not a by-id link, but just given path + by_id = "/dev/serial/by-id" + if by_id not in dev_path: + return dev_path + + try: + return f"/dev/{os.path.basename(os.readlink(dev_path))}" + except FileNotFoundError: + return None + + +def map_from_to(val: int, in_min: int, in_max: int, out_min: int, out_max: int) -> int: + """Map a value from a range to another.""" + return int((val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py new file mode 100644 index 0000000000000..ff647b2fc849a --- /dev/null +++ b/homeassistant/components/crownstone/light.py @@ -0,0 +1,168 @@ +"""Support for Crownstone devices.""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, Any + +from crownstone_cloud.cloud_models.crownstones import Crownstone +from crownstone_cloud.const import DIMMING_ABILITY +from crownstone_cloud.exceptions import CrownstoneAbilityError +from crownstone_uart import CrownstoneUart + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CROWNSTONE_INCLUDE_TYPES, + CROWNSTONE_SUFFIX, + DOMAIN, + SIG_CROWNSTONE_STATE_UPDATE, + SIG_UART_STATE_CHANGE, +) +from .devices import CrownstoneBaseEntity +from .helpers import map_from_to + +if TYPE_CHECKING: + from .entry_manager import CrownstoneEntryManager + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up crownstones from a config entry.""" + manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[CrownstoneEntity] = [] + + # Add Crownstone entities that support switching/dimming + for sphere in manager.cloud.cloud_data: + for crownstone in sphere.crownstones: + if crownstone.type in CROWNSTONE_INCLUDE_TYPES: + # Crownstone can communicate with Crownstone USB + if manager.uart and sphere.cloud_id == manager.usb_sphere_id: + entities.append(CrownstoneEntity(crownstone, manager.uart)) + # Crownstone can't communicate with Crownstone USB + else: + entities.append(CrownstoneEntity(crownstone)) + + async_add_entities(entities) + + +def crownstone_state_to_hass(value: int) -> int: + """Crownstone 0..100 to hass 0..255.""" + return map_from_to(value, 0, 100, 0, 255) + + +def hass_to_crownstone_state(value: int) -> int: + """Hass 0..255 to Crownstone 0..100.""" + return map_from_to(value, 0, 255, 0, 100) + + +class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): + """ + Representation of a crownstone. + + Light platform is used to support dimming. + """ + + _attr_icon = "mdi:power-socket-de" + + def __init__( + self, crownstone_data: Crownstone, usb: CrownstoneUart | None = None + ) -> None: + """Initialize the crownstone.""" + super().__init__(crownstone_data) + self.usb = usb + # Entity class attributes + self._attr_name = str(self.device.name) + self._attr_unique_id = f"{self.cloud_id}-{CROWNSTONE_SUFFIX}" + + @property + def brightness(self) -> int | None: + """Return the brightness if dimming enabled.""" + return crownstone_state_to_hass(self.device.state) + + @property + def is_on(self) -> bool: + """Return if the device is on.""" + return crownstone_state_to_hass(self.device.state) > 0 + + @property + def supported_features(self) -> int: + """Return the supported features of this Crownstone.""" + if self.device.abilities.get(DIMMING_ABILITY).is_enabled: + return SUPPORT_BRIGHTNESS + return 0 + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + # new state received + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIG_CROWNSTONE_STATE_UPDATE, self.async_write_ha_state + ) + ) + # updates state attributes when usb connects/disconnects + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIG_UART_STATE_CHANGE, self.async_write_ha_state + ) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on this light via dongle or cloud.""" + if ATTR_BRIGHTNESS in kwargs: + if self.usb is not None and self.usb.is_ready(): + await self.hass.async_add_executor_job( + partial( + self.usb.dim_crownstone, + self.device.unique_id, + hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]), + ) + ) + else: + try: + await self.device.async_set_brightness( + hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) + ) + except CrownstoneAbilityError as ability_error: + raise HomeAssistantError(ability_error) from ability_error + + # assume brightness is set on device + self.device.state = hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) + self.async_write_ha_state() + + elif self.usb is not None and self.usb.is_ready(): + await self.hass.async_add_executor_job( + partial(self.usb.switch_crownstone, self.device.unique_id, on=True) + ) + self.device.state = 100 + self.async_write_ha_state() + + else: + await self.device.async_turn_on() + self.device.state = 100 + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off this device via dongle or cloud.""" + if self.usb is not None and self.usb.is_ready(): + await self.hass.async_add_executor_job( + partial(self.usb.switch_crownstone, self.device.unique_id, on=False) + ) + + else: + await self.device.async_turn_off() + + self.device.state = 0 + self.async_write_ha_state() diff --git a/homeassistant/components/crownstone/listeners.py b/homeassistant/components/crownstone/listeners.py new file mode 100644 index 0000000000000..63891545cab84 --- /dev/null +++ b/homeassistant/components/crownstone/listeners.py @@ -0,0 +1,154 @@ +""" +Listeners for updating data in the Crownstone integration. + +For data updates, Cloud Push is used in form of an SSE server that sends out events. +For fast device switching Local Push is used in form of a USB dongle that hooks into a BLE mesh. +""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, cast + +from crownstone_cloud.exceptions import CrownstoneNotFoundError +from crownstone_core.packets.serviceDataParsers.containers.AdvExternalCrownstoneState import ( + AdvExternalCrownstoneState, +) +from crownstone_core.packets.serviceDataParsers.containers.elements.AdvTypes import ( + AdvType, +) +from crownstone_core.protocol.SwitchState import SwitchState +from crownstone_sse.const import ( + EVENT_ABILITY_CHANGE, + EVENT_ABILITY_CHANGE_DIMMING, + EVENT_SWITCH_STATE_UPDATE, +) +from crownstone_sse.events import AbilityChangeEvent, SwitchStateUpdateEvent +from crownstone_uart import UartEventBus, UartTopics +from crownstone_uart.topics.SystemTopics import SystemTopics + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) + +from .const import ( + DOMAIN, + SIG_CROWNSTONE_STATE_UPDATE, + SIG_UART_STATE_CHANGE, + SSE_LISTENERS, + UART_LISTENERS, +) + +if TYPE_CHECKING: + from .entry_manager import CrownstoneEntryManager + + +@callback +def async_update_crwn_state_sse( + manager: CrownstoneEntryManager, switch_event: SwitchStateUpdateEvent +) -> None: + """Update the state of a Crownstone when switched externally.""" + try: + updated_crownstone = manager.cloud.get_crownstone_by_id(switch_event.cloud_id) + except CrownstoneNotFoundError: + return + + # only update on change. + if updated_crownstone.state != switch_event.switch_state: + updated_crownstone.state = switch_event.switch_state + async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +@callback +def async_update_crwn_ability( + manager: CrownstoneEntryManager, ability_event: AbilityChangeEvent +) -> None: + """Update the ability information of a Crownstone.""" + try: + updated_crownstone = manager.cloud.get_crownstone_by_id(ability_event.cloud_id) + except CrownstoneNotFoundError: + return + + ability_type = ability_event.ability_type + ability_enabled = ability_event.ability_enabled + # only update on a change in state + if updated_crownstone.abilities[ability_type].is_enabled == ability_enabled: + return + + # write the change to the crownstone entity. + updated_crownstone.abilities[ability_type].is_enabled = ability_enabled + + if ability_event.sub_type == EVENT_ABILITY_CHANGE_DIMMING: + # reload the config entry because dimming is part of supported features + manager.hass.async_create_task( + manager.hass.config_entries.async_reload(manager.config_entry.entry_id) + ) + else: + async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +def update_uart_state(manager: CrownstoneEntryManager, _: bool | None) -> None: + """Update the uart ready state for entities that use USB.""" + # update availability of power usage entities. + dispatcher_send(manager.hass, SIG_UART_STATE_CHANGE) + + +def update_crwn_state_uart( + manager: CrownstoneEntryManager, data: AdvExternalCrownstoneState +) -> None: + """Update the state of a Crownstone when switched externally.""" + if data.type != AdvType.EXTERNAL_STATE: + return + try: + updated_crownstone = manager.cloud.get_crownstone_by_uid( + data.crownstoneId, manager.usb_sphere_id + ) + except CrownstoneNotFoundError: + return + + if data.switchState is None: + return + # update on change + updated_state = cast(SwitchState, data.switchState) + if updated_crownstone.state != updated_state.intensity: + updated_crownstone.state = updated_state.intensity + + dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +def setup_sse_listeners(manager: CrownstoneEntryManager) -> None: + """Set up SSE listeners.""" + # save unsub function for when entry removed + manager.listeners[SSE_LISTENERS] = [ + async_dispatcher_connect( + manager.hass, + f"{DOMAIN}_{EVENT_SWITCH_STATE_UPDATE}", + partial(async_update_crwn_state_sse, manager), + ), + async_dispatcher_connect( + manager.hass, + f"{DOMAIN}_{EVENT_ABILITY_CHANGE}", + partial(async_update_crwn_ability, manager), + ), + ] + + +def setup_uart_listeners(manager: CrownstoneEntryManager) -> None: + """Set up UART listeners.""" + # save subscription id to unsub + manager.listeners[UART_LISTENERS] = [ + UartEventBus.subscribe( + SystemTopics.connectionEstablished, + partial(update_uart_state, manager), + ), + UartEventBus.subscribe( + SystemTopics.connectionClosed, + partial(update_uart_state, manager), + ), + UartEventBus.subscribe( + UartTopics.newDataAvailable, + partial(update_crwn_state_uart, manager), + ), + ] diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json new file mode 100644 index 0000000000000..758721d5f71bf --- /dev/null +++ b/homeassistant/components/crownstone/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "crownstone", + "name": "Crownstone", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/crownstone", + "requirements": [ + "crownstone-cloud==1.4.9", + "crownstone-sse==2.0.3", + "crownstone-uart==2.1.0", + "pyserial==3.5" + ], + "codeowners": ["@Crownstone", "@RicArch97"], + "after_dependencies": ["usb"], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/crownstone/strings.json b/homeassistant/components/crownstone/strings.json new file mode 100644 index 0000000000000..25c9fd10293e4 --- /dev/null +++ b/homeassistant/components/crownstone/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "usb_setup_complete": "Crownstone USB setup complete.", + "usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful." + }, + "error": { + "account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "Crownstone account" + }, + "usb_config": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle configuration", + "description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle manual path", + "description": "Manually enter the path of a Crownstone USB dongle." + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "title": "Crownstone USB Sphere", + "description": "Select a Crownstone Sphere where the USB is located." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "use_usb_option": "Use a Crownstone USB dongle for local data transmission", + "usb_sphere_option": "Crownstone Sphere where the USB is located" + } + }, + "usb_config": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle configuration", + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle manual path", + "description": "Manually enter the path of a Crownstone USB dongle." + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "title": "Crownstone USB Sphere", + "description": "Select a Crownstone Sphere where the USB is located." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/bg.json b/homeassistant/components/crownstone/translations/bg.json new file mode 100644 index 0000000000000..2c567e2a1e8e3 --- /dev/null +++ b/homeassistant/components/crownstone/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ca.json b/homeassistant/components/crownstone/translations/ca.json new file mode 100644 index 0000000000000..9de845d87c612 --- /dev/null +++ b/homeassistant/components/crownstone/translations/ca.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "usb_setup_complete": "S'ha completat la configuraci\u00f3 USB de Crownstone.", + "usb_setup_unsuccessful": "La configuraci\u00f3 USB de Crownstone ha fallat." + }, + "error": { + "account_not_verified": "Compte no verificat. Activa el teu compte mitjan\u00e7ant el correu d'activaci\u00f3 de Crownstone.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone o selecciona 'No utilitzar USB' si no vols configurar l'adaptador USB.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "title": "Compte de Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Esfera Crownstone on es troba l'USB.", + "use_usb_option": "Utilitza un adaptador USB Crownstone per a la transmissi\u00f3 de dades locals" + } + }, + "usb_config": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/cs.json b/homeassistant/components/crownstone/translations/cs.json new file mode 100644 index 0000000000000..f1e209b21d8f2 --- /dev/null +++ b/homeassistant/components/crownstone/translations/cs.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "usb_manual_config": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "usb_config_option": { + "data": { + "usb_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/de.json b/homeassistant/components/crownstone/translations/de.json new file mode 100644 index 0000000000000..a969d9b299986 --- /dev/null +++ b/homeassistant/components/crownstone/translations/de.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "usb_setup_complete": "Crownstone USB-Einrichtung abgeschlossen.", + "usb_setup_unsuccessful": "Crownstone USB-Einrichtung war nicht erfolgreich." + }, + "error": { + "account_not_verified": "Konto nicht verifiziert. Bitte aktiviere dein Konto \u00fcber die Aktivierungs-E-Mail von Crownstone.", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles aus, oder w\u00e4hle \"Don't use USB\", wenn du keinen USB-Dongle einrichten m\u00f6chtest.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + }, + "title": "Crownstone-Konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, wo sich der USB befindet", + "use_usb_option": "Verwende einen Crownstone USB-Dongle f\u00fcr die lokale Daten\u00fcbertragung" + } + }, + "usb_config": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_config_option": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/en.json b/homeassistant/components/crownstone/translations/en.json new file mode 100644 index 0000000000000..d6070c90a0f9b --- /dev/null +++ b/homeassistant/components/crownstone/translations/en.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "usb_setup_complete": "Crownstone USB setup complete.", + "usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful." + }, + "error": { + "account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Crownstone account" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere where the USB is located", + "use_usb_option": "Use a Crownstone USB dongle for local data transmission" + } + }, + "usb_config": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_config_option": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/es.json b/homeassistant/components/crownstone/translations/es.json new file mode 100644 index 0000000000000..f9038fb22b480 --- /dev/null +++ b/homeassistant/components/crownstone/translations/es.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositivo USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositivo USB" + } + }, + "user": { + "data": { + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositivo USB" + }, + "description": "Seleccione el puerto serie del dispositivo USB Crownstone.\n\nBusque un dispositivo con VID 10C4 y PID EA60.", + "title": "Configuraci\u00f3n del dispositivo USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Ruta del dispositivo USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositivo USB" + }, + "description": "Introduzca manualmente la ruta de un dispositivo USB Crownstone.", + "title": "Ruta manual del dispositivo USB Crownstone" + }, + "usb_manual_config_option": { + "title": "Ruta manual del dispositivo USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona una Esfera Crownstone donde se encuentra el USB.", + "title": "USB de Esfera Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona una Esfera Crownstone donde se encuentra el USB.", + "title": "USB de Esfera Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/et.json b/homeassistant/components/crownstone/translations/et.json new file mode 100644 index 0000000000000..3a651257e1aa5 --- /dev/null +++ b/homeassistant/components/crownstone/translations/et.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "usb_setup_complete": "Crownstone'i USB seadistamine on l\u00f5petatud.", + "usb_setup_unsuccessful": "Crownstone'i USB seadistamine nurjus." + }, + "error": { + "account_not_verified": "Konto pole kinnitatud. Aktiveeri oma konto Crownstone'i aktiveerimismeili kaudu.", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport v\u00f5i vali '\u00c4ra kasuta USB-d' kui ei soovi USB seadet h\u00e4\u00e4lestada. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + }, + "title": "Crownstone'i konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere kus USB asub.", + "use_usb_option": "Kasuta Crownstone'i USB seadet kohalikuks andmeedastuseks" + } + }, + "usb_config": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_config_option": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/fr.json b/homeassistant/components/crownstone/translations/fr.json new file mode 100644 index 0000000000000..783cd25bd491d --- /dev/null +++ b/homeassistant/components/crownstone/translations/fr.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "usb_setup_complete": "Configuration de la cl\u00e9 USB Crownstone termin\u00e9e.", + "usb_setup_unsuccessful": "La configuration USB de Crownstone a \u00e9chou\u00e9." + }, + "error": { + "account_not_verified": "Compte non v\u00e9rifi\u00e9. Veuillez activer votre compte via l'e-mail d'activation de Crownstone.", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "description": "S\u00e9lectionnez le port s\u00e9rie du dongle USB Crownstone ou s\u00e9lectionnez \u00ab\u00a0Ne pas utiliser USB\u00a0\u00bb si vous ne souhaitez pas configurer un dongle USB. \n\n Recherchez un appareil avec VID 10C4 et PID EA60.", + "title": "Configuration du dongle USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "description": "Entrez manuellement le chemin d'un dongle USB Crownstone.", + "title": "Chemin d'acc\u00e8s manuel du dongle USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "S\u00e9lectionnez une sph\u00e8re Crownstone o\u00f9 se trouve l\u2019USB.", + "title": "Sph\u00e8re USB Crownstone" + }, + "user": { + "data": { + "email": "Email", + "password": "Mot de passe" + }, + "title": "Compte Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Sph\u00e8re Crownstone o\u00f9 se trouve la cl\u00e9 USB", + "use_usb_option": "Utilisez un dongle USB Crownstone pour la transmission de donn\u00e9es locale" + } + }, + "usb_config": { + "data": { + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "description": "S\u00e9lectionnez le port s\u00e9rie du dongle USB Crownstone. \n\n Recherchez un appareil avec VID 10C4 et PID EA60.", + "title": "Configuration du dongle USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "description": "S\u00e9lectionnez le port s\u00e9rie du dongle USB Crownstone. \n\n Recherchez un appareil avec VID 10C4 et PID EA60.", + "title": "Configuration du dongle USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "description": "Entrez manuellement le chemin d'un dongle USB Crownstone.", + "title": "Chemin d'acc\u00e8s manuel du dongle USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "description": "Entrez manuellement le chemin d'un dongle USB Crownstone.", + "title": "Chemin d'acc\u00e8s manuel du dongle USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "S\u00e9lectionnez une sph\u00e8re Crownstone o\u00f9 se trouve l\u2019USB.", + "title": "Sph\u00e8re USB Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "S\u00e9lectionnez une sph\u00e8re Crownstone o\u00f9 se trouve l\u2019USB.", + "title": "Sph\u00e8re USB Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/he.json b/homeassistant/components/crownstone/translations/he.json new file mode 100644 index 0000000000000..af11b65839b68 --- /dev/null +++ b/homeassistant/components/crownstone/translations/he.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_config_option": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/hu.json b/homeassistant/components/crownstone/translations/hu.json new file mode 100644 index 0000000000000..2c2a2e34fe1d5 --- /dev/null +++ b/homeassistant/components/crownstone/translations/hu.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "usb_setup_complete": "A Crownstone USB be\u00e1ll\u00edt\u00e1sa befejez\u0151d\u00f6tt.", + "usb_setup_unsuccessful": "A Crownstone USB be\u00e1ll\u00edt\u00e1sa sikertelen volt." + }, + "error": { + "account_not_verified": "Nem ellen\u0151rz\u00f6tt fi\u00f3k. K\u00e9rj\u00fck, aktiv\u00e1lja fi\u00f3kj\u00e1t a Crownstone-t\u00f3l kapott aktiv\u00e1l\u00f3 e-mailben.", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t, vagy v\u00e1lassza 'Ne haszn\u00e1ljon USB-t' ha nem szerenke egy USB kulcsot be\u00e1ll\u00edtani most.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + }, + "title": "Crownstone fi\u00f3k" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, ahol az USB kulcs tal\u00e1lhat\u00f3", + "use_usb_option": "Crownstone USB-kulcs haszn\u00e1lata a helyi adat\u00e1tvitelhez" + } + }, + "usb_config": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_config_option": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/id.json b/homeassistant/components/crownstone/translations/id.json new file mode 100644 index 0000000000000..aef98346fd24d --- /dev/null +++ b/homeassistant/components/crownstone/translations/id.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "usb_setup_complete": "Penyiapan USB Crownstone selesai.", + "usb_setup_unsuccessful": "Penyiapan USB Crownstone tidak berhasil." + }, + "error": { + "account_not_verified": "Akun tidak diverifikasi. Aktifkan akun Anda melalui email aktivasi dari Crownstone.", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Jalur Perangkat USB" + }, + "description": "Pilih port serial dongle USB Crownstone, atau pilih 'Jangan gunakan USB' jika Anda tidak ingin menyiapkan dongle USB.\n\nCari perangkat dengan VID 10C4 dan PID EA60.", + "title": "Konfigurasi dongle USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + }, + "description": "Masukkan jalur dongle USB Crownstone secara manual.", + "title": "Jalur manual dongle USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Pilih Crownstone Sphere tempat USB berada.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + }, + "title": "Akun Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere tempat USB berada", + "use_usb_option": "Gunakan dongle USB Crownstone untuk transmisi data lokal" + } + }, + "usb_config": { + "data": { + "usb_path": "Jalur Perangkat USB" + }, + "description": "Pilih port serial dongle USB Crownstone. \n\nCari perangkat dengan VID 10C4 dan PID EA60.", + "title": "Konfigurasi dongle USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Jalur Perangkat USB" + }, + "description": "Pilih port serial dongle USB Crownstone. \n\nCari perangkat dengan VID 10C4 dan PID EA60.", + "title": "Konfigurasi dongle USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + }, + "description": "Masukkan jalur dongle USB Crownstone secara manual.", + "title": "Jalur manual dongle USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + }, + "description": "Masukkan jalur dongle USB Crownstone secara manual.", + "title": "Jalur manual dongle USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Pilih Crownstone Sphere tempat USB berada.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Pilih Crownstone Sphere tempat USB berada.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/it.json b/homeassistant/components/crownstone/translations/it.json new file mode 100644 index 0000000000000..062f77c93493c --- /dev/null +++ b/homeassistant/components/crownstone/translations/it.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "usb_setup_complete": "Configurazione USB Crownstone completata.", + "usb_setup_unsuccessful": "La configurazione USB di Crownstone non ha avuto successo." + }, + "error": { + "account_not_verified": "Account non verificato. Attiva il tuo account tramite l'email di attivazione di Crownstone.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immetti manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera di Crownstone dove si trova l'USB.", + "title": "Sfera USB Crownstone" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Account Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Sfera di Crownstone dove si trova l'USB", + "use_usb_option": "Utilizza una chiavetta USB Crownstone per la trasmissione locale dei dati" + } + }, + "usb_config": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immetti manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera di Crownstone dove si trova l'USB.", + "title": "Sfera USB Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera Crownstone in cui si trova l'USB.", + "title": "Sfera USB Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ja.json b/homeassistant/components/crownstone/translations/ja.json new file mode 100644 index 0000000000000..6ab8f858af47c --- /dev/null +++ b/homeassistant/components/crownstone/translations/ja.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "usb_setup_complete": "Crownstone USB\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u304c\u5b8c\u4e86\u3057\u307e\u3057\u305f\u3002", + "usb_setup_unsuccessful": "Crownstone USB\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "account_not_verified": "\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002Crownstone\u304b\u3089\u306e\u30a2\u30af\u30c6\u30a3\u30d9\u30fc\u30b7\u30e7\u30f3\u30e1\u30fc\u30eb\u3092\u901a\u3057\u3066\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30a2\u30af\u30c6\u30a3\u30d9\u30fc\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3092\u9078\u629e\u3059\u308b\u304b\u3001USB\u30c9\u30f3\u30b0\u30eb\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u306a\u3044\u5834\u5408\u306f\u3001\"USB\u3092\u4f7f\u7528\u3057\u306a\u3044\" \u3092\u9078\u629e\u3057\u307e\u3059\u3002 \n\n VID 10C4 \u3067 PID EA60 \u306a\u30c7\u30d0\u30a4\u30b9\u3092\u898b\u3064\u3051\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u624b\u52d5\u3067\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3078\u306e\u624b\u52d5\u30d1\u30b9" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Crownstone\u30a2\u30ab\u30a6\u30f3\u30c8" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere", + "use_usb_option": "\u30ed\u30fc\u30ab\u30eb\u30c7\u30fc\u30bf\u306e\u9001\u4fe1\u306b\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3092\u4f7f\u7528\u3059\u308b" + } + }, + "usb_config": { + "data": { + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30b7\u30ea\u30a2\u30eb \u30dd\u30fc\u30c8\u3092\u9078\u629e\u3057\u307e\u3059\u3002\n\nVID 10C4 \u3067 PID EA60 \u306a\u5024\u306e\u30c7\u30d0\u30a4\u30b9\u3092\u63a2\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u8a2d\u5b9a" + }, + "usb_config_option": { + "data": { + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30b7\u30ea\u30a2\u30eb \u30dd\u30fc\u30c8\u3092\u9078\u629e\u3057\u307e\u3059\u3002\n\nVID 10C4 \u3067 PID EA60 \u306a\u5024\u306e\u30c7\u30d0\u30a4\u30b9\u3092\u63a2\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u624b\u52d5\u3067\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3078\u306e\u624b\u52d5\u30d1\u30b9" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u624b\u52d5\u3067\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3078\u306e\u624b\u52d5\u30d1\u30b9" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ko.json b/homeassistant/components/crownstone/translations/ko.json new file mode 100644 index 0000000000000..aadd2d3da42de --- /dev/null +++ b/homeassistant/components/crownstone/translations/ko.json @@ -0,0 +1,16 @@ +{ + "options": { + "step": { + "usb_config_option": { + "data": { + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/nl.json b/homeassistant/components/crownstone/translations/nl.json new file mode 100644 index 0000000000000..1da12c8f84175 --- /dev/null +++ b/homeassistant/components/crownstone/translations/nl.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "usb_setup_complete": "Crownstone USB installatie voltooid.", + "usb_setup_unsuccessful": "Crownstone USB installatie is mislukt." + }, + "error": { + "account_not_verified": "Account niet geverifieerd. Gelieve uw account te activeren via de activeringsmail van Crownstone.", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle, of selecteer 'Don't use USB' als u geen USB dongle wilt instellen.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "title": "Crownstone account" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere waar de USB zich bevindt", + "use_usb_option": "Gebruik een Crownstone USB-dongle voor lokale gegevensoverdracht" + } + }, + "usb_config": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_config_option": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/no.json b/homeassistant/components/crownstone/translations/no.json new file mode 100644 index 0000000000000..88f3578a9a4c8 --- /dev/null +++ b/homeassistant/components/crownstone/translations/no.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "usb_setup_complete": "Crownstone USB -oppsett fullf\u00f8rt.", + "usb_setup_unsuccessful": "Crownstone USB -oppsett mislyktes." + }, + "error": { + "account_not_verified": "Kontoen er ikke bekreftet. Vennligst aktiver kontoen din via aktiverings -e -posten fra Crownstone.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg den serielle porten p\u00e5 Crownstone USB -dongelen, eller velg 'Ikke bruk USB' hvis du ikke vil konfigurere en USB -dongle. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone USB Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "title": "Crownstone -konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Velg en Crownstone Sphere der USB -en er plassert.", + "use_usb_option": "Bruk en Crownstone USB -dongle for lokal dataoverf\u00f8ring" + } + }, + "usb_config": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg serieporten til Crownstone USB -dongelen. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_config_option": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg serieporten til Crownstone USB -dongelen. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone USB Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/pl.json b/homeassistant/components/crownstone/translations/pl.json new file mode 100644 index 0000000000000..12ac55d668ca4 --- /dev/null +++ b/homeassistant/components/crownstone/translations/pl.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "usb_setup_complete": "Konfiguracja USB Crownstone zako\u0144czona.", + "usb_setup_unsuccessful": "Konfiguracja USB Crownstone nie powiod\u0142a si\u0119." + }, + "error": { + "account_not_verified": "Konto niezweryfikowane. Aktywuj swoje konto za pomoc\u0105 e-maila aktywacyjnego od Crownstone.", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Wybierz port szeregowy urz\u0105dzenia USB Crownstone lub wybierz opcj\u0119 \"Nie u\u017cywaj USB\", je\u015bli nie chcesz konfigurowa\u0107 urz\u0105dzenia USB. \n\nPoszukaj urz\u0105dzenia z VID 10C4 i PID EA60.", + "title": "Konfiguracja urz\u0105dzenia USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Wprowad\u017a r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone.", + "title": "Wpisz r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Wybierz USB w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "title": "USB Crownstone Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Has\u0142o" + }, + "title": "Konto Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "USB w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "use_usb_option": "U\u017cyj urz\u0105dzenia USB Crownstone do lokalnej transmisji danych" + } + }, + "usb_config": { + "data": { + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Wybierz port szeregowy urz\u0105dzenia USB Crownstone. \n\nPoszukaj urz\u0105dzenia z VID 10C4 i PID EA60.", + "title": "Konfiguracja urz\u0105dzenia USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Wybierz port szeregowy urz\u0105dzenia USB Crownstone. \n\nPoszukaj urz\u0105dzenia z VID 10C4 i PID EA60.", + "title": "Konfiguracja urz\u0105dzenia USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Wprowad\u017a r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone.", + "title": "Wpisz r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Wprowad\u017a r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone.", + "title": "Wpisz r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Wybierz USB, w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "title": "USB Crownstone Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Wybierz USB w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "title": "USB Crownstone Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ru.json b/homeassistant/components/crownstone/translations/ru.json new file mode 100644 index 0000000000000..7dfd88bd63e5e --- /dev/null +++ b/homeassistant/components/crownstone/translations/ru.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "usb_setup_complete": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Crownstone USB \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430.", + "usb_setup_unsuccessful": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Crownstone USB \u043d\u0435 \u0443\u0434\u0430\u043b\u0430\u0441\u044c." + }, + "error": { + "account_not_verified": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u0430. \u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0439\u0442\u0435 \u0435\u0451 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0438\u0441\u044c\u043c\u0430, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043f\u043e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u0435.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "usb_config": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone \u0438\u043b\u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 'Don't use USB', \u0435\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c \u0435\u0433\u043e. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "title": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "use_usb_option": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Crownstone \u0434\u043b\u044f \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0434\u0430\u043d\u043d\u044b\u0445" + } + }, + "usb_config": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/tr.json b/homeassistant/components/crownstone/translations/tr.json new file mode 100644 index 0000000000000..1c97cbdf170df --- /dev/null +++ b/homeassistant/components/crownstone/translations/tr.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "usb_setup_complete": "Crownstone USB kurulumu tamamland\u0131.", + "usb_setup_unsuccessful": "Crownstone USB kurulumu ba\u015far\u0131s\u0131z oldu." + }, + "error": { + "account_not_verified": "Hesap do\u011frulanmad\u0131. L\u00fctfen hesab\u0131n\u0131z\u0131 Crownstone'dan gelen aktivasyon e-postas\u0131 arac\u0131l\u0131\u011f\u0131yla etkinle\u015ftirin.", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB donan\u0131m kilidinin seri ba\u011flant\u0131 noktas\u0131n\u0131 se\u00e7in veya bir USB donan\u0131m kilidi kurmak istemiyorsan\u0131z 'USB kullanma' se\u00e7ene\u011fini se\u00e7in. \n\n VID 10C4 ve PID EA60'a sahip bir cihaz aray\u0131n.", + "title": "Crownstone USB dongle yap\u0131land\u0131rmas\u0131" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB dongle'\u0131n yolunu manuel olarak girin.", + "title": "Crownstone USB dongle manuel yolu" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Stick" + }, + "description": "USB'nin bulundu\u011fu bir Crownstone Stick se\u00e7in.", + "title": "Crownstone USB Stick" + }, + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + }, + "title": "Crownstone hesab\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "USB'nin bulundu\u011fu Crownstone Stick", + "use_usb_option": "Yerel veri iletimi i\u00e7in Crownstone USB dongle kullan\u0131n" + } + }, + "usb_config": { + "data": { + "usb_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB donan\u0131m kilidinin seri ba\u011flant\u0131 noktas\u0131n\u0131 se\u00e7in. \n\n VID 10C4 ve PID EA60'a sahip bir cihaz aray\u0131n.", + "title": "Crownstone USB dongle yap\u0131land\u0131rmas\u0131" + }, + "usb_config_option": { + "data": { + "usb_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB donan\u0131m kilidinin seri ba\u011flant\u0131 noktas\u0131n\u0131 se\u00e7in. \n\n VID 10C4 ve PID EA60'a sahip bir cihaz aray\u0131n.", + "title": "Crownstone USB dongle yap\u0131land\u0131rmas\u0131" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB dongle'\u0131n yolunu manuel olarak girin.", + "title": "Crownstone USB dongle manuel yolu" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB dongle'\u0131n yolunu manuel olarak girin.", + "title": "Crownstone USB dongle manuel yolu" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Stick" + }, + "description": "USB'nin bulundu\u011fu bir Crownstone Stick se\u00e7in.", + "title": "Crownstone USB Stick" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Stick" + }, + "description": "USB'nin bulundu\u011fu bir Crownstone Stick se\u00e7in.", + "title": "Crownstone USB Stick" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/zh-Hant.json b/homeassistant/components/crownstone/translations/zh-Hant.json new file mode 100644 index 0000000000000..2c362ba0bcb5a --- /dev/null +++ b/homeassistant/components/crownstone/translations/zh-Hant.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "usb_setup_complete": "Crownstone USB \u8a2d\u5b9a\u5b8c\u6210\u3002", + "usb_setup_unsuccessful": "Crownstone USB \u8a2d\u5b9a\u6210\u529f\u3002" + }, + "error": { + "account_not_verified": "\u5e33\u865f\u5c1a\u672a\u9a57\u8b49\u3001\u8acb\u900f\u904e\u4f86\u81ea Crownstone \u7684\u9a57\u8b49\u90f5\u4ef6\u555f\u52d5\u60a8\u7684\u5e33\u865f\u3002", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\uff0c\u6216\u5047\u5982\u60a8\u4e0d\u60f3\u8a2d\u5b9a USB \u88dd\u7f6e\u7684\u8a71\u3001\u8acb\u9078\u64c7 '\u4e0d\u4f7f\u7528 USB'\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "title": "Crownstone \u5e33\u865f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere \u6240\u5728 USB \u8def\u5f91", + "use_usb_option": "\u4f7f\u7528 Crownstone USB \u88dd\u7f6e\u9032\u884c\u672c\u5730\u7aef\u8cc7\u6599\u50b3\u8f38" + } + }, + "usb_config": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_config_option": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 6a3fc7b4215fc..74d3d9a36a295 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -111,7 +111,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._printer is None: return None @@ -183,7 +183,7 @@ def available(self): return self._available @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._attributes is None: return None @@ -257,7 +257,7 @@ def icon(self): return ICON_MARKER @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._attributes is None: return None @@ -265,7 +265,7 @@ def state(self): return self._attributes[self._printer]["marker-levels"][self._index] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index f42534f509b6c..2f0461cc8c43f 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -65,7 +65,7 @@ def __init__(self, rest, base, quote): self._state = None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._quote @@ -80,7 +80,7 @@ def icon(self): return ICON @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -92,8 +92,7 @@ def extra_state_attributes(self): def update(self): """Update current date.""" self.rest.update() - value = self.rest.data - if value is not None: + if (value := self.rest.data) is not None: self._state = round(value[f"{self._base}{self._quote}"], 4) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 9d0d189248fe8..95189774a8173 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -8,11 +8,12 @@ from pydaikin.daikin_base import Appliance from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import Throttle from .const import CONF_UUID, DOMAIN, KEY_MAC, TIMEOUT @@ -22,12 +23,12 @@ PARALLEL_UPDATES = 0 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORMS = ["climate", "sensor", "switch"] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with Daikin.""" conf = entry.data # For backwards compat, set unique ID @@ -64,7 +65,7 @@ async def daikin_api_setup(hass, host, key, uuid, password): session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with timeout(TIMEOUT): + async with timeout(TIMEOUT): device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) @@ -86,7 +87,7 @@ async def daikin_api_setup(hass, host, key, uuid, password): class DaikinApi: """Keep the Daikin instance in one place and centralize the update.""" - def __init__(self, device: Appliance): + def __init__(self, device: Appliance) -> None: """Initialize the Daikin Handle.""" self.device = device self.name = device.values.get("name", "Daikin AC") @@ -109,13 +110,13 @@ def available(self) -> bool: return self._available @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" info = self.device.values - return { - "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, - "manufacturer": "Daikin", - "model": info.get("model"), - "name": info.get("name"), - "sw_version": info.get("ver", "").replace("_", "."), - } + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, + manufacturer="Daikin", + model=info.get("model"), + name=info.get("name"), + sw_version=info.get("ver", "").replace("_", "."), + ) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 7a60cb4c3b22f..c8e962b9c76e4 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -9,6 +9,10 @@ ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_SWING_MODE, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, @@ -60,6 +64,12 @@ "off": HVAC_MODE_OFF, } +HA_STATE_TO_CURRENT_HVAC = { + HVAC_MODE_COOL: CURRENT_HVAC_COOL, + HVAC_MODE_HEAT: CURRENT_HVAC_HEAT, + HVAC_MODE_OFF: CURRENT_HVAC_OFF, +} + HA_PRESET_TO_DAIKIN = { PRESET_AWAY: "on", PRESET_NONE: "off", @@ -125,13 +135,11 @@ async def _set(self, settings): """Set device settings using API.""" values = {} - for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE]: - value = settings.get(attr) - if value is None: + for attr in (ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE): + if (value := settings.get(attr)) is None: continue - daikin_attr = HA_ATTR_TO_DAIKIN.get(attr) - if daikin_attr is not None: + if (daikin_attr := HA_ATTR_TO_DAIKIN.get(attr)) is not None: if attr == ATTR_HVAC_MODE: values[daikin_attr] = HA_STATE_TO_DAIKIN[value] elif value in self._list[attr]: @@ -188,6 +196,18 @@ async def async_set_temperature(self, **kwargs): """Set new target temperature.""" await self._set(kwargs) + @property + def hvac_action(self): + """Return the current state.""" + ret = HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) + if ( + ret in (CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT) + and self._api.device.support_compressor_frequency + and self._api.device.compressor_frequency == 0 + ): + return CURRENT_HVAC_IDLE + return ret + @property def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index ea0709e55572e..0084a89172f57 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -5,12 +5,14 @@ from aiohttp import ClientError, web_exceptions from async_timeout import timeout -from pydaikin.daikin_base import Appliance +from pydaikin.daikin_base import Appliance, DaikinException from pydaikin.discovery import Discovery import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult from .const import CONF_UUID, DOMAIN, KEY_MAC, TIMEOUT @@ -67,7 +69,7 @@ async def _create_device(self, host, key=None, password=None): password = None try: - with timeout(TIMEOUT): + async with timeout(TIMEOUT): device = await Appliance.factory( host, self.hass.helpers.aiohttp_client.async_get_clientsession(), @@ -75,7 +77,8 @@ async def _create_device(self, host, key=None, password=None): uuid=uuid, password=password, ) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, ClientError): + self.host = None return self.async_show_form( step_id="user", data_schema=self.schema, @@ -87,8 +90,8 @@ async def _create_device(self, host, key=None, password=None): data_schema=self.schema, errors={"base": "invalid_auth"}, ) - except ClientError: - _LOGGER.exception("ClientError") + except DaikinException as daikin_exp: + _LOGGER.error(daikin_exp) return self.async_show_form( step_id="user", data_schema=self.schema, @@ -109,24 +112,33 @@ async def async_step_user(self, user_input=None): """User initiated config flow.""" if user_input is None: return self.async_show_form(step_id="user", data_schema=self.schema) + if user_input.get(CONF_API_KEY) and user_input.get(CONF_PASSWORD): + self.host = user_input.get(CONF_HOST) + return self.async_show_form( + step_id="user", + data_schema=self.schema, + errors={"base": "api_password"}, + ) return await self._create_device( user_input[CONF_HOST], user_input.get(CONF_API_KEY), user_input.get(CONF_PASSWORD), ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered Daikin device.""" _LOGGER.debug("Zeroconf user_input: %s", discovery_info) - devices = Discovery().poll(ip=discovery_info[CONF_HOST]) + devices = Discovery().poll(ip=discovery_info.host) if not devices: _LOGGER.debug( "Could not find MAC-address for %s," " make sure the required UDP ports are open (see integration documentation)", - discovery_info[CONF_HOST], + discovery_info.host, ) return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(next(iter(devices))[KEY_MAC]) self._abort_if_unique_id_configured() - self.host = discovery_info[CONF_HOST] + self.host = discovery_info.host return await self.async_step_user() diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index 5b4bdd28331b2..e0222d308ea56 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -1,19 +1,4 @@ """Constants for Daikin.""" -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_TYPE, - CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - POWER_KILO_WATT, - TEMP_CELSIUS, -) - DOMAIN = "daikin" ATTR_TARGET_TEMPERATURE = "target_temperature" @@ -24,60 +9,11 @@ ATTR_HEAT_ENERGY = "heat_energy" ATTR_HUMIDITY = "humidity" ATTR_TARGET_HUMIDITY = "target_humidity" +ATTR_COMPRESSOR_FREQUENCY = "compressor_frequency" ATTR_STATE_ON = "on" ATTR_STATE_OFF = "off" -SENSOR_TYPE_TEMPERATURE = "temperature" -SENSOR_TYPE_HUMIDITY = "humidity" -SENSOR_TYPE_POWER = "power" -SENSOR_TYPE_ENERGY = "energy" - -SENSOR_TYPES = { - ATTR_INSIDE_TEMPERATURE: { - CONF_NAME: "Inside Temperature", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - }, - ATTR_OUTSIDE_TEMPERATURE: { - CONF_NAME: "Outside Temperature", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - }, - ATTR_HUMIDITY: { - CONF_NAME: "Humidity", - CONF_TYPE: SENSOR_TYPE_HUMIDITY, - CONF_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - ATTR_TARGET_HUMIDITY: { - CONF_NAME: "Target Humidity", - CONF_TYPE: SENSOR_TYPE_HUMIDITY, - CONF_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - ATTR_TOTAL_POWER: { - CONF_NAME: "Total Power Consumption", - CONF_TYPE: SENSOR_TYPE_POWER, - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, - }, - ATTR_COOL_ENERGY: { - CONF_NAME: "Cool Energy Consumption", - CONF_TYPE: SENSOR_TYPE_ENERGY, - CONF_ICON: "mdi:snowflake", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, - ATTR_HEAT_ENERGY: { - CONF_NAME: "Heat Energy Consumption", - CONF_TYPE: SENSOR_TYPE_ENERGY, - CONF_ICON: "mdi:fire", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, -} - CONF_UUID = "uuid" KEY_MAC = "mac" diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 2db81e8f167d7..2a1619594ba17 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -3,7 +3,7 @@ "name": "Daikin AC", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/daikin", - "requirements": ["pydaikin==2.4.1"], + "requirements": ["pydaikin==2.6.0"], "codeowners": ["@fredrike"], "zeroconf": ["_dkapi._tcp.local."], "quality_scale": "platinum", diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index a5b515ea91894..cf91d65b4a6e7 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -1,15 +1,28 @@ """Support for Daikin AC sensors.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pydaikin.daikin_base import Appliance + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_TYPE, - CONF_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_KILO_WATT, + TEMP_CELSIUS, ) from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi from .const import ( + ATTR_COMPRESSOR_FREQUENCY, ATTR_COOL_ENERGY, ATTR_HEAT_ENERGY, ATTR_HUMIDITY, @@ -17,11 +30,84 @@ ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_HUMIDITY, ATTR_TOTAL_POWER, - SENSOR_TYPE_ENERGY, - SENSOR_TYPE_HUMIDITY, - SENSOR_TYPE_POWER, - SENSOR_TYPE_TEMPERATURE, - SENSOR_TYPES, +) + + +@dataclass +class DaikinRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[Appliance], float | None] + + +@dataclass +class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysMixin): + """Describes Daikin sensor entity.""" + + +SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( + DaikinSensorEntityDescription( + key=ATTR_INSIDE_TEMPERATURE, + name="Inside Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value_func=lambda device: device.inside_temperature, + ), + DaikinSensorEntityDescription( + key=ATTR_OUTSIDE_TEMPERATURE, + name="Outside Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value_func=lambda device: device.outside_temperature, + ), + DaikinSensorEntityDescription( + key=ATTR_HUMIDITY, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_func=lambda device: device.humidity, + ), + DaikinSensorEntityDescription( + key=ATTR_TARGET_HUMIDITY, + name="Target Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_func=lambda device: device.humidity, + ), + DaikinSensorEntityDescription( + key=ATTR_TOTAL_POWER, + name="Total Power Consumption", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + value_func=lambda device: round(device.current_total_power_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_COOL_ENERGY, + name="Cool Energy Consumption", + icon="mdi:snowflake", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_func=lambda device: round(device.last_hour_cool_energy_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_HEAT_ENERGY, + name="Heat Energy Consumption", + icon="mdi:fire", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_func=lambda device: round(device.last_hour_heat_energy_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_COMPRESSOR_FREQUENCY, + name="Compressor Frequency", + icon="mdi:fan", + native_unit_of_measurement=FREQUENCY_HERTZ, + value_func=lambda device: device.compressor_frequency, + ), ) @@ -46,59 +132,39 @@ async def async_setup_entry(hass, entry, async_add_entities): if daikin_api.device.support_humidity: sensors.append(ATTR_HUMIDITY) sensors.append(ATTR_TARGET_HUMIDITY) - async_add_entities([DaikinSensor.factory(daikin_api, sensor) for sensor in sensors]) + if daikin_api.device.support_compressor_frequency: + sensors.append(ATTR_COMPRESSOR_FREQUENCY) + + entities = [ + DaikinSensor(daikin_api, description) + for description in SENSOR_TYPES + if description.key in sensors + ] + async_add_entities(entities) class DaikinSensor(SensorEntity): """Representation of a Sensor.""" - @staticmethod - def factory(api: DaikinApi, monitored_state: str): - """Initialize any DaikinSensor.""" - cls = { - SENSOR_TYPE_TEMPERATURE: DaikinClimateSensor, - SENSOR_TYPE_HUMIDITY: DaikinClimateSensor, - SENSOR_TYPE_POWER: DaikinPowerSensor, - SENSOR_TYPE_ENERGY: DaikinPowerSensor, - }[SENSOR_TYPES[monitored_state][CONF_TYPE]] - return cls(api, monitored_state) - - def __init__(self, api: DaikinApi, monitored_state: str) -> None: + entity_description: DaikinSensorEntityDescription + + def __init__( + self, api: DaikinApi, description: DaikinSensorEntityDescription + ) -> None: """Initialize the sensor.""" + self.entity_description = description self._api = api - self._sensor = SENSOR_TYPES[monitored_state] - self._name = f"{api.name} {self._sensor[CONF_NAME]}" - self._device_attribute = monitored_state + self._attr_name = f"{api.name} {description.name}" @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.device.mac}-{self._device_attribute}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + return f"{self._api.device.mac}-{self.entity_description.key}" @property - def state(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" - raise NotImplementedError - - @property - def device_class(self): - """Return the class of this device.""" - return self._sensor.get(CONF_DEVICE_CLASS) - - @property - def icon(self): - """Return the icon of this device.""" - return self._sensor.get(CONF_ICON) - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._sensor[CONF_UNIT_OF_MEASUREMENT] + return self.entity_description.value_func(self._api.device) async def async_update(self): """Retrieve latest state.""" @@ -108,36 +174,3 @@ async def async_update(self): def device_info(self): """Return a device description for device registry.""" return self._api.device_info - - -class DaikinClimateSensor(DaikinSensor): - """Representation of a Climate Sensor.""" - - @property - def state(self): - """Return the internal state of the sensor.""" - if self._device_attribute == ATTR_INSIDE_TEMPERATURE: - return self._api.device.inside_temperature - if self._device_attribute == ATTR_OUTSIDE_TEMPERATURE: - return self._api.device.outside_temperature - - if self._device_attribute == ATTR_HUMIDITY: - return self._api.device.humidity - if self._device_attribute == ATTR_TARGET_HUMIDITY: - return self._api.device.target_humidity - return None - - -class DaikinPowerSensor(DaikinSensor): - """Representation of a power/energy consumption sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - if self._device_attribute == ATTR_TOTAL_POWER: - return round(self._api.device.current_total_power_consumption, 3) - if self._device_attribute == ATTR_COOL_ENERGY: - return round(self._api.device.last_hour_cool_energy_consumption, 3) - if self._device_attribute == ATTR_HEAT_ENERGY: - return round(self._api.device.last_hour_heat_energy_consumption, 3) - return None diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index fc2b6e79a5ece..5c75938479511 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -18,6 +18,7 @@ "error": { "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "api_password": "[%key:common::config_flow::error::invalid_auth%], use either API Key or Password.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 5e0e1b5761a0d..647ee0689e6fc 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -22,8 +22,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up Daikin climate based on config_entry.""" daikin_api = hass.data[DAIKIN_DOMAIN][entry.entry_id] switches = [] - zones = daikin_api.device.zones - if zones: + if zones := daikin_api.device.zones: switches.extend( [ DaikinZoneSwitch(daikin_api, zone_id) diff --git a/homeassistant/components/daikin/translations/bg.json b/homeassistant/components/daikin/translations/bg.json index a1f8209b2bff4..c0796234f9aae 100644 --- a/homeassistant/components/daikin/translations/bg.json +++ b/homeassistant/components/daikin/translations/bg.json @@ -1,12 +1,18 @@ { "config": { "abort": { - "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "error": { + "api_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 API \u043a\u043b\u044e\u0447 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430." }, "step": { "user": { "data": { - "host": "\u0410\u0434\u0440\u0435\u0441" + "api_key": "API \u043a\u043b\u044e\u0447", + "host": "\u0410\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" }, "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 IP \u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin.", "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin" diff --git a/homeassistant/components/daikin/translations/ca.json b/homeassistant/components/daikin/translations/ca.json index 0b8c2aae7eb8a..a895697f490df 100644 --- a/homeassistant/components/daikin/translations/ca.json +++ b/homeassistant/components/daikin/translations/ca.json @@ -5,6 +5,7 @@ "cannot_connect": "Ha fallat la connexi\u00f3" }, "error": { + "api_password": "Autenticaci\u00f3 inv\u00e0lida, utilitza la clau API o la contrasenya.", "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "unknown": "Error inesperat" diff --git a/homeassistant/components/daikin/translations/de.json b/homeassistant/components/daikin/translations/de.json index dcec53c15690b..3310d96da6085 100644 --- a/homeassistant/components/daikin/translations/de.json +++ b/homeassistant/components/daikin/translations/de.json @@ -5,6 +5,7 @@ "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { + "api_password": "Ung\u00fcltige Authentifizierung, verwende entweder den API-Schl\u00fcssel oder das Passwort.", "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" @@ -16,7 +17,7 @@ "host": "Host", "password": "Passwort" }, - "description": "Gib die IP-Adresse deiner Daikin AC ein.", + "description": "Gib die IP-Adresse deiner Daikin AC ein.\n\nBeachte, dass API-Schl\u00fcssel und Passwort nur von BRP072Cxx bzw. SKYFi-Ger\u00e4ten verwendet werden.", "title": "Daikin AC konfigurieren" } } diff --git a/homeassistant/components/daikin/translations/en.json b/homeassistant/components/daikin/translations/en.json index d1db170d76913..84843ba82116d 100644 --- a/homeassistant/components/daikin/translations/en.json +++ b/homeassistant/components/daikin/translations/en.json @@ -5,6 +5,7 @@ "cannot_connect": "Failed to connect" }, "error": { + "api_password": "Invalid authentication, use either API Key or Password.", "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" diff --git a/homeassistant/components/daikin/translations/et.json b/homeassistant/components/daikin/translations/et.json index 0f303d2014ebf..9fa1ea3b293b4 100644 --- a/homeassistant/components/daikin/translations/et.json +++ b/homeassistant/components/daikin/translations/et.json @@ -5,6 +5,7 @@ "cannot_connect": "\u00dchendamine nurjus" }, "error": { + "api_password": "Vigane autentimine , kasuta kas API v\u00f5tit v\u00f5i salas\u00f5na.", "cannot_connect": "\u00dchendamine nurjus", "invalid_auth": "Tuvastamise viga", "unknown": "Tundmatu viga" diff --git a/homeassistant/components/daikin/translations/fr.json b/homeassistant/components/daikin/translations/fr.json index ab193dc20aff3..3b2dcd8ce27c3 100644 --- a/homeassistant/components/daikin/translations/fr.json +++ b/homeassistant/components/daikin/translations/fr.json @@ -5,6 +5,7 @@ "cannot_connect": "\u00c9chec de connexion" }, "error": { + "api_password": "Authentification invalide, utilisez la cl\u00e9 API ou le mot de passe.", "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" @@ -13,8 +14,8 @@ "user": { "data": { "api_key": "Cl\u00e9 d'API", - "host": "Nom d'h\u00f4te ou adresse IP", - "password": "Mot de passe de l'appareil (utilis\u00e9 uniquement par les appareils SKYFi)" + "host": "H\u00f4te", + "password": "Mot de passe" }, "description": "Saisissez l'adresse IP de votre Daikin AC. \n\n Notez que Cl\u00e9 d'API et Mot de passe sont utilis\u00e9s respectivement par les p\u00e9riph\u00e9riques BRP072Cxx et SKYFi.", "title": "Configurer Daikin AC" diff --git a/homeassistant/components/daikin/translations/he.json b/homeassistant/components/daikin/translations/he.json index 3007c0e968c1d..0bc64f684fd2f 100644 --- a/homeassistant/components/daikin/translations/he.json +++ b/homeassistant/components/daikin/translations/he.json @@ -1,8 +1,19 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, "step": { "user": { "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "host": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" } } diff --git a/homeassistant/components/daikin/translations/hu.json b/homeassistant/components/daikin/translations/hu.json index f1cb7eab8f68c..f5f774e152796 100644 --- a/homeassistant/components/daikin/translations/hu.json +++ b/homeassistant/components/daikin/translations/hu.json @@ -5,6 +5,7 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "error": { + "api_password": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s, haszn\u00e1ljon API-kulcsot vagy jelsz\u00f3t.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" @@ -13,10 +14,10 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3" }, - "description": "Add meg a Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 IP-c\u00edm\u00e9t.", + "description": "Adja meg Daikin k\u00e9sz\u00fcl\u00e9k\u00e9nek az IP c\u00edm\u00e9t.\n\nNe feledje, hogy z API kulcs \u00e9s a Jelsz\u00f3 funkci\u00f3t csak a BRP072Cxx \u00e9s a SKYFi eszk\u00f6z\u00f6k haszn\u00e1lj\u00e1k.", "title": "A Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 konfigur\u00e1l\u00e1sa" } } diff --git a/homeassistant/components/daikin/translations/id.json b/homeassistant/components/daikin/translations/id.json index 8b7cfb5460eb1..8a35b8e113c67 100644 --- a/homeassistant/components/daikin/translations/id.json +++ b/homeassistant/components/daikin/translations/id.json @@ -5,6 +5,7 @@ "cannot_connect": "Gagal terhubung" }, "error": { + "api_password": "Autentikasi tidak valid, gunakan Kunci API atau Kata Sandi.", "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" diff --git a/homeassistant/components/daikin/translations/is.json b/homeassistant/components/daikin/translations/is.json new file mode 100644 index 0000000000000..c0d8b4164dad1 --- /dev/null +++ b/homeassistant/components/daikin/translations/is.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "api_password": "\u00d3gild au\u00f0kenning, nota\u00f0u anna\u00f0hvort API lykil e\u00f0a lykilor\u00f0." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/translations/it.json b/homeassistant/components/daikin/translations/it.json index c85f4961e57c1..8dfa0380d81ca 100644 --- a/homeassistant/components/daikin/translations/it.json +++ b/homeassistant/components/daikin/translations/it.json @@ -5,6 +5,7 @@ "cannot_connect": "Impossibile connettersi" }, "error": { + "api_password": "Autenticazione non valida, utilizza la chiave API o la password.", "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida", "unknown": "Errore imprevisto" @@ -16,7 +17,7 @@ "host": "Host", "password": "Password" }, - "description": "Inserire l'Indirizzo IP del tuo condizionatore d'aria Daikin.\n\nNotare che solo la Chiave API e la Password sono rispettivamente usati dai dispositivi BRP072Cxx e SKYFi.", + "description": "Inserisci l'indirizzo IP del tuo condizionatore d'aria Daikin.\n\nNota che solo la chiave API e la password sono rispettivamente usati dai dispositivi BRP072Cxx e SKYFi.", "title": "Configura Daikin AC" } } diff --git a/homeassistant/components/daikin/translations/ja.json b/homeassistant/components/daikin/translations/ja.json new file mode 100644 index 0000000000000..6b210a056c982 --- /dev/null +++ b/homeassistant/components/daikin/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "api_password": "\u7121\u52b9\u306a\u8a8d\u8a3c\u3002API\u30ad\u30fc\u307e\u305f\u306f\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u3044\u305a\u308c\u304b\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30c0\u30a4\u30ad\u30f3\u88fd\u30a8\u30a2\u30b3\u30f3\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u306a\u304a\u3001API\u30ad\u30fc\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u3001\u305d\u308c\u305e\u308cBRP072Cxx\u3068SKYFi\u30c7\u30d0\u30a4\u30b9\u3067\u306e\u307f\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002", + "title": "\u30c0\u30a4\u30ad\u30f3\u88fd\u30a8\u30a2\u30b3\u30f3\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/translations/nl.json b/homeassistant/components/daikin/translations/nl.json index 706a81b5f7fa1..33659797e7af9 100644 --- a/homeassistant/components/daikin/translations/nl.json +++ b/homeassistant/components/daikin/translations/nl.json @@ -5,6 +5,7 @@ "cannot_connect": "Kon niet verbinden" }, "error": { + "api_password": "Ongeldige authenticatie, gebruik API-sleutel of wachtwoord.", "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" diff --git a/homeassistant/components/daikin/translations/no.json b/homeassistant/components/daikin/translations/no.json index e63b8eeef0d7b..45914b515783c 100644 --- a/homeassistant/components/daikin/translations/no.json +++ b/homeassistant/components/daikin/translations/no.json @@ -5,6 +5,7 @@ "cannot_connect": "Tilkobling mislyktes" }, "error": { + "api_password": "Ugyldig godkjenning, bruk enten API -n\u00f8kkel eller passord.", "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning", "unknown": "Uventet feil" diff --git a/homeassistant/components/daikin/translations/pl.json b/homeassistant/components/daikin/translations/pl.json index d11ffd4dd3a6c..c376bb0eb0eaa 100644 --- a/homeassistant/components/daikin/translations/pl.json +++ b/homeassistant/components/daikin/translations/pl.json @@ -5,6 +5,7 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, "error": { + "api_password": "Niepoprawne uwierzytelnienie, u\u017cyj klucza API albo has\u0142a.", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_auth": "Niepoprawne uwierzytelnienie", "unknown": "Nieoczekiwany b\u0142\u0105d" diff --git a/homeassistant/components/daikin/translations/ru.json b/homeassistant/components/daikin/translations/ru.json index 7365bb0e7bb37..45734a361facc 100644 --- a/homeassistant/components/daikin/translations/ru.json +++ b/homeassistant/components/daikin/translations/ru.json @@ -5,6 +5,7 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, "error": { + "api_password": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043a\u043b\u044e\u0447 API \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." diff --git a/homeassistant/components/daikin/translations/sl.json b/homeassistant/components/daikin/translations/sl.json index a9f8514146fdd..40c54ed020645 100644 --- a/homeassistant/components/daikin/translations/sl.json +++ b/homeassistant/components/daikin/translations/sl.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Naprava je \u017ee konfigurirana" }, + "error": { + "api_password": "Neveljavna avtentikacija, uporabite API klju\u010d ali geslo." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/daikin/translations/tr.json b/homeassistant/components/daikin/translations/tr.json index 4148bf2b9f1c5..a5e0f0ab9f239 100644 --- a/homeassistant/components/daikin/translations/tr.json +++ b/homeassistant/components/daikin/translations/tr.json @@ -5,6 +5,7 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "error": { + "api_password": "Ge\u00e7ersiz kimlik do\u011frulama , API Anahtar\u0131 veya Parola kullan\u0131n.", "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" @@ -13,9 +14,11 @@ "user": { "data": { "api_key": "API Anahtar\u0131", - "host": "Ana Bilgisayar", + "host": "Sunucu", "password": "Parola" - } + }, + "description": "IP Adresi de\u011ferini girin. \n\n API Anahtar\u0131 ve Parola \u00f6\u011felerinin s\u0131ras\u0131yla BRP072Cxx ve SKYFi cihazlar\u0131 taraf\u0131ndan kullan\u0131ld\u0131\u011f\u0131n\u0131 unutmay\u0131n.", + "title": "Daikin AC'yi yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/daikin/translations/zh-Hans.json b/homeassistant/components/daikin/translations/zh-Hans.json index 0acb5110fecd6..844b7bd78f63c 100644 --- a/homeassistant/components/daikin/translations/zh-Hans.json +++ b/homeassistant/components/daikin/translations/zh-Hans.json @@ -5,17 +5,20 @@ "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "error": { + "api_password": "\u9a8c\u8bc1\u65e0\u6548\uff0c\u8bf7\u4f7f\u7528\u5176\u5b83\u7684 API \u5bc6\u94a5\u6216\u5bc6\u7801\u91cd\u8bd5", "cannot_connect": "\u8fde\u63a5\u5931\u8d25", - "invalid_auth": "\u9a8c\u8bc1\u7801\u9519\u8bef" + "invalid_auth": "\u9a8c\u8bc1\u9519\u8bef", + "unknown": "\u672a\u77e5\u9519\u8bef" }, "step": { "user": { "data": { - "api_key": "API\u5bc6\u7801", - "host": "\u4e3b\u673a" + "api_key": "API \u5bc6\u94a5", + "host": "\u4e3b\u673a\u5730\u5740", + "password": "\u5bc6\u7801" }, - "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684 IP \u5730\u5740\u3002", - "title": "\u914d\u7f6e Daikin \u7a7a\u8c03" + "description": "\u8f93\u5165\u60a8\u7684\u5927\u91d1\u7a7a\u8c03\u7684 IP \u5730\u5740\u3002\n\n\u6ce8\u610f\uff1aBRP072Cxx \u6216 SKYFi \u578b\u53f7\u8bbe\u5907\u9700\u8981\u63d0\u4f9b API \u5bc6\u94a5\u548c\u5bc6\u7801", + "title": "\u914d\u7f6e\u5927\u91d1\u7a7a\u8c03" } } } diff --git a/homeassistant/components/daikin/translations/zh-Hant.json b/homeassistant/components/daikin/translations/zh-Hant.json index a6d4b4598b12b..9128c03592118 100644 --- a/homeassistant/components/daikin/translations/zh-Hant.json +++ b/homeassistant/components/daikin/translations/zh-Hant.json @@ -5,6 +5,7 @@ "cannot_connect": "\u9023\u7dda\u5931\u6557" }, "error": { + "api_password": "\u9a57\u8b49\u78bc\u7121\u6548\u3001\u8acb\u4f7f\u7528 API \u91d1\u9470\u6216\u5bc6\u78bc\u3002", "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" @@ -12,11 +13,11 @@ "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "host": "\u4e3b\u6a5f\u7aef", "password": "\u5bc6\u78bc" }, - "description": "\u8f38\u5165\u60a8\u7684\u5927\u91d1\u7a7a\u8abfIP \u4f4d\u5740\u3002\n\n\u8acb\u6ce8\u610f\uff1aBRP072Cxx \u8207 SKYFi \u88dd\u7f6e\u4e4b API \u5bc6\u9470\u8207\u5bc6\u78bc\u70ba\u5206\u958b\u4f7f\u7528\u3002", + "description": "\u8f38\u5165\u60a8\u7684\u5927\u91d1\u7a7a\u8abfIP \u4f4d\u5740\u3002\n\n\u8acb\u6ce8\u610f\uff1aBRP072Cxx \u8207 SKYFi \u88dd\u7f6e\u4e4b API \u91d1\u9470\u8207\u5bc6\u78bc\u70ba\u5206\u958b\u4f7f\u7528\u3002", "title": "\u8a2d\u5b9a\u5927\u91d1\u7a7a\u8abf" } } diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py index 9d3123185c4f9..379d76ec4c803 100644 --- a/homeassistant/components/danfoss_air/binary_sensor.py +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -2,7 +2,7 @@ from pydanfossair.commands import ReadCommand from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_OPENING, + BinarySensorDeviceClass, BinarySensorEntity, ) @@ -14,7 +14,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = hass.data[DANFOSS_AIR_DOMAIN] sensors = [ - ["Danfoss Air Bypass Active", ReadCommand.bypass, DEVICE_CLASS_OPENING], + [ + "Danfoss Air Bypass Active", + ReadCommand.bypass, + BinarySensorDeviceClass.OPENING, + ], ["Danfoss Air Away Mode Active", ReadCommand.away_mode, None], ] @@ -32,28 +36,12 @@ class DanfossAirBinarySensor(BinarySensorEntity): def __init__(self, data, name, sensor_type, device_class): """Initialize the Danfoss Air binary sensor.""" self._data = data - self._name = name - self._state = None + self._attr_name = name self._type = sensor_type - self._device_class = device_class - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Type of device class.""" - return self._device_class + self._attr_device_class = device_class def update(self): """Fetch new state data for the sensor.""" self._data.update() - self._state = self._data.get_value(self._type) + self._attr_is_on = self._data.get_value(self._type) diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 792a95e8ac46f..098032478aa4e 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -3,14 +3,12 @@ from pydanfossair.commands import ReadCommand -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_CELSIUS, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, ) +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from . import DOMAIN as DANFOSS_AIR_DOMAIN @@ -26,53 +24,74 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "Danfoss Air Exhaust Temperature", TEMP_CELSIUS, ReadCommand.exhaustTemperature, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, ], [ "Danfoss Air Outdoor Temperature", TEMP_CELSIUS, ReadCommand.outdoorTemperature, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, ], [ "Danfoss Air Supply Temperature", TEMP_CELSIUS, ReadCommand.supplyTemperature, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, ], [ "Danfoss Air Extract Temperature", TEMP_CELSIUS, ReadCommand.extractTemperature, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, ], [ "Danfoss Air Remaining Filter", PERCENTAGE, ReadCommand.filterPercent, None, + None, ], [ "Danfoss Air Humidity", PERCENTAGE, ReadCommand.humidity, - DEVICE_CLASS_HUMIDITY, + SensorDeviceClass.HUMIDITY, + SensorStateClass.MEASUREMENT, + ], + ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None, None], + [ + "Danfoss Air Exhaust Fan Speed", + "RPM", + ReadCommand.exhaust_fan_speed, + None, + None, + ], + [ + "Danfoss Air Supply Fan Speed", + "RPM", + ReadCommand.supply_fan_speed, + None, + None, ], - ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None], - ["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], - ["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], [ "Danfoss Air Dial Battery", PERCENTAGE, ReadCommand.battery_percent, - DEVICE_CLASS_BATTERY, + SensorDeviceClass.BATTERY, + None, ], ] dev = [] for sensor in sensors: - dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3])) + dev.append( + DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3], sensor[4]) + ) add_entities(dev, True) @@ -80,34 +99,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DanfossAir(SensorEntity): """Representation of a Sensor.""" - def __init__(self, data, name, sensor_unit, sensor_type, device_class): + def __init__(self, data, name, sensor_unit, sensor_type, device_class, state_class): """Initialize the sensor.""" self._data = data - self._name = name - self._state = None + self._attr_name = name + self._attr_native_value = None self._type = sensor_type - self._unit = sensor_unit - self._device_class = device_class - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + self._attr_native_unit_of_measurement = sensor_unit + self._attr_device_class = device_class + self._attr_state_class = state_class def update(self): """Update the new state of the sensor. @@ -117,6 +117,6 @@ def update(self): """ self._data.update() - self._state = self._data.get_value(self._type) - if self._state is None: + self._attr_native_value = self._data.get_value(self._type) + if self._attr_native_value is None: _LOGGER.debug("Could not get data for %s", self._type) diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 058969d96f957..d6bcf404e85de 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -1,15 +1,21 @@ """Support for Dark Sky weather service.""" +from __future__ import annotations + +from dataclasses import dataclass, field from datetime import timedelta import logging +from typing import Literal, NamedTuple import forecastio from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout import voluptuous as vol from homeassistant.components.sensor import ( - DEVICE_CLASS_TEMPERATURE, PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -21,8 +27,11 @@ CONF_SCAN_INTERVAL, DEGREE, LENGTH_CENTIMETERS, + LENGTH_INCHES, LENGTH_KILOMETERS, + LENGTH_MILES, PERCENTAGE, + PRECIPITATION_INCHES, PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_MBAR, SPEED_KILOMETERS_PER_HOUR, @@ -55,352 +64,427 @@ "temperature_min", } -# Sensor types are defined like so: -# Name, si unit, us unit, ca unit, uk unit, uk2 unit -SENSOR_TYPES = { - "summary": [ - "Summary", - None, - None, - None, - None, - None, - None, - ["currently", "hourly", "daily"], - ], - "minutely_summary": ["Minutely Summary", None, None, None, None, None, None, []], - "hourly_summary": ["Hourly Summary", None, None, None, None, None, None, []], - "daily_summary": ["Daily Summary", None, None, None, None, None, None, []], - "icon": [ - "Icon", - None, - None, - None, - None, - None, - None, - ["currently", "hourly", "daily"], - ], - "nearest_storm_distance": [ - "Nearest Storm Distance", - LENGTH_KILOMETERS, - "mi", - LENGTH_KILOMETERS, - LENGTH_KILOMETERS, - "mi", - "mdi:weather-lightning", - ["currently"], - ], - "nearest_storm_bearing": [ - "Nearest Storm Bearing", - DEGREE, - DEGREE, - DEGREE, - DEGREE, - DEGREE, - "mdi:weather-lightning", - ["currently"], - ], - "precip_type": [ - "Precip", - None, - None, - None, - None, - None, - "mdi:weather-pouring", - ["currently", "minutely", "hourly", "daily"], - ], - "precip_intensity": [ - "Precip Intensity", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "in", - PRECIPITATION_MILLIMETERS_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-rainy", - ["currently", "minutely", "hourly", "daily"], - ], - "precip_probability": [ - "Precip Probability", - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - "mdi:water-percent", - ["currently", "minutely", "hourly", "daily"], - ], - "precip_accumulation": [ - "Precip Accumulation", - LENGTH_CENTIMETERS, - "in", - LENGTH_CENTIMETERS, - LENGTH_CENTIMETERS, - LENGTH_CENTIMETERS, - "mdi:weather-snowy", - ["hourly", "daily"], - ], - "temperature": [ - "Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["currently", "hourly"], - ], - "apparent_temperature": [ - "Apparent Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["currently", "hourly"], - ], - "dew_point": [ - "Dew Point", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["currently", "hourly", "daily"], - ], - "wind_speed": [ - "Wind Speed", - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ["currently", "hourly", "daily"], - ], - "wind_bearing": [ - "Wind Bearing", - DEGREE, - DEGREE, - DEGREE, - DEGREE, - DEGREE, - "mdi:compass", - ["currently", "hourly", "daily"], - ], - "wind_gust": [ - "Wind Gust", - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy-variant", - ["currently", "hourly", "daily"], - ], - "cloud_cover": [ - "Cloud Coverage", - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - "mdi:weather-partly-cloudy", - ["currently", "hourly", "daily"], - ], - "humidity": [ - "Humidity", - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - PERCENTAGE, - "mdi:water-percent", - ["currently", "hourly", "daily"], - ], - "pressure": [ - "Pressure", - PRESSURE_MBAR, - PRESSURE_MBAR, - PRESSURE_MBAR, - PRESSURE_MBAR, - PRESSURE_MBAR, - "mdi:gauge", - ["currently", "hourly", "daily"], - ], - "visibility": [ - "Visibility", - LENGTH_KILOMETERS, - "mi", - LENGTH_KILOMETERS, - LENGTH_KILOMETERS, - "mi", - "mdi:eye", - ["currently", "hourly", "daily"], - ], - "ozone": [ - "Ozone", - "DU", - "DU", - "DU", - "DU", - "DU", - "mdi:eye", - ["currently", "hourly", "daily"], - ], - "apparent_temperature_max": [ - "Daily High Apparent Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "apparent_temperature_high": [ - "Daytime High Apparent Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "apparent_temperature_min": [ - "Daily Low Apparent Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "apparent_temperature_low": [ - "Overnight Low Apparent Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "temperature_max": [ - "Daily High Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "temperature_high": [ - "Daytime High Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "temperature_min": [ - "Daily Low Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "temperature_low": [ - "Overnight Low Temperature", - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - TEMP_CELSIUS, - TEMP_CELSIUS, - "mdi:thermometer", - ["daily"], - ], - "precip_intensity_max": [ - "Daily Max Precip Intensity", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "in", - PRECIPITATION_MILLIMETERS_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:thermometer", - ["daily"], - ], - "uv_index": [ - "UV Index", - UV_INDEX, - UV_INDEX, - UV_INDEX, - UV_INDEX, - UV_INDEX, - "mdi:weather-sunny", - ["currently", "hourly", "daily"], - ], - "moon_phase": [ - "Moon Phase", - None, - None, - None, - None, - None, - "mdi:weather-night", - ["daily"], - ], - "sunrise_time": [ - "Sunrise", - None, - None, - None, - None, - None, - "mdi:white-balance-sunny", - ["daily"], - ], - "sunset_time": [ - "Sunset", - None, - None, - None, - None, - None, - "mdi:weather-night", - ["daily"], - ], - "alerts": ["Alerts", None, None, None, None, None, "mdi:alert-circle-outline", []], +MAP_UNIT_SYSTEM: dict[ + Literal["si", "us", "ca", "uk", "uk2"], + Literal["si_unit", "us_unit", "ca_unit", "uk_unit", "uk2_unit"], +] = { + "si": "si_unit", + "us": "us_unit", + "ca": "ca_unit", + "uk": "uk_unit", + "uk2": "uk2_unit", +} + + +@dataclass +class DarkskySensorEntityDescription(SensorEntityDescription): + """Describes Darksky sensor entity.""" + + si_unit: str | None = None + us_unit: str | None = None + ca_unit: str | None = None + uk_unit: str | None = None + uk2_unit: str | None = None + forecast_mode: list[str] = field(default_factory=list) + + +SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { + "summary": DarkskySensorEntityDescription( + key="summary", + name="Summary", + forecast_mode=["currently", "hourly", "daily"], + ), + "minutely_summary": DarkskySensorEntityDescription( + key="minutely_summary", + name="Minutely Summary", + forecast_mode=[], + ), + "hourly_summary": DarkskySensorEntityDescription( + key="hourly_summary", + name="Hourly Summary", + forecast_mode=[], + ), + "daily_summary": DarkskySensorEntityDescription( + key="daily_summary", + name="Daily Summary", + forecast_mode=[], + ), + "icon": DarkskySensorEntityDescription( + key="icon", + name="Icon", + forecast_mode=["currently", "hourly", "daily"], + ), + "nearest_storm_distance": DarkskySensorEntityDescription( + key="nearest_storm_distance", + name="Nearest Storm Distance", + si_unit=LENGTH_KILOMETERS, + us_unit=LENGTH_MILES, + ca_unit=LENGTH_KILOMETERS, + uk_unit=LENGTH_KILOMETERS, + uk2_unit=LENGTH_MILES, + icon="mdi:weather-lightning", + forecast_mode=["currently"], + ), + "nearest_storm_bearing": DarkskySensorEntityDescription( + key="nearest_storm_bearing", + name="Nearest Storm Bearing", + si_unit=DEGREE, + us_unit=DEGREE, + ca_unit=DEGREE, + uk_unit=DEGREE, + uk2_unit=DEGREE, + icon="mdi:weather-lightning", + forecast_mode=["currently"], + ), + "precip_type": DarkskySensorEntityDescription( + key="precip_type", + name="Precip", + icon="mdi:weather-pouring", + forecast_mode=["currently", "minutely", "hourly", "daily"], + ), + "precip_intensity": DarkskySensorEntityDescription( + key="precip_intensity", + name="Precip Intensity", + si_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + us_unit=PRECIPITATION_INCHES, + ca_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + uk_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + uk2_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-rainy", + forecast_mode=["currently", "minutely", "hourly", "daily"], + ), + "precip_probability": DarkskySensorEntityDescription( + key="precip_probability", + name="Precip Probability", + si_unit=PERCENTAGE, + us_unit=PERCENTAGE, + ca_unit=PERCENTAGE, + uk_unit=PERCENTAGE, + uk2_unit=PERCENTAGE, + icon="mdi:water-percent", + forecast_mode=["currently", "minutely", "hourly", "daily"], + ), + "precip_accumulation": DarkskySensorEntityDescription( + key="precip_accumulation", + name="Precip Accumulation", + si_unit=LENGTH_CENTIMETERS, + us_unit=LENGTH_INCHES, + ca_unit=LENGTH_CENTIMETERS, + uk_unit=LENGTH_CENTIMETERS, + uk2_unit=LENGTH_CENTIMETERS, + icon="mdi:weather-snowy", + forecast_mode=["hourly", "daily"], + ), + "temperature": DarkskySensorEntityDescription( + key="temperature", + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["currently", "hourly"], + ), + "apparent_temperature": DarkskySensorEntityDescription( + key="apparent_temperature", + name="Apparent Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["currently", "hourly"], + ), + "dew_point": DarkskySensorEntityDescription( + key="dew_point", + name="Dew Point", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["currently", "hourly", "daily"], + ), + "wind_speed": DarkskySensorEntityDescription( + key="wind_speed", + name="Wind Speed", + si_unit=SPEED_METERS_PER_SECOND, + us_unit=SPEED_MILES_PER_HOUR, + ca_unit=SPEED_KILOMETERS_PER_HOUR, + uk_unit=SPEED_MILES_PER_HOUR, + uk2_unit=SPEED_MILES_PER_HOUR, + icon="mdi:weather-windy", + forecast_mode=["currently", "hourly", "daily"], + ), + "wind_bearing": DarkskySensorEntityDescription( + key="wind_bearing", + name="Wind Bearing", + si_unit=DEGREE, + us_unit=DEGREE, + ca_unit=DEGREE, + uk_unit=DEGREE, + uk2_unit=DEGREE, + icon="mdi:compass", + forecast_mode=["currently", "hourly", "daily"], + ), + "wind_gust": DarkskySensorEntityDescription( + key="wind_gust", + name="Wind Gust", + si_unit=SPEED_METERS_PER_SECOND, + us_unit=SPEED_MILES_PER_HOUR, + ca_unit=SPEED_KILOMETERS_PER_HOUR, + uk_unit=SPEED_MILES_PER_HOUR, + uk2_unit=SPEED_MILES_PER_HOUR, + icon="mdi:weather-windy-variant", + forecast_mode=["currently", "hourly", "daily"], + ), + "cloud_cover": DarkskySensorEntityDescription( + key="cloud_cover", + name="Cloud Coverage", + si_unit=PERCENTAGE, + us_unit=PERCENTAGE, + ca_unit=PERCENTAGE, + uk_unit=PERCENTAGE, + uk2_unit=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + forecast_mode=["currently", "hourly", "daily"], + ), + "humidity": DarkskySensorEntityDescription( + key="humidity", + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + si_unit=PERCENTAGE, + us_unit=PERCENTAGE, + ca_unit=PERCENTAGE, + uk_unit=PERCENTAGE, + uk2_unit=PERCENTAGE, + forecast_mode=["currently", "hourly", "daily"], + ), + "pressure": DarkskySensorEntityDescription( + key="pressure", + name="Pressure", + device_class=SensorDeviceClass.PRESSURE, + si_unit=PRESSURE_MBAR, + us_unit=PRESSURE_MBAR, + ca_unit=PRESSURE_MBAR, + uk_unit=PRESSURE_MBAR, + uk2_unit=PRESSURE_MBAR, + forecast_mode=["currently", "hourly", "daily"], + ), + "visibility": DarkskySensorEntityDescription( + key="visibility", + name="Visibility", + si_unit=LENGTH_KILOMETERS, + us_unit=LENGTH_MILES, + ca_unit=LENGTH_KILOMETERS, + uk_unit=LENGTH_KILOMETERS, + uk2_unit=LENGTH_MILES, + icon="mdi:eye", + forecast_mode=["currently", "hourly", "daily"], + ), + "ozone": DarkskySensorEntityDescription( + key="ozone", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + si_unit="DU", + us_unit="DU", + ca_unit="DU", + uk_unit="DU", + uk2_unit="DU", + forecast_mode=["currently", "hourly", "daily"], + ), + "apparent_temperature_max": DarkskySensorEntityDescription( + key="apparent_temperature_max", + name="Daily High Apparent Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "apparent_temperature_high": DarkskySensorEntityDescription( + key="apparent_temperature_high", + name="Daytime High Apparent Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "apparent_temperature_min": DarkskySensorEntityDescription( + key="apparent_temperature_min", + name="Daily Low Apparent Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "apparent_temperature_low": DarkskySensorEntityDescription( + key="apparent_temperature_low", + name="Overnight Low Apparent Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "temperature_max": DarkskySensorEntityDescription( + key="temperature_max", + name="Daily High Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "temperature_high": DarkskySensorEntityDescription( + key="temperature_high", + name="Daytime High Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "temperature_min": DarkskySensorEntityDescription( + key="temperature_min", + name="Daily Low Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "temperature_low": DarkskySensorEntityDescription( + key="temperature_low", + name="Overnight Low Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + si_unit=TEMP_CELSIUS, + us_unit=TEMP_FAHRENHEIT, + ca_unit=TEMP_CELSIUS, + uk_unit=TEMP_CELSIUS, + uk2_unit=TEMP_CELSIUS, + forecast_mode=["daily"], + ), + "precip_intensity_max": DarkskySensorEntityDescription( + key="precip_intensity_max", + name="Daily Max Precip Intensity", + si_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + us_unit=PRECIPITATION_INCHES, + ca_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + uk_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + uk2_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:thermometer", + forecast_mode=["daily"], + ), + "uv_index": DarkskySensorEntityDescription( + key="uv_index", + name="UV Index", + si_unit=UV_INDEX, + us_unit=UV_INDEX, + ca_unit=UV_INDEX, + uk_unit=UV_INDEX, + uk2_unit=UV_INDEX, + icon="mdi:weather-sunny", + forecast_mode=["currently", "hourly", "daily"], + ), + "moon_phase": DarkskySensorEntityDescription( + key="moon_phase", + name="Moon Phase", + icon="mdi:weather-night", + forecast_mode=["daily"], + ), + "sunrise_time": DarkskySensorEntityDescription( + key="sunrise_time", + name="Sunrise", + icon="mdi:white-balance-sunny", + forecast_mode=["daily"], + ), + "sunset_time": DarkskySensorEntityDescription( + key="sunset_time", + name="Sunset", + icon="mdi:weather-night", + forecast_mode=["daily"], + ), + "alerts": DarkskySensorEntityDescription( + key="alerts", + name="Alerts", + icon="mdi:alert-circle-outline", + forecast_mode=[], + ), } -CONDITION_PICTURES = { - "clear-day": ["/static/images/darksky/weather-sunny.svg", "mdi:weather-sunny"], - "clear-night": ["/static/images/darksky/weather-night.svg", "mdi:weather-night"], - "rain": ["/static/images/darksky/weather-pouring.svg", "mdi:weather-pouring"], - "snow": ["/static/images/darksky/weather-snowy.svg", "mdi:weather-snowy"], - "sleet": ["/static/images/darksky/weather-hail.svg", "mdi:weather-snowy-rainy"], - "wind": ["/static/images/darksky/weather-windy.svg", "mdi:weather-windy"], - "fog": ["/static/images/darksky/weather-fog.svg", "mdi:weather-fog"], - "cloudy": ["/static/images/darksky/weather-cloudy.svg", "mdi:weather-cloudy"], - "partly-cloudy-day": [ - "/static/images/darksky/weather-partlycloudy.svg", - "mdi:weather-partly-cloudy", - ], - "partly-cloudy-night": [ - "/static/images/darksky/weather-cloudy.svg", - "mdi:weather-night-partly-cloudy", - ], + +class ConditionPicture(NamedTuple): + """Entity picture and icon for condition.""" + + entity_picture: str + icon: str + + +CONDITION_PICTURES: dict[str, ConditionPicture] = { + "clear-day": ConditionPicture( + entity_picture="/static/images/darksky/weather-sunny.svg", + icon="mdi:weather-sunny", + ), + "clear-night": ConditionPicture( + entity_picture="/static/images/darksky/weather-night.svg", + icon="mdi:weather-night", + ), + "rain": ConditionPicture( + entity_picture="/static/images/darksky/weather-pouring.svg", + icon="mdi:weather-pouring", + ), + "snow": ConditionPicture( + entity_picture="/static/images/darksky/weather-snowy.svg", + icon="mdi:weather-snowy", + ), + "sleet": ConditionPicture( + entity_picture="/static/images/darksky/weather-hail.svg", + icon="mdi:weather-snowy-rainy", + ), + "wind": ConditionPicture( + entity_picture="/static/images/darksky/weather-windy.svg", + icon="mdi:weather-windy", + ), + "fog": ConditionPicture( + entity_picture="/static/images/darksky/weather-fog.svg", + icon="mdi:weather-fog", + ), + "cloudy": ConditionPicture( + entity_picture="/static/images/darksky/weather-cloudy.svg", + icon="mdi:weather-cloudy", + ), + "partly-cloudy-day": ConditionPicture( + entity_picture="/static/images/darksky/weather-partlycloudy.svg", + icon="mdi:weather-partly-cloudy", + ), + "partly-cloudy-night": ConditionPicture( + entity_picture="/static/images/darksky/weather-cloudy.svg", + icon="mdi:weather-night-partly-cloudy", + ), } # Language Supported Codes @@ -523,26 +607,31 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for variable in config[CONF_MONITORED_CONDITIONS]: if variable in DEPRECATED_SENSOR_TYPES: _LOGGER.warning("Monitored condition %s is deprecated", variable) - if not SENSOR_TYPES[variable][7] or "currently" in SENSOR_TYPES[variable][7]: + description = SENSOR_TYPES[variable] + if not description.forecast_mode or "currently" in description.forecast_mode: if variable == "alerts": - sensors.append(DarkSkyAlertSensor(forecast_data, variable, name)) + sensors.append(DarkSkyAlertSensor(forecast_data, description, name)) else: - sensors.append(DarkSkySensor(forecast_data, variable, name)) + sensors.append(DarkSkySensor(forecast_data, description, name)) - if forecast is not None and "daily" in SENSOR_TYPES[variable][7]: - for forecast_day in forecast: - sensors.append( + if forecast is not None and "daily" in description.forecast_mode: + sensors.extend( + [ DarkSkySensor( - forecast_data, variable, name, forecast_day=forecast_day + forecast_data, description, name, forecast_day=forecast_day ) - ) - if forecast_hour is not None and "hourly" in SENSOR_TYPES[variable][7]: - for forecast_h in forecast_hour: - sensors.append( + for forecast_day in forecast + ] + ) + if forecast_hour is not None and "hourly" in description.forecast_mode: + sensors.extend( + [ DarkSkySensor( - forecast_data, variable, name, forecast_hour=forecast_h + forecast_data, description, name, forecast_hour=forecast_h ) - ) + for forecast_h in forecast_hour + ] + ) add_entities(sensors, True) @@ -550,36 +639,34 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DarkSkySensor(SensorEntity): """Implementation of a Dark Sky sensor.""" + entity_description: DarkskySensorEntityDescription + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__( - self, forecast_data, sensor_type, name, forecast_day=None, forecast_hour=None + self, + forecast_data, + description: DarkskySensorEntityDescription, + name, + forecast_day=None, + forecast_hour=None, ): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.forecast_data = forecast_data - self.type = sensor_type self.forecast_day = forecast_day self.forecast_hour = forecast_hour - self._state = None self._icon = None self._unit_of_measurement = None - @property - def name(self): - """Return the name of the sensor.""" - if self.forecast_day is not None: - return f"{self.client_name} {self._name} {self.forecast_day}d" - if self.forecast_hour is not None: - return f"{self.client_name} {self._name} {self.forecast_hour}h" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + if forecast_day is not None: + self._attr_name = f"{name} {description.name} {forecast_day}d" + elif forecast_hour is not None: + self._attr_name = f"{name} {description.name} {forecast_hour}h" + else: + self._attr_name = f"{name} {description.name}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @@ -591,41 +678,29 @@ def unit_system(self): @property def entity_picture(self): """Return the entity picture to use in the frontend, if any.""" - if self._icon is None or "summary" not in self.type: + if self._icon is None or "summary" not in self.entity_description.key: return None if self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon][0] + return CONDITION_PICTURES[self._icon].entity_picture return None def update_unit_of_measurement(self): """Update units based on unit system.""" - unit_index = {"si": 1, "us": 2, "ca": 3, "uk": 4, "uk2": 5}.get( - self.unit_system, 1 - ) - self._unit_of_measurement = SENSOR_TYPES[self.type][unit_index] + unit_key = MAP_UNIT_SYSTEM.get(self.unit_system, "si_unit") + self._unit_of_measurement = getattr(self.entity_description, unit_key) @property def icon(self): """Icon to use in the frontend, if any.""" - if "summary" in self.type and self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon][1] + if ( + "summary" in self.entity_description.key + and self._icon in CONDITION_PICTURES + ): + return CONDITION_PICTURES[self._icon].icon - return SENSOR_TYPES[self.type][6] - - @property - def device_class(self): - """Device class of the entity.""" - if SENSOR_TYPES[self.type][1] == TEMP_CELSIUS: - return DEVICE_CLASS_TEMPERATURE - - return None - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} + return self.entity_description.icon def update(self): """Get the latest data from Dark Sky and updates the states.""" @@ -636,39 +711,42 @@ def update(self): self.forecast_data.update() self.update_unit_of_measurement() - if self.type == "minutely_summary": + sensor_type = self.entity_description.key + if sensor_type == "minutely_summary": self.forecast_data.update_minutely() minutely = self.forecast_data.data_minutely - self._state = getattr(minutely, "summary", "") + self._attr_native_value = getattr(minutely, "summary", "") self._icon = getattr(minutely, "icon", "") - elif self.type == "hourly_summary": + elif sensor_type == "hourly_summary": self.forecast_data.update_hourly() hourly = self.forecast_data.data_hourly - self._state = getattr(hourly, "summary", "") + self._attr_native_value = getattr(hourly, "summary", "") self._icon = getattr(hourly, "icon", "") elif self.forecast_hour is not None: self.forecast_data.update_hourly() hourly = self.forecast_data.data_hourly if hasattr(hourly, "data"): - self._state = self.get_state(hourly.data[self.forecast_hour]) + self._attr_native_value = self.get_state( + hourly.data[self.forecast_hour] + ) else: - self._state = 0 - elif self.type == "daily_summary": + self._attr_native_value = 0 + elif sensor_type == "daily_summary": self.forecast_data.update_daily() daily = self.forecast_data.data_daily - self._state = getattr(daily, "summary", "") + self._attr_native_value = getattr(daily, "summary", "") self._icon = getattr(daily, "icon", "") elif self.forecast_day is not None: self.forecast_data.update_daily() daily = self.forecast_data.data_daily if hasattr(daily, "data"): - self._state = self.get_state(daily.data[self.forecast_day]) + self._attr_native_value = self.get_state(daily.data[self.forecast_day]) else: - self._state = 0 + self._attr_native_value = 0 else: self.forecast_data.update_currently() currently = self.forecast_data.data_currently - self._state = self.get_state(currently) + self._attr_native_value = self.get_state(currently) def get_state(self, data): """ @@ -676,21 +754,21 @@ def get_state(self, data): If the sensor type is unknown, the current state is returned. """ - lookup_type = convert_to_camel(self.type) - state = getattr(data, lookup_type, None) + sensor_type = self.entity_description.key + lookup_type = convert_to_camel(sensor_type) - if state is None: - return state + if (state := getattr(data, lookup_type, None)) is None: + return None - if "summary" in self.type: + if "summary" in sensor_type: self._icon = getattr(data, "icon", "") # Some state data needs to be rounded to whole values or converted to # percentages - if self.type in ["precip_probability", "cloud_cover", "humidity"]: + if sensor_type in {"precip_probability", "cloud_cover", "humidity"}: return round(state * 100, 1) - if self.type in [ + if sensor_type in { "dew_point", "temperature", "apparent_temperature", @@ -706,7 +784,7 @@ def get_state(self, data): "pressure", "ozone", "uvIndex", - ]: + }: return round(state, 1) return state @@ -714,30 +792,23 @@ def get_state(self, data): class DarkSkyAlertSensor(SensorEntity): """Implementation of a Dark Sky sensor.""" - def __init__(self, forecast_data, sensor_type, name): + entity_description: DarkskySensorEntityDescription + _attr_native_value: int | None + + def __init__( + self, forecast_data, description: DarkskySensorEntityDescription, name + ): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.forecast_data = forecast_data - self.type = sensor_type - self._state = None - self._icon = None self._alerts = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{name} {description.name}" @property def icon(self): """Icon to use in the frontend, if any.""" - if self._state is not None and self._state > 0: + if self._attr_native_value is not None and self._attr_native_value > 0: return "mdi:alert-circle" return "mdi:alert-circle-outline" @@ -755,7 +826,7 @@ def update(self): self.forecast_data.update() self.forecast_data.update_alerts() alerts = self.forecast_data.data_alerts - self._state = self.get_state(alerts) + self._attr_native_value = self.get_state(alerts) def get_state(self, data): """ diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index 0ad448ddfbd9a..b5aafd24c3d62 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -91,8 +91,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config.get(CONF_NAME) mode = config.get(CONF_MODE) - units = config.get(CONF_UNITS) - if not units: + if not (units := config.get(CONF_UNITS)): units = "ca" if hass.config.units.is_metric else "us" dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index c324d6a5b6469..8b7e7ed8c047b 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -1,4 +1,5 @@ """Support for DD-WRT routers.""" +from http import HTTPStatus import logging import re @@ -7,7 +8,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import ( @@ -16,8 +17,6 @@ CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, - HTTP_OK, - HTTP_UNAUTHORIZED, ) import homeassistant.helpers.config_validation as cv @@ -31,7 +30,7 @@ CONF_WIRELESS_ONLY = "wireless_only" DEFAULT_WIRELESS_ONLY = True -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -68,8 +67,7 @@ def __init__(self, config): # Test the router is accessible url = f"{self.protocol}://{self.host}/Status_Wireless.live.asp" - data = self.get_ddwrt_data(url) - if not data: + if not self.get_ddwrt_data(url): raise ConnectionError("Cannot connect to DD-Wrt router") def scan_devices(self): @@ -83,14 +81,11 @@ def get_device_name(self, device): # If not initialised and not already scanned and not found. if device not in self.mac2name: url = f"{self.protocol}://{self.host}/Status_Lan.live.asp" - data = self.get_ddwrt_data(url) - if not data: + if not (data := self.get_ddwrt_data(url)): return None - dhcp_leases = data.get("dhcp_leases") - - if not dhcp_leases: + if not (dhcp_leases := data.get("dhcp_leases")): return None # Remove leading and trailing quotes and spaces @@ -118,9 +113,8 @@ def _update_info(self): endpoint = "Wireless" if self.wireless_only else "Lan" url = f"{self.protocol}://{self.host}/Status_{endpoint}.live.asp" - data = self.get_ddwrt_data(url) - if not data: + if not (data := self.get_ddwrt_data(url)): return False self.last_results = [] @@ -154,9 +148,9 @@ def get_ddwrt_data(self, url): except requests.exceptions.Timeout: _LOGGER.exception("Connection to the router timed out") return - if response.status_code == HTTP_OK: + if response.status_code == HTTPStatus.OK: return _parse_ddwrt_response(response.text) - if response.status_code == HTTP_UNAUTHORIZED: + if response.status_code == HTTPStatus.UNAUTHORIZED: # Authentication error _LOGGER.exception( "Failed to authenticate, check your username and password" diff --git a/homeassistant/components/debugpy/__init__.py b/homeassistant/components/debugpy/__init__.py index 98f08827c239e..21cfeb15a808f 100644 --- a/homeassistant/components/debugpy/__init__.py +++ b/homeassistant/components/debugpy/__init__.py @@ -1,7 +1,7 @@ """The Remote Python Debugger integration.""" from __future__ import annotations -from asyncio import Event +from asyncio import Event, get_running_loop import logging from threading import Thread @@ -15,8 +15,8 @@ from homeassistant.helpers.typing import ConfigType DOMAIN = "debugpy" -CONF_WAIT = "wait" CONF_START = "start" +CONF_WAIT = "wait" SERVICE_START = "start" CONFIG_SCHEMA = vol.Schema( @@ -43,11 +43,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def debug_start( call: ServiceCall | None = None, *, wait: bool = True ) -> None: - """Start the debugger.""" + """Enable asyncio debugging and start the debugger.""" + get_running_loop().set_debug(True) + debugpy.listen((conf[CONF_HOST], conf[CONF_PORT])) - wait = conf[CONF_WAIT] - if wait: + if conf[CONF_WAIT]: _LOGGER.warning( "Waiting for remote debug connection on %s:%s", conf[CONF_HOST], diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index b82f544329c1d..041c2f9e31e82 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -2,7 +2,7 @@ "domain": "debugpy", "name": "Remote Python Debugger", "documentation": "https://www.home-assistant.io/integrations/debugpy", - "requirements": ["debugpy==1.3.0"], + "requirements": ["debugpy==1.5.1"], "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/debugpy/services.yaml b/homeassistant/components/debugpy/services.yaml index 6bf9ad6728844..c864684226f05 100644 --- a/homeassistant/components/debugpy/services.yaml +++ b/homeassistant/components/debugpy/services.yaml @@ -1,3 +1,4 @@ # Describes the format for available Remote Python Debugger services start: + name: Start description: Start the Remote Python Debugger diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 8b47363c7ba98..f069605d43865 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,12 +1,18 @@ """Support for deCONZ devices.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback -from homeassistant.helpers.entity_registry import async_migrate_entries +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.entity_registry as er from .config_flow import get_master_gateway from .const import CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN @@ -14,14 +20,13 @@ from .services import async_setup_services, async_unload_services -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a deCONZ bridge for a config entry. Load config, group, light and sensor data for server information. Start websocket for push notification of state changes from deCONZ. """ - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) await async_update_group_unique_id(hass, config_entry) @@ -29,15 +34,15 @@ async def async_setup_entry(hass, config_entry): await async_update_master_gateway(hass, config_entry) gateway = DeconzGateway(hass, config_entry) - if not await gateway.async_setup(): return False - hass.data[DOMAIN][config_entry.unique_id] = gateway + if not hass.data[DOMAIN]: + async_setup_services(hass) - await gateway.async_update_device_registry() + hass.data[DOMAIN][config_entry.entry_id] = gateway - await async_setup_services(hass) + await gateway.async_update_device_registry() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown) @@ -46,12 +51,12 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload deCONZ config entry.""" - gateway = hass.data[DOMAIN].pop(config_entry.unique_id) + gateway = hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.data[DOMAIN]: - await async_unload_services(hass) + async_unload_services(hass) elif gateway.master: await async_update_master_gateway(hass, config_entry) @@ -61,27 +66,36 @@ async def async_unload_entry(hass, config_entry): return await gateway.async_reset() -async def async_update_master_gateway(hass, config_entry): +async def async_update_master_gateway( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Update master gateway boolean. Called by setup_entry and unload_entry. Makes sure there is always one master available. """ - master = not get_master_gateway(hass) + try: + master_gateway = get_master_gateway(hass) + master = master_gateway.config_entry == config_entry + except ValueError: + master = True + options = {**config_entry.options, CONF_MASTER_GATEWAY: master} hass.config_entries.async_update_entry(config_entry, options=options) -async def async_update_group_unique_id(hass, config_entry) -> None: +async def async_update_group_unique_id( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Update unique ID entities based on deCONZ groups.""" - if not (old_unique_id := config_entry.data.get(CONF_GROUP_ID_BASE)): + if not isinstance(old_unique_id := config_entry.data.get(CONF_GROUP_ID_BASE), str): return - new_unique_id: str = config_entry.unique_id + new_unique_id = cast(str, config_entry.unique_id) @callback - def update_unique_id(entity_entry): + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: """Update unique ID of entity entry.""" if f"{old_unique_id}-" not in entity_entry.unique_id: return None @@ -91,7 +105,7 @@ def update_unique_id(entity_entry): ) } - await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) data = { CONF_API_KEY: config_entry.data[CONF_API_KEY], CONF_HOST: config_entry.data[CONF_HOST], diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 6bb4b72e89de2..e16e4bcc327aa 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -1,61 +1,77 @@ """Support for deCONZ alarm control panel devices.""" from __future__ import annotations +from collections.abc import ValuesView + +from pydeconz.alarm_system import AlarmSystem from pydeconz.sensor import ( ANCILLARY_CONTROL_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY, + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, ANCILLARY_CONTROL_DISARMED, + ANCILLARY_CONTROL_ENTRY_DELAY, + ANCILLARY_CONTROL_EXIT_DELAY, + ANCILLARY_CONTROL_IN_ALARM, AncillaryControl, ) -import voluptuous as vol from homeassistant.components.alarm_control_panel import ( DOMAIN, + FORMAT_NUMBER, SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, AlarmControlPanelEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback -from homeassistant.helpers import entity_platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEW_SENSOR from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry - -PANEL_ENTRY_DELAY = "entry_delay" -PANEL_EXIT_DELAY = "exit_delay" -PANEL_NOT_READY_TO_ARM = "not_ready_to_arm" - -SERVICE_ALARM_PANEL_STATE = "alarm_panel_state" -CONF_ALARM_PANEL_STATE = "panel_state" -SERVICE_ALARM_PANEL_STATE_SCHEMA = { - vol.Required(CONF_ALARM_PANEL_STATE): vol.In( - [ - PANEL_ENTRY_DELAY, - PANEL_EXIT_DELAY, - PANEL_NOT_READY_TO_ARM, - ] - ) -} +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_TO_ALARM_STATE = { ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, + ANCILLARY_CONTROL_ARMING_AWAY: STATE_ALARM_ARMING, + ANCILLARY_CONTROL_ARMING_NIGHT: STATE_ALARM_ARMING, + ANCILLARY_CONTROL_ARMING_STAY: STATE_ALARM_ARMING, ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, + ANCILLARY_CONTROL_ENTRY_DELAY: STATE_ALARM_PENDING, + ANCILLARY_CONTROL_EXIT_DELAY: STATE_ALARM_PENDING, + ANCILLARY_CONTROL_IN_ALARM: STATE_ALARM_TRIGGERED, } -async def async_setup_entry(hass, config_entry, async_add_entities) -> None: +def get_alarm_system_for_unique_id( + gateway: DeconzGateway, unique_id: str +) -> AlarmSystem | None: + """Retrieve alarm system unique ID is registered to.""" + for alarm_system in gateway.api.alarmsystems.values(): + if unique_id in alarm_system.devices: + return alarm_system + return None + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ alarm control panel devices. Alarm control panels are based on the same device class as sensors in deCONZ. @@ -63,33 +79,36 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - platform = entity_platform.async_get_current_platform() - @callback - def async_add_alarm_control_panel(sensors=gateway.api.sensors.values()) -> None: + def async_add_alarm_control_panel( + sensors: list[AncillaryControl] + | ValuesView[AncillaryControl] = gateway.api.sensors.values(), + ) -> None: """Add alarm control panel devices from deCONZ.""" entities = [] for sensor in sensors: if ( - sensor.type in AncillaryControl.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] + isinstance(sensor, AncillaryControl) + and sensor.unique_id not in gateway.entities[DOMAIN] + and ( + alarm_system := get_alarm_system_for_unique_id( + gateway, sensor.unique_id + ) + ) + is not None ): - entities.append(DeconzAlarmControlPanel(sensor, gateway)) + + entities.append(DeconzAlarmControlPanel(sensor, gateway, alarm_system)) if entities: - platform.async_register_entity_service( - SERVICE_ALARM_PANEL_STATE, - SERVICE_ALARM_PANEL_STATE_SCHEMA, - "async_set_panel_state", - ) async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect( hass, - gateway.async_signal_new_device(NEW_SENSOR), + gateway.signal_new_sensor, async_add_alarm_control_panel, ) ) @@ -101,67 +120,50 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): """Representation of a deCONZ alarm control panel.""" TYPE = DOMAIN + _device: AncillaryControl + + _attr_code_format = FORMAT_NUMBER + _attr_supported_features = ( + SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT + ) - def __init__(self, device, gateway) -> None: + def __init__( + self, + device: AncillaryControl, + gateway: DeconzGateway, + alarm_system: AlarmSystem, + ) -> None: """Set up alarm control panel device.""" super().__init__(device, gateway) - - self._features = SUPPORT_ALARM_ARM_AWAY - self._features |= SUPPORT_ALARM_ARM_HOME - self._features |= SUPPORT_ALARM_ARM_NIGHT - - self._service_to_device_panel_command = { - PANEL_ENTRY_DELAY: self._device.entry_delay, - PANEL_EXIT_DELAY: self._device.exit_delay, - PANEL_NOT_READY_TO_ARM: self._device.not_ready_to_arm, - } - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return self._features - - @property - def code_arm_required(self) -> bool: - """Code is not required for arm actions.""" - return False - - @property - def code_format(self) -> None: - """Code is not supported.""" - return None + self.alarm_system = alarm_system @callback - def async_update_callback(self, force_update: bool = False) -> None: + def async_update_callback(self) -> None: """Update the control panels state.""" - keys = {"armed", "reachable"} - if force_update or ( + keys = {"panel", "reachable"} + if ( self._device.changed_keys.intersection(keys) and self._device.state in DECONZ_TO_ALARM_STATE ): - super().async_update_callback(force_update=force_update) + super().async_update_callback() @property - def state(self) -> str: + def state(self) -> str | None: """Return the state of the control panel.""" - return DECONZ_TO_ALARM_STATE.get(self._device.state) + return DECONZ_TO_ALARM_STATE.get(self._device.panel) - async def async_alarm_arm_away(self, code: None = None) -> None: + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._device.arm_away() + await self.alarm_system.arm_away(code) - async def async_alarm_arm_home(self, code: None = None) -> None: + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._device.arm_stay() + await self.alarm_system.arm_stay(code) - async def async_alarm_arm_night(self, code: None = None) -> None: + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - await self._device.arm_night() + await self.alarm_system.arm_night(code) - async def async_alarm_disarm(self, code: None = None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._device.disarm() - - async def async_set_panel_state(self, panel_state: str) -> None: - """Send panel_state command.""" - await self._service_to_device_panel_command[panel_state]() + await self.alarm_system.disarm(code) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index de23d06e7dbda..32b444b115d9a 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,58 +1,106 @@ """Support for deCONZ binary sensors.""" -from pydeconz.sensor import CarbonMonoxide, Fire, OpenClose, Presence, Vibration, Water +from __future__ import annotations + +from collections.abc import ValuesView + +from pydeconz.sensor import ( + Alarm, + CarbonMonoxide, + DeconzBinarySensor as PydeconzBinarySensor, + DeconzSensor as PydeconzSensor, + Fire, + GenericFlag, + OpenClose, + Presence, + Vibration, + Water, +) from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_GAS, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_PROBLEM, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_VIBRATION, DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR +from .const import ATTR_DARK, ATTR_ON from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry + +DECONZ_BINARY_SENSORS = ( + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + OpenClose, + Presence, + Vibration, + Water, +) ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" ATTR_VIBRATIONSTRENGTH = "vibrationstrength" -DEVICE_CLASS = { - CarbonMonoxide: DEVICE_CLASS_GAS, - Fire: DEVICE_CLASS_SMOKE, - OpenClose: DEVICE_CLASS_OPENING, - Presence: DEVICE_CLASS_MOTION, - Vibration: DEVICE_CLASS_VIBRATION, - Water: DEVICE_CLASS_MOISTURE, +ENTITY_DESCRIPTIONS = { + CarbonMonoxide: BinarySensorEntityDescription( + key="carbonmonoxide", + device_class=BinarySensorDeviceClass.GAS, + ), + Fire: BinarySensorEntityDescription( + key="fire", + device_class=BinarySensorDeviceClass.SMOKE, + ), + OpenClose: BinarySensorEntityDescription( + key="openclose", + device_class=BinarySensorDeviceClass.OPENING, + ), + Presence: BinarySensorEntityDescription( + key="presence", + device_class=BinarySensorDeviceClass.MOTION, + ), + Vibration: BinarySensorEntityDescription( + key="vibration", + device_class=BinarySensorDeviceClass.VIBRATION, + ), + Water: BinarySensorEntityDescription( + key="water", + device_class=BinarySensorDeviceClass.MOISTURE, + ), } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ binary sensor.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_sensor(sensors=gateway.api.sensors.values()): + def async_add_sensor( + sensors: list[PydeconzSensor] + | ValuesView[PydeconzSensor] = gateway.api.sensors.values(), + ) -> None: """Add binary sensor from deCONZ.""" - entities = [] + entities: list[DeconzBinarySensor | DeconzTampering] = [] for sensor in sensors: + if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): + continue + if ( - sensor.BINARY - and sensor.uniqueid not in gateway.entities[DOMAIN] - and ( - gateway.option_allow_clip_sensor - or not sensor.type.startswith("CLIP") - ) + isinstance(sensor, DECONZ_BINARY_SENSORS) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzBinarySensor(sensor, gateway)) @@ -67,7 +115,9 @@ def async_add_sensor(sensors=gateway.api.sensors.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + hass, + gateway.signal_new_sensor, + async_add_sensor, ) ) @@ -80,28 +130,31 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): """Representation of a deCONZ binary sensor.""" TYPE = DOMAIN + _device: PydeconzBinarySensor + + def __init__(self, device: PydeconzBinarySensor, gateway: DeconzGateway) -> None: + """Initialize deCONZ binary sensor.""" + super().__init__(device, gateway) + + if entity_description := ENTITY_DESCRIPTIONS.get(type(device)): + self.entity_description = entity_description @callback - def async_update_callback(self, force_update=False): + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"on", "reachable", "state"} - if force_update or self._device.changed_keys.intersection(keys): - super().async_update_callback(force_update=force_update) + if self._device.changed_keys.intersection(keys): + super().async_update_callback() @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor is on.""" - return self._device.state - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS.get(type(self._device)) + return self._device.state # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | float | int | list | None]: """Return the state attributes of the sensor.""" - attr = {} + attr: dict[str, bool | float | int | list | None] = {} if self._device.on is not None: attr[ATTR_ON] = self._device.on @@ -109,15 +162,15 @@ def extra_state_attributes(self): if self._device.secondary_temperature is not None: attr[ATTR_TEMPERATURE] = self._device.secondary_temperature - if self._device.type in Presence.ZHATYPE: + if isinstance(self._device, Presence): if self._device.dark is not None: attr[ATTR_DARK] = self._device.dark - elif self._device.type in Vibration.ZHATYPE: + elif isinstance(self._device, Vibration): attr[ATTR_ORIENTATION] = self._device.orientation - attr[ATTR_TILTANGLE] = self._device.tiltangle - attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength + attr[ATTR_TILTANGLE] = self._device.tilt_angle + attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibration_strength return attr @@ -126,6 +179,16 @@ class DeconzTampering(DeconzDevice, BinarySensorEntity): """Representation of a deCONZ tampering sensor.""" TYPE = DOMAIN + _device: PydeconzSensor + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_device_class = BinarySensorDeviceClass.TAMPER + + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: + """Initialize deCONZ binary sensor.""" + super().__init__(device, gateway) + + self._attr_name = f"{self._device.name} Tampered" @property def unique_id(self) -> str: @@ -133,23 +196,13 @@ def unique_id(self) -> str: return f"{self.serial}-tampered" @callback - def async_update_callback(self, force_update: bool = False) -> None: + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"tampered", "reachable"} - if force_update or self._device.changed_keys.intersection(keys): - super().async_update_callback(force_update=force_update) + if self._device.changed_keys.intersection(keys): + super().async_update_callback() @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self._device.tampered - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.name} Tampered" - - @property - def device_class(self) -> str: - """Return the class of the sensor.""" - return DEVICE_CLASS_PROBLEM + return self._device.tampered # type: ignore[no-any-return] diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 1ef881e9c9033..85ab4b17a1e8c 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -1,7 +1,30 @@ """Support for deCONZ climate devices.""" from __future__ import annotations -from pydeconz.sensor import Thermostat +from collections.abc import ValuesView +from typing import Any + +from pydeconz.sensor import ( + THERMOSTAT_FAN_MODE_AUTO, + THERMOSTAT_FAN_MODE_HIGH, + THERMOSTAT_FAN_MODE_LOW, + THERMOSTAT_FAN_MODE_MEDIUM, + THERMOSTAT_FAN_MODE_OFF, + THERMOSTAT_FAN_MODE_ON, + THERMOSTAT_FAN_MODE_SMART, + THERMOSTAT_MODE_AUTO, + THERMOSTAT_MODE_COOL, + THERMOSTAT_MODE_HEAT, + THERMOSTAT_MODE_OFF, + THERMOSTAT_PRESET_AUTO, + THERMOSTAT_PRESET_BOOST, + THERMOSTAT_PRESET_COMFORT, + THERMOSTAT_PRESET_COMPLEX, + THERMOSTAT_PRESET_ECO, + THERMOSTAT_PRESET_HOLIDAY, + THERMOSTAT_PRESET_MANUAL, + Thermostat, +) from homeassistant.components.climate import DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( @@ -22,33 +45,35 @@ SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE, NEW_SENSOR +from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_FAN_SMART = "smart" FAN_MODE_TO_DECONZ = { - DECONZ_FAN_SMART: "smart", - FAN_AUTO: "auto", - FAN_HIGH: "high", - FAN_MEDIUM: "medium", - FAN_LOW: "low", - FAN_ON: "on", - FAN_OFF: "off", + DECONZ_FAN_SMART: THERMOSTAT_FAN_MODE_SMART, + FAN_AUTO: THERMOSTAT_FAN_MODE_AUTO, + FAN_HIGH: THERMOSTAT_FAN_MODE_HIGH, + FAN_MEDIUM: THERMOSTAT_FAN_MODE_MEDIUM, + FAN_LOW: THERMOSTAT_FAN_MODE_LOW, + FAN_ON: THERMOSTAT_FAN_MODE_ON, + FAN_OFF: THERMOSTAT_FAN_MODE_OFF, } DECONZ_TO_FAN_MODE = {value: key for key, value in FAN_MODE_TO_DECONZ.items()} HVAC_MODE_TO_DECONZ = { - HVAC_MODE_AUTO: "auto", - HVAC_MODE_COOL: "cool", - HVAC_MODE_HEAT: "heat", - HVAC_MODE_OFF: "off", + HVAC_MODE_AUTO: THERMOSTAT_MODE_AUTO, + HVAC_MODE_COOL: THERMOSTAT_MODE_COOL, + HVAC_MODE_HEAT: THERMOSTAT_MODE_HEAT, + HVAC_MODE_OFF: THERMOSTAT_MODE_OFF, } DECONZ_PRESET_AUTO = "auto" @@ -57,19 +82,23 @@ DECONZ_PRESET_MANUAL = "manual" PRESET_MODE_TO_DECONZ = { - DECONZ_PRESET_AUTO: "auto", - PRESET_BOOST: "boost", - PRESET_COMFORT: "comfort", - DECONZ_PRESET_COMPLEX: "complex", - PRESET_ECO: "eco", - DECONZ_PRESET_HOLIDAY: "holiday", - DECONZ_PRESET_MANUAL: "manual", + DECONZ_PRESET_AUTO: THERMOSTAT_PRESET_AUTO, + PRESET_BOOST: THERMOSTAT_PRESET_BOOST, + PRESET_COMFORT: THERMOSTAT_PRESET_COMFORT, + DECONZ_PRESET_COMPLEX: THERMOSTAT_PRESET_COMPLEX, + PRESET_ECO: THERMOSTAT_PRESET_ECO, + DECONZ_PRESET_HOLIDAY: THERMOSTAT_PRESET_HOLIDAY, + DECONZ_PRESET_MANUAL: THERMOSTAT_PRESET_MANUAL, } DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.items()} -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ climate devices. Thermostats are based on the same device class as sensors in deCONZ. @@ -78,19 +107,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.entities[DOMAIN] = set() @callback - def async_add_climate(sensors=gateway.api.sensors.values()): + def async_add_climate( + sensors: list[Thermostat] + | ValuesView[Thermostat] = gateway.api.sensors.values(), + ) -> None: """Add climate devices from deCONZ.""" - entities = [] + entities: list[DeconzThermostat] = [] for sensor in sensors: + if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): + continue + if ( - sensor.type in Thermostat.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] - and ( - gateway.option_allow_clip_sensor - or not sensor.type.startswith("CLIP") - ) + isinstance(sensor, Thermostat) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzThermostat(sensor, gateway)) @@ -99,7 +130,9 @@ def async_add_climate(sensors=gateway.api.sensors.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_climate + hass, + gateway.signal_new_sensor, + async_add_climate, ) ) @@ -110,13 +143,16 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): """Representation of a deCONZ thermostat.""" TYPE = DOMAIN + _device: Thermostat + + _attr_temperature_unit = TEMP_CELSIUS - def __init__(self, device, gateway): + def __init__(self, device: Thermostat, gateway: DeconzGateway) -> None: """Set up thermostat device.""" super().__init__(device, gateway) self._hvac_mode_to_deconz = dict(HVAC_MODE_TO_DECONZ) - if "mode" not in device.raw["config"]: + if not device.mode: self._hvac_mode_to_deconz = { HVAC_MODE_HEAT: True, HVAC_MODE_OFF: False, @@ -127,18 +163,13 @@ def __init__(self, device, gateway): value: key for key, value in self._hvac_mode_to_deconz.items() } - self._features = SUPPORT_TARGET_TEMPERATURE + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE - if "fanmode" in device.raw["config"]: - self._features |= SUPPORT_FAN_MODE + if device.fan_mode: + self._attr_supported_features |= SUPPORT_FAN_MODE - if "preset" in device.raw["config"]: - self._features |= SUPPORT_PRESET_MODE - - @property - def supported_features(self): - """Return the list of supported features.""" - return self._features + if device.preset: + self._attr_supported_features |= SUPPORT_PRESET_MODE # Fan control @@ -146,11 +177,11 @@ def supported_features(self): def fan_mode(self) -> str: """Return fan operation.""" return DECONZ_TO_FAN_MODE.get( - self._device.fanmode, FAN_ON if self._device.state_on else FAN_OFF + self._device.fan_mode, FAN_ON if self._device.state_on else FAN_OFF ) @property - def fan_modes(self) -> list: + def fan_modes(self) -> list[str]: """Return the list of available fan operation modes.""" return list(FAN_MODE_TO_DECONZ) @@ -159,14 +190,12 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: if fan_mode not in FAN_MODE_TO_DECONZ: raise ValueError(f"Unsupported fan mode {fan_mode}") - data = {"fanmode": FAN_MODE_TO_DECONZ[fan_mode]} - - await self._device.async_set_config(data) + await self._device.set_config(fan_mode=FAN_MODE_TO_DECONZ[fan_mode]) # HVAC control @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode. Need to be one of HVAC_MODE_*. @@ -177,7 +206,7 @@ def hvac_mode(self): ) @property - def hvac_modes(self) -> list: + def hvac_modes(self) -> list[str]: """Return the list of available hvac operation modes.""" return list(self._hvac_mode_to_deconz) @@ -190,7 +219,7 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: if len(self._hvac_mode_to_deconz) == 2: # Only allow turn on and off thermostat data = {"on": self._hvac_mode_to_deconz[hvac_mode]} - await self._device.async_set_config(data) + await self._device.set_config(**data) # Preset control @@ -209,46 +238,43 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if preset_mode not in PRESET_MODE_TO_DECONZ: raise ValueError(f"Unsupported preset mode {preset_mode}") - data = {"preset": PRESET_MODE_TO_DECONZ[preset_mode]} - - await self._device.async_set_config(data) + await self._device.set_config(preset=PRESET_MODE_TO_DECONZ[preset_mode]) # Temperature control @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._device.temperature + return self._device.temperature # type: ignore[no-any-return] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature.""" - if self._device.mode == "cool": - return self._device.coolsetpoint - return self._device.heatsetpoint + if self._device.mode == THERMOSTAT_MODE_COOL and self._device.cooling_setpoint: + return self._device.cooling_setpoint # type: ignore[no-any-return] + + if self._device.heating_setpoint: + return self._device.heating_setpoint # type: ignore[no-any-return] - async def async_set_temperature(self, **kwargs): + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - data = {"heatsetpoint": kwargs[ATTR_TEMPERATURE] * 100} + data = {"heating_setpoint": kwargs[ATTR_TEMPERATURE] * 100} if self._device.mode == "cool": - data = {"coolsetpoint": kwargs[ATTR_TEMPERATURE] * 100} - - await self._device.async_set_config(data) + data = {"cooling_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS + await self._device.set_config(**data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | int]: """Return the state attributes of the thermostat.""" attr = {} - if self._device.offset: + if self._device.offset is not None: attr[ATTR_OFFSET] = self._device.offset if self._device.valve is not None: diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 2f15aaa50cc29..e4d61394ce0f6 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,22 +1,29 @@ """Config flow to configure deCONZ component.""" + +from __future__ import annotations + import asyncio from pprint import pformat +from typing import Any, cast from urllib.parse import urlparse import async_timeout from pydeconz.errors import RequestError, ResponseError +from pydeconz.gateway import DeconzSession from pydeconz.utils import ( - async_discovery, - async_get_api_key, - async_get_bridge_id, + discovery as deconz_discovery, + get_bridge_id as deconz_get_bridge_id, normalize_bridge_id, ) import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import ( @@ -28,7 +35,7 @@ DOMAIN, LOGGER, ) -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_MANUFACTURERURL = "http://www.dresden-elektronik.de" CONF_SERIAL = "serial" @@ -36,33 +43,36 @@ @callback -def get_master_gateway(hass): +def get_master_gateway(hass: HomeAssistant) -> DeconzGateway: """Return the gateway which is marked as master.""" for gateway in hass.data[DOMAIN].values(): if gateway.master: - return gateway + return cast(DeconzGateway, gateway) + raise ValueError -class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a deCONZ config flow.""" VERSION = 1 - _hassio_discovery = None + _hassio_discovery: dict[str, Any] @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return DeconzOptionsFlowHandler(config_entry) - def __init__(self): + def __init__(self) -> None: """Initialize the deCONZ config flow.""" - self.bridge_id = None - self.bridges = [] - self.deconz_config = {} + self.bridge_id = "" + self.bridges: list[dict[str, int | str]] = [] + self.deconz_config: dict[str, int | str] = {} - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a deCONZ config flow start. Let user choose between discovered bridges and manual configuration. @@ -75,7 +85,7 @@ async def async_step_user(self, user_input=None): for bridge in self.bridges: if bridge[CONF_HOST] == user_input[CONF_HOST]: - self.bridge_id = bridge[CONF_BRIDGE_ID] + self.bridge_id = cast(str, bridge[CONF_BRIDGE_ID]) self.deconz_config = { CONF_HOST: bridge[CONF_HOST], CONF_PORT: bridge[CONF_PORT], @@ -85,8 +95,8 @@ async def async_step_user(self, user_input=None): session = aiohttp_client.async_get_clientsession(self.hass) try: - with async_timeout.timeout(10): - self.bridges = await async_discovery(session) + async with async_timeout.timeout(10): + self.bridges = await deconz_discovery(session) except (asyncio.TimeoutError, ResponseError): self.bridges = [] @@ -108,7 +118,9 @@ async def async_step_user(self, user_input=None): return await self.async_step_manual_input() - async def async_step_manual_input(self, user_input=None): + async def async_step_manual_input( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manual configuration.""" if user_input: self.deconz_config = user_input @@ -124,9 +136,11 @@ async def async_step_manual_input(self, user_input=None): ), ) - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the deCONZ bridge.""" - errors = {} + errors: dict[str, str] = {} LOGGER.debug( "Preparing linking with deCONZ gateway %s", pformat(self.deconz_config) @@ -134,10 +148,15 @@ async def async_step_link(self, user_input=None): if user_input is not None: session = aiohttp_client.async_get_clientsession(self.hass) + deconz_session = DeconzSession( + session, + host=self.deconz_config[CONF_HOST], + port=self.deconz_config[CONF_PORT], + ) try: - with async_timeout.timeout(10): - api_key = await async_get_api_key(session, **self.deconz_config) + async with async_timeout.timeout(10): + api_key = await deconz_session.get_api_key() except (ResponseError, RequestError, asyncio.TimeoutError): errors["base"] = "no_key" @@ -148,14 +167,14 @@ async def async_step_link(self, user_input=None): return self.async_show_form(step_id="link", errors=errors) - async def _create_entry(self): + async def _create_entry(self) -> FlowResult: """Create entry for gateway.""" if not self.bridge_id: session = aiohttp_client.async_get_clientsession(self.hass) try: - with async_timeout.timeout(10): - self.bridge_id = await async_get_bridge_id( + async with async_timeout.timeout(10): + self.bridge_id = await deconz_get_bridge_id( session, **self.deconz_config ) await self.async_set_unique_id(self.bridge_id) @@ -173,7 +192,7 @@ async def _create_entry(self): return self.async_create_entry(title=self.bridge_id, data=self.deconz_config) - async def async_step_reauth(self, config: dict): + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]} @@ -184,60 +203,63 @@ async def async_step_reauth(self, config: dict): return await self.async_step_link() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered deCONZ bridge.""" if ( - discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) + discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) != DECONZ_MANUFACTURERURL ): return self.async_abort(reason="not_deconz_bridge") LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) - self.bridge_id = normalize_bridge_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + parsed_url = urlparse(discovery_info.ssdp_location) entry = await self.async_set_unique_id(self.bridge_id) if entry and entry.source == config_entries.SOURCE_HASSIO: return self.async_abort(reason="already_configured") + hostname = cast(str, parsed_url.hostname) + port = cast(int, parsed_url.port) + self._abort_if_unique_id_configured( - updates={CONF_HOST: parsed_url.hostname, CONF_PORT: parsed_url.port} + updates={CONF_HOST: hostname, CONF_PORT: port} ) - self.context["title_placeholders"] = {"host": parsed_url.hostname} + self.context["title_placeholders"] = {"host": hostname} - self.deconz_config = { - CONF_HOST: parsed_url.hostname, - CONF_PORT: parsed_url.port, - } + self.deconz_config = {CONF_HOST: hostname, CONF_PORT: port} return await self.async_step_link() - async def async_step_hassio(self, discovery_info): + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Prepare configuration for a Hass.io deCONZ bridge. This flow is triggered by the discovery component. """ - LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info)) + LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info.config)) - self.bridge_id = normalize_bridge_id(discovery_info[CONF_SERIAL]) + self.bridge_id = normalize_bridge_id(discovery_info.config[CONF_SERIAL]) await self.async_set_unique_id(self.bridge_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: discovery_info[CONF_HOST], - CONF_PORT: discovery_info[CONF_PORT], - CONF_API_KEY: discovery_info[CONF_API_KEY], + CONF_HOST: discovery_info.config[CONF_HOST], + CONF_PORT: discovery_info.config[CONF_PORT], + CONF_API_KEY: discovery_info.config[CONF_API_KEY], } ) - self._hassio_discovery = discovery_info + self._hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() - async def async_step_hassio_confirm(self, user_input=None): + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm a Hass.io discovery.""" + if user_input is not None: self.deconz_config = { CONF_HOST: self._hassio_discovery[CONF_HOST], @@ -253,21 +275,26 @@ async def async_step_hassio_confirm(self, user_input=None): ) -class DeconzOptionsFlowHandler(config_entries.OptionsFlow): +class DeconzOptionsFlowHandler(OptionsFlow): """Handle deCONZ options.""" - def __init__(self, config_entry): + gateway: DeconzGateway + + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize deCONZ options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) - self.gateway = None - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the deCONZ options.""" self.gateway = get_gateway_from_config_entry(self.hass, self.config_entry) return await self.async_step_deconz_devices() - async def async_step_deconz_devices(self, user_input=None): + async def async_step_deconz_devices( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the deconz devices options.""" if user_input is not None: self.options.update(user_input) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 799fc221e2c54..ad668934acf31 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -1,18 +1,7 @@ """Constants for the deCONZ component.""" import logging -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) @@ -32,44 +21,28 @@ CONF_MASTER_GATEWAY = "master" PLATFORMS = [ - ALARM_CONTROL_PANEL_DOMAIN, - BINARY_SENSOR_DOMAIN, - CLIMATE_DOMAIN, - COVER_DOMAIN, - FAN_DOMAIN, - LIGHT_DOMAIN, - LOCK_DOMAIN, - SCENE_DOMAIN, - SENSOR_DOMAIN, - SWITCH_DOMAIN, + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SCENE, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, ] -NEW_GROUP = "groups" -NEW_LIGHT = "lights" -NEW_SCENE = "scenes" -NEW_SENSOR = "sensors" - ATTR_DARK = "dark" ATTR_LOCKED = "locked" ATTR_OFFSET = "offset" ATTR_ON = "on" ATTR_VALVE = "valve" -# Covers -DAMPERS = ["Level controllable output"] -WINDOW_COVERS = ["Window covering device", "Window covering controller"] -COVER_TYPES = DAMPERS + WINDOW_COVERS - -# Fans -FANS = ["Fan"] - -# Locks -LOCK_TYPES = ["Door Lock", "ZHADoorLock"] - # Switches POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] -SIRENS = ["Warning device"] -SWITCH_TYPES = POWER_PLUGS + SIRENS CONF_ANGLE = "angle" CONF_GESTURE = "gesture" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 68fb9527e8710..d369678393319 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -1,9 +1,15 @@ """Support for deCONZ covers.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any, cast + +from pydeconz.light import Cover + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DEVICE_CLASS_DAMPER, - DEVICE_CLASS_SHADE, DOMAIN, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT, @@ -13,30 +19,44 @@ SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, SUPPORT_STOP_TILT, + CoverDeviceClass, CoverEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COVER_TYPES, DAMPERS, NEW_LIGHT, WINDOW_COVERS from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry +DEVICE_CLASS = { + "Level controllable output": CoverDeviceClass.DAMPER, + "Window covering controller": CoverDeviceClass.SHADE, + "Window covering device": CoverDeviceClass.SHADE, +} -async def async_setup_entry(hass, config_entry, async_add_entities): + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up covers for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_cover(lights=gateway.api.lights.values()): + def async_add_cover( + lights: list[Cover] | ValuesView[Cover] = gateway.api.lights.values(), + ) -> None: """Add cover from deCONZ.""" entities = [] for light in lights: if ( - light.type in COVER_TYPES - and light.uniqueid not in gateway.entities[DOMAIN] + isinstance(light, Cover) + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzCover(light, gateway)) @@ -45,7 +65,9 @@ def async_add_cover(lights=gateway.api.lights.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_cover + hass, + gateway.signal_new_light, + async_add_cover, ) ) @@ -56,82 +78,72 @@ class DeconzCover(DeconzDevice, CoverEntity): """Representation of a deCONZ cover.""" TYPE = DOMAIN + _device: Cover - def __init__(self, device, gateway): + def __init__(self, device: Cover, gateway: DeconzGateway) -> None: """Set up cover device.""" super().__init__(device, gateway) - self._features = SUPPORT_OPEN - self._features |= SUPPORT_CLOSE - self._features |= SUPPORT_STOP - self._features |= SUPPORT_SET_POSITION + self._attr_supported_features = SUPPORT_OPEN + self._attr_supported_features |= SUPPORT_CLOSE + self._attr_supported_features |= SUPPORT_STOP + self._attr_supported_features |= SUPPORT_SET_POSITION if self._device.tilt is not None: - self._features |= SUPPORT_OPEN_TILT - self._features |= SUPPORT_CLOSE_TILT - self._features |= SUPPORT_STOP_TILT - self._features |= SUPPORT_SET_TILT_POSITION - - @property - def supported_features(self): - """Flag supported features.""" - return self._features + self._attr_supported_features |= SUPPORT_OPEN_TILT + self._attr_supported_features |= SUPPORT_CLOSE_TILT + self._attr_supported_features |= SUPPORT_STOP_TILT + self._attr_supported_features |= SUPPORT_SET_TILT_POSITION - @property - def device_class(self): - """Return the class of the cover.""" - if self._device.type in DAMPERS: - return DEVICE_CLASS_DAMPER - if self._device.type in WINDOW_COVERS: - return DEVICE_CLASS_SHADE + self._attr_device_class = DEVICE_CLASS.get(self._device.type) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of the cover.""" - return 100 - self._device.lift + return 100 - self._device.lift # type: ignore[no-any-return] @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return not self._device.is_open - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = 100 - kwargs[ATTR_POSITION] + position = 100 - cast(int, kwargs[ATTR_POSITION]) await self._device.set_position(lift=position) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" await self._device.open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._device.close() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" await self._device.stop() @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" if self._device.tilt is not None: - return 100 - self._device.tilt + return 100 - self._device.tilt # type: ignore[no-any-return] return None - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Tilt the cover to a specific position.""" - position = 100 - kwargs[ATTR_TILT_POSITION] + position = 100 - cast(int, kwargs[ATTR_TILT_POSITION]) await self._device.set_position(tilt=position) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" await self._device.set_position(tilt=0) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" await self._device.set_position(tilt=100) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" await self._device.stop() diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index ab4d40830953c..bbd4051c177e5 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -1,8 +1,10 @@ """Base class for deCONZ devices.""" +from __future__ import annotations + from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN as DECONZ_DOMAIN @@ -18,38 +20,38 @@ def __init__(self, device, gateway): @property def unique_id(self): """Return a unique identifier for this device.""" - return self._device.uniqueid + return self._device.unique_id @property def serial(self): """Return a serial number for this device.""" - if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: + if self._device.unique_id is None or self._device.unique_id.count(":") != 7: return None - return self._device.uniqueid.split("-", 1)[0] + return self._device.unique_id.split("-", 1)[0] @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" if self.serial is None: return None - bridgeid = self.gateway.api.config.bridgeid - - return { - "connections": {(CONNECTION_ZIGBEE, self.serial)}, - "identifiers": {(DECONZ_DOMAIN, self.serial)}, - "manufacturer": self._device.manufacturer, - "model": self._device.modelid, - "name": self._device.name, - "sw_version": self._device.swversion, - "via_device": (DECONZ_DOMAIN, bridgeid), - } + return DeviceInfo( + connections={(CONNECTION_ZIGBEE, self.serial)}, + identifiers={(DECONZ_DOMAIN, self.serial)}, + manufacturer=self._device.manufacturer, + model=self._device.model_id, + name=self._device.name, + sw_version=self._device.software_version, + via_device=(DECONZ_DOMAIN, self.gateway.api.config.bridge_id), + ) class DeconzDevice(DeconzBase, Entity): """Representation of a deCONZ device.""" + _attr_should_poll = False + TYPE = "" def __init__(self, device, gateway): @@ -57,16 +59,7 @@ def __init__(self, device, gateway): super().__init__(device, gateway) self.gateway.entities[self.TYPE].add(self.unique_id) - @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry. - - Daylight is a virtual sensor from deCONZ that should never be enabled by default. - """ - if self._device.type == "Daylight": - return False - - return True + self._attr_name = self._device.name async def async_added_to_hass(self): """Subscribe to device events.""" @@ -74,7 +67,9 @@ async def async_added_to_hass(self): self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id self.async_on_remove( async_dispatcher_connect( - self.hass, self.gateway.signal_reachable, self.async_update_callback + self.hass, + self.gateway.signal_reachable, + self.async_update_connection_state, ) ) @@ -85,9 +80,14 @@ async def async_will_remove_from_hass(self) -> None: self.gateway.entities[self.TYPE].remove(self.unique_id) @callback - def async_update_callback(self, force_update=False): + def async_update_connection_state(self): + """Update the device's available state.""" + self.async_write_ha_state() + + @callback + def async_update_callback(self): """Update the device's state.""" - if not force_update and self.gateway.ignore_state_updates: + if self.gateway.ignore_state_updates: return self.async_write_ha_state() @@ -96,13 +96,3 @@ def async_update_callback(self, force_update=False): def available(self): """Return True if device is available.""" return self.gateway.available and self._device.reachable - - @property - def name(self): - """Return the name of the device.""" - return self._device.name - - @property - def should_poll(self): - """No polling needed.""" - return False diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 872dc3688c297..300aef3f82a9b 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -1,41 +1,37 @@ """Representation of a deCONZ remote or keypad.""" from pydeconz.sensor import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_DISARMED, + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, AncillaryControl, Switch, ) from homeassistant.const import ( - CONF_CODE, CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_UNIQUE_ID, CONF_XY, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, ) from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import slugify -from .const import CONF_ANGLE, CONF_GESTURE, LOGGER, NEW_SENSOR +from .const import CONF_ANGLE, CONF_GESTURE, LOGGER from .deconz_device import DeconzBase CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" -DECONZ_TO_ALARM_STATE = { - ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, - ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, +SUPPORTED_DECONZ_ALARM_EVENTS = { + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, } @@ -45,29 +41,32 @@ async def async_setup_events(gateway) -> None: @callback def async_add_sensor(sensors=gateway.api.sensors.values()): """Create DeconzEvent.""" + new_events = [] + known_events = {event.unique_id for event in gateway.events} + for sensor in sensors: if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): continue - if ( - sensor.type not in Switch.ZHATYPE + AncillaryControl.ZHATYPE - or sensor.uniqueid in {event.unique_id for event in gateway.events} - ): + if sensor.unique_id in known_events: continue - if sensor.type in Switch.ZHATYPE: - new_event = DeconzEvent(sensor, gateway) + if isinstance(sensor, Switch): + new_events.append(DeconzEvent(sensor, gateway)) - elif sensor.type in AncillaryControl.ZHATYPE: - new_event = DeconzAlarmEvent(sensor, gateway) + elif isinstance(sensor, AncillaryControl): + new_events.append(DeconzAlarmEvent(sensor, gateway)) + for new_event in new_events: gateway.hass.async_create_task(new_event.async_update_device_registry()) gateway.events.append(new_event) gateway.config_entry.async_on_unload( async_dispatcher_connect( - gateway.hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + gateway.hass, + gateway.signal_new_sensor, + async_add_sensor, ) ) @@ -111,7 +110,7 @@ def async_will_remove_from_hass(self) -> None: self._device.remove_callback(self.async_update_callback) @callback - def async_update_callback(self, force_update=False): + def async_update_callback(self): """Fire the event if reason is that state is updated.""" if ( self.gateway.ignore_state_updates @@ -144,9 +143,7 @@ async def async_update_device_registry(self) -> None: if not self.device_info: return - device_registry = ( - await self.gateway.hass.helpers.device_registry.async_get_registry() - ) + device_registry = dr.async_get(self.gateway.hass) entry = device_registry.async_get_or_create( config_entry_id=self.gateway.config_entry.entry_id, **self.device_info @@ -155,29 +152,23 @@ async def async_update_device_registry(self) -> None: class DeconzAlarmEvent(DeconzEvent): - """Alarm control panel companion event when user inputs a code.""" + """Alarm control panel companion event when user interacts with a keypad.""" @callback - def async_update_callback(self, force_update=False): - """Fire the event if reason is that state is updated.""" + def async_update_callback(self): + """Fire the event if reason is new action is updated.""" if ( self.gateway.ignore_state_updates or "action" not in self._device.changed_keys - or self._device.action == "" + or self._device.action not in SUPPORTED_DECONZ_ALARM_EVENTS ): return - state, code, _area = self._device.action.split(",") - - if state not in DECONZ_TO_ALARM_STATE: - return - data = { CONF_ID: self.event_id, CONF_UNIQUE_ID: self.serial, CONF_DEVICE_ID: self.device_id, - CONF_EVENT: DECONZ_TO_ALARM_STATE[state], - CONF_CODE: code, + CONF_EVENT: self._device.action, } self.gateway.hass.bus.async_fire(CONF_DECONZ_ALARM_EVENT, data) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 2703adbc13952..ae539ee5d48c8 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -1,7 +1,10 @@ """Provides device automations for deconz events.""" + +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -14,9 +17,11 @@ CONF_TYPE, CONF_UNIQUE_ID, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import DOMAIN -from .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE +from .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE, DeconzAlarmEvent, DeconzEvent CONF_SUBTYPE = "subtype" @@ -205,6 +210,13 @@ (CONF_LONG_RELEASE, CONF_RIGHT): {CONF_EVENT: 5003}, } +TRADFRI_SHORTCUT_REMOTE_MODEL = "TRADFRI SHORTCUT Button" +TRADFRI_SHORTCUT_REMOTE = { + (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, ""): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, ""): {CONF_EVENT: 1003}, +} + TRADFRI_WIRELESS_DIMMER_MODEL = "TRADFRI wireless dimmer" TRADFRI_WIRELESS_DIMMER = { (CONF_ROTATED_FAST, CONF_LEFT): {CONF_EVENT: 4002}, @@ -443,6 +455,25 @@ (CONF_SHORT_RELEASE, CONF_BUTTON_8): {CONF_EVENT: 8002}, } +LEGRAND_ZGP_TOGGLE_SWITCH_MODEL = "LEGRANDZGPTOGGLESWITCH" +LEGRAND_ZGP_TOGGLE_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, +} + +LEGRAND_ZGP_SCENE_SWITCH_MODEL = "LEGRANDZGPSCENESWITCH" +LEGRAND_ZGP_SCENE_SWITCH = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 2002}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 3002}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 4002}, +} + +LIDL_SILVERCREST_DOORBELL_MODEL = "HG06668" +LIDL_SILVERCREST_DOORBELL = { + (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, +} + LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL = "Switch-LIGHTIFY" LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL = "Switch 4x-LIGHTIFY" LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_EU_MODEL = "Switch 4x EU-LIGHTIFY" @@ -490,6 +521,14 @@ (CONF_LONG_RELEASE, CONF_BUTTON_8): {CONF_EVENT: 8003}, } +SONOFF_SNZB_01_1_MODEL = "WB01" +SONOFF_SNZB_01_2_MODEL = "WB-01" +SONOFF_SNZB_01_SWITCH = { + (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1003}, + (CONF_DOUBLE_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1004}, +} + TRUST_ZYCT_202_MODEL = "ZYCT-202" TRUST_ZYCT_202_ZLL_MODEL = "ZLL-NonColorController" TRUST_ZYCT_202 = { @@ -534,6 +573,7 @@ TRADFRI_ON_OFF_SWITCH_MODEL: TRADFRI_ON_OFF_SWITCH, TRADFRI_OPEN_CLOSE_REMOTE_MODEL: TRADFRI_OPEN_CLOSE_REMOTE, TRADFRI_REMOTE_MODEL: TRADFRI_REMOTE, + TRADFRI_SHORTCUT_REMOTE_MODEL: TRADFRI_SHORTCUT_REMOTE, TRADFRI_WIRELESS_DIMMER_MODEL: TRADFRI_WIRELESS_DIMMER, AQARA_CUBE_MODEL: AQARA_CUBE, AQARA_CUBE_MODEL_ALT1: AQARA_CUBE, @@ -553,9 +593,12 @@ AQARA_OPPLE_6_BUTTONS_MODEL: AQARA_OPPLE_6_BUTTONS, DRESDEN_ELEKTRONIK_LIGHTING_SWITCH_MODEL: DRESDEN_ELEKTRONIK_LIGHTING_SWITCH, DRESDEN_ELEKTRONIK_SCENE_SWITCH_MODEL: DRESDEN_ELEKTRONIK_SCENE_SWITCH, - GIRA_JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH_MODEL, - GIRA_SWITCH_MODEL: GIRA_JUNG_SWITCH_MODEL, - JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH_MODEL, + GIRA_JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH, + GIRA_SWITCH_MODEL: GIRA_JUNG_SWITCH, + JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH, + LEGRAND_ZGP_TOGGLE_SWITCH_MODEL: LEGRAND_ZGP_TOGGLE_SWITCH, + LEGRAND_ZGP_SCENE_SWITCH_MODEL: LEGRAND_ZGP_SCENE_SWITCH, + LIDL_SILVERCREST_DOORBELL_MODEL: LIDL_SILVERCREST_DOORBELL, LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_EU_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, @@ -565,30 +608,35 @@ TRUST_ZYCT_202_ZLL_MODEL: TRUST_ZYCT_202, UBISYS_POWER_SWITCH_S2_MODEL: UBISYS_POWER_SWITCH_S2, UBISYS_CONTROL_UNIT_C4_MODEL: UBISYS_CONTROL_UNIT_C4, + SONOFF_SNZB_01_1_MODEL: SONOFF_SNZB_01_SWITCH, + SONOFF_SNZB_01_2_MODEL: SONOFF_SNZB_01_SWITCH, } -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} ) -def _get_deconz_event_from_device_id(hass, device_id): - """Resolve deconz event from device id.""" +def _get_deconz_event_from_device( + hass: HomeAssistant, + device: dr.DeviceEntry, +) -> DeconzAlarmEvent | DeconzEvent: + """Resolve deconz event from device.""" for gateway in hass.data.get(DOMAIN, {}).values(): - for deconz_event in gateway.events: - - if device_id == deconz_event.device_id: + if device.id == deconz_event.device_id: return deconz_event - return None + raise InvalidDeviceAutomationConfig( + f'No deconz_event tied to device "{device.name}" found' + ) async def async_validate_trigger_config(hass, config): """Validate config.""" config = TRIGGER_SCHEMA(config) - device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(hass) device = device_registry.async_get(config[CONF_DEVICE_ID]) trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) @@ -610,18 +658,14 @@ async def async_validate_trigger_config(hass, config): async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(hass) device = device_registry.async_get(config[CONF_DEVICE_ID]) trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) trigger = REMOTES[device.model][trigger] - deconz_event = _get_deconz_event_from_device_id(hass, device.id) - if deconz_event is None: - raise InvalidDeviceAutomationConfig( - f'No deconz_event tied to device "{device.name}" found' - ) + deconz_event = _get_deconz_event_from_device(hass, device) event_id = deconz_event.serial @@ -644,7 +688,7 @@ async def async_get_triggers(hass, device_id): Retrieve the deconz event object matching device entry. Generate device trigger list. """ - device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(hass) device = device_registry.async_get(device_id) if device.model not in REMOTES: diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index dfb6802fd758d..d1ff85f9d6571 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -1,6 +1,18 @@ """Support for deCONZ fans.""" from __future__ import annotations +from collections.abc import ValuesView +from typing import Any + +from pydeconz.light import ( + FAN_SPEED_25_PERCENT, + FAN_SPEED_50_PERCENT, + FAN_SPEED_75_PERCENT, + FAN_SPEED_100_PERCENT, + FAN_SPEED_OFF, + Fan, +) + from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, @@ -10,36 +22,61 @@ SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, ) -from .const import FANS, NEW_LIGHT from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry - -ORDERED_NAMED_FAN_SPEEDS = [1, 2, 3, 4] - -LEGACY_SPEED_TO_DECONZ = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 2, SPEED_HIGH: 4} -LEGACY_DECONZ_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 4: SPEED_HIGH} - - -async def async_setup_entry(hass, config_entry, async_add_entities) -> None: +from .gateway import DeconzGateway, get_gateway_from_config_entry + +ORDERED_NAMED_FAN_SPEEDS = [ + FAN_SPEED_25_PERCENT, + FAN_SPEED_50_PERCENT, + FAN_SPEED_75_PERCENT, + FAN_SPEED_100_PERCENT, +] + +LEGACY_SPEED_TO_DECONZ = { + SPEED_OFF: FAN_SPEED_OFF, + SPEED_LOW: FAN_SPEED_25_PERCENT, + SPEED_MEDIUM: FAN_SPEED_50_PERCENT, + SPEED_HIGH: FAN_SPEED_100_PERCENT, +} +LEGACY_DECONZ_TO_SPEED = { + FAN_SPEED_OFF: SPEED_OFF, + FAN_SPEED_25_PERCENT: SPEED_LOW, + FAN_SPEED_50_PERCENT: SPEED_MEDIUM, + FAN_SPEED_100_PERCENT: SPEED_HIGH, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up fans for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_fan(lights=gateway.api.lights.values()) -> None: + def async_add_fan( + lights: list[Fan] | ValuesView[Fan] = gateway.api.lights.values(), + ) -> None: """Add fan from deCONZ.""" entities = [] for light in lights: - if light.type in FANS and light.uniqueid not in gateway.entities[DOMAIN]: + if ( + isinstance(light, Fan) + and light.unique_id not in gateway.entities[DOMAIN] + ): entities.append(DeconzFan(light, gateway)) if entities: @@ -47,7 +84,9 @@ def async_add_fan(lights=gateway.api.lights.values()) -> None: config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_fan + hass, + gateway.signal_new_light, + async_add_fan, ) ) @@ -58,26 +97,27 @@ class DeconzFan(DeconzDevice, FanEntity): """Representation of a deCONZ fan.""" TYPE = DOMAIN + _device: Fan + + _attr_supported_features = SUPPORT_SET_SPEED - def __init__(self, device, gateway) -> None: + def __init__(self, device: Fan, gateway: DeconzGateway) -> None: """Set up fan.""" super().__init__(device, gateway) - self._default_on_speed = 2 + self._default_on_speed = FAN_SPEED_50_PERCENT if self._device.speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = self._device.speed - self._features = SUPPORT_SET_SPEED - @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.speed != 0 + return self._device.speed != FAN_SPEED_OFF # type: ignore[no-any-return] @property def percentage(self) -> int | None: """Return the current speed percentage.""" - if self._device.speed == 0: + if self._device.speed == FAN_SPEED_OFF: return 0 if self._device.speed not in ORDERED_NAMED_FAN_SPEEDS: return None @@ -125,17 +165,12 @@ def percentage_to_speed(self, percentage: int) -> str: SPEED_MEDIUM, ) - @property - def supported_features(self) -> int: - """Flag supported features.""" - return self._features - @callback - def async_update_callback(self, force_update=False) -> None: + def async_update_callback(self) -> None: """Store latest configured speed from the device.""" if self._device.speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = self._device.speed - super().async_update_callback(force_update) + super().async_update_callback() async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -155,10 +190,10 @@ async def async_set_speed(self, speed: str) -> None: async def async_turn_on( self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, - **kwargs, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on fan.""" new_speed = self._default_on_speed @@ -170,6 +205,6 @@ async def async_turn_on( await self._device.set_speed(new_speed) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off fan.""" - await self._device.set_speed(0) + await self._device.set_speed(FAN_SPEED_OFF) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 8b057ab9e51f3..8742cd087d4d2 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -2,12 +2,17 @@ import asyncio import async_timeout -from pydeconz import DeconzSession, errors +from pydeconz import DeconzSession, errors, group, light, sensor +from homeassistant.config_entries import SOURCE_HASSIO from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -21,10 +26,6 @@ DEFAULT_ALLOW_NEW_DEVICES, DOMAIN as DECONZ_DOMAIN, LOGGER, - NEW_GROUP, - NEW_LIGHT, - NEW_SCENE, - NEW_SENSOR, PLATFORMS, ) from .deconz_event import async_setup_events, async_unload_events @@ -33,8 +34,8 @@ @callback def get_gateway_from_config_entry(hass, config_entry): - """Return gateway with a matching bridge id.""" - return hass.data[DECONZ_DOMAIN][config_entry.unique_id] + """Return gateway with a matching config entry ID.""" + return hass.data[DECONZ_DOMAIN][config_entry.entry_id] class DeconzGateway: @@ -50,6 +51,20 @@ def __init__(self, hass, config_entry) -> None: self.available = True self.ignore_state_updates = False + self.signal_reachable = f"deconz-reachable-{config_entry.entry_id}" + + self.signal_new_group = f"deconz_new_group_{config_entry.entry_id}" + self.signal_new_light = f"deconz_new_light_{config_entry.entry_id}" + self.signal_new_scene = f"deconz_new_scene_{config_entry.entry_id}" + self.signal_new_sensor = f"deconz_new_sensor_{config_entry.entry_id}" + + self.deconz_resource_type_to_signal_new_device = { + group.RESOURCE_TYPE: self.signal_new_group, + light.RESOURCE_TYPE: self.signal_new_light, + group.RESOURCE_TYPE_SCENE: self.signal_new_scene, + sensor.RESOURCE_TYPE: self.signal_new_sensor, + } + self.deconz_ids = {} self.entities = {} self.events = [] @@ -92,24 +107,6 @@ def option_allow_new_devices(self) -> bool: CONF_ALLOW_NEW_DEVICES, DEFAULT_ALLOW_NEW_DEVICES ) - # Signals - - @property - def signal_reachable(self) -> str: - """Gateway specific event to signal a change in connection status.""" - return f"deconz-reachable-{self.bridgeid}" - - @callback - def async_signal_new_device(self, device_type) -> str: - """Gateway specific event to signal new device.""" - new_device = { - NEW_GROUP: f"deconz_new_group_{self.bridgeid}", - NEW_LIGHT: f"deconz_new_light_{self.bridgeid}", - NEW_SCENE: f"deconz_new_scene_{self.bridgeid}", - NEW_SENSOR: f"deconz_new_sensor_{self.bridgeid}", - } - return new_device[device_type] - # Callbacks @callback @@ -117,14 +114,18 @@ def async_connection_status_callback(self, available) -> None: """Handle signals of gateway connection status.""" self.available = available self.ignore_state_updates = False - async_dispatcher_send(self.hass, self.signal_reachable, True) + async_dispatcher_send(self.hass, self.signal_reachable) @callback def async_add_device_callback( - self, device_type, device=None, force: bool = False + self, resource_type, device=None, force: bool = False ) -> None: """Handle event of new device creation in deCONZ.""" - if not force and not self.option_allow_new_devices: + if ( + not force + and not self.option_allow_new_devices + or resource_type not in self.deconz_resource_type_to_signal_new_device + ): return args = [] @@ -134,13 +135,13 @@ def async_add_device_callback( async_dispatcher_send( self.hass, - self.async_signal_new_device(device_type), + self.deconz_resource_type_to_signal_new_device[resource_type], *args, # Don't send device if None, it would override default value in listeners ) async def async_update_device_registry(self) -> None: """Update device registry.""" - device_registry = await self.hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(self.hass) # Host device device_registry.async_get_or_create( @@ -149,13 +150,18 @@ async def async_update_device_registry(self) -> None: ) # Gateway service + configuration_url = f"http://{self.host}:{self.config_entry.data[CONF_PORT]}" + if self.config_entry.source == SOURCE_HASSIO: + configuration_url = "homeassistant://hassio/ingress/core_deconz" device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, - identifiers={(DECONZ_DOMAIN, self.api.config.bridgeid)}, + configuration_url=configuration_url, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", - model=self.api.config.modelid, + model=self.api.config.model_id, name=self.api.config.name, - sw_version=self.api.config.swversion, + sw_version=self.api.config.software_version, via_device=(CONNECTION_NETWORK_MAC, self.api.config.mac), ) @@ -207,7 +213,7 @@ async def options_updated(self): deconz_ids = [] if self.option_allow_clip_sensor: - self.async_add_device_callback(NEW_SENSOR) + self.async_add_device_callback(sensor.RESOURCE_TYPE) else: deconz_ids += [ @@ -217,12 +223,12 @@ async def options_updated(self): ] if self.option_allow_deconz_groups: - self.async_add_device_callback(NEW_GROUP) + self.async_add_device_callback(group.RESOURCE_TYPE) else: deconz_ids += [group.deconz_id for group in self.api.groups.values()] - entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + entity_registry = er.async_get(self.hass) for entity_id, deconz_id in self.deconz_ids.items(): if deconz_id in deconz_ids and entity_registry.async_is_registered( @@ -266,12 +272,12 @@ async def get_gateway( config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], - async_add_device=async_add_device_callback, + add_device=async_add_device_callback, connection_status=async_connection_status_callback, ) try: - with async_timeout.timeout(10): - await deconz.initialize() + async with async_timeout.timeout(10): + await deconz.refresh_state() return deconz except errors.Unauthorized as err: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 838e7639fc76f..5330fdb32261e 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -1,6 +1,18 @@ """Support for deCONZ lights.""" -from pydeconz.light import Light +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + +from pydeconz.group import DeconzGroup as Group +from pydeconz.light import ( + ALERT_LONG, + ALERT_SHORT, + EFFECT_COLOR_LOOP, + EFFECT_NONE, + Light, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -9,52 +21,58 @@ ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + COLOR_MODE_XY, DOMAIN, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, LightEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -import homeassistant.util.color as color_util - -from .const import ( - COVER_TYPES, - DOMAIN as DECONZ_DOMAIN, - LOCK_TYPES, - NEW_GROUP, - NEW_LIGHT, - SWITCH_TYPES, -) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import color_hs_to_xy + +from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry -CONTROLLER = ["Configuration tool"] +DECONZ_GROUP = "is_deconz_group" +EFFECT_TO_DECONZ = {EFFECT_COLORLOOP: EFFECT_COLOR_LOOP, "None": EFFECT_NONE} +FLASH_TO_DECONZ = {FLASH_SHORT: ALERT_SHORT, FLASH_LONG: ALERT_LONG} -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ lights and groups from a config entry.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - other_light_resource_types = CONTROLLER + COVER_TYPES + LOCK_TYPES + SWITCH_TYPES - @callback - def async_add_light(lights=gateway.api.lights.values()): + def async_add_light( + lights: list[Light] | ValuesView[Light] = gateway.api.lights.values(), + ) -> None: """Add light from deCONZ.""" entities = [] for light in lights: if ( - light.type not in other_light_resource_types - and light.uniqueid not in gateway.entities[DOMAIN] + isinstance(light, Light) + and light.type not in POWER_PLUGS + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLight(light, gateway)) @@ -63,12 +81,16 @@ def async_add_light(lights=gateway.api.lights.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_light + hass, + gateway.signal_new_light, + async_add_light, ) ) @callback - def async_add_group(groups=gateway.api.groups.values()): + def async_add_group( + groups: list[Group] | ValuesView[Group] = gateway.api.groups.values(), + ) -> None: """Add group from deCONZ.""" if not gateway.option_allow_deconz_groups: return @@ -89,7 +111,9 @@ def async_add_group(groups=gateway.api.groups.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_GROUP), async_add_group + hass, + gateway.signal_new_group, + async_add_group, ) ) @@ -102,180 +126,176 @@ class DeconzBaseLight(DeconzDevice, LightEntity): TYPE = DOMAIN - def __init__(self, device, gateway): + def __init__(self, device: Group | Light, gateway: DeconzGateway) -> None: """Set up light.""" super().__init__(device, gateway) - self._features = 0 - self.update_features(self._device) + self._attr_supported_color_modes: set[str] = set() - def update_features(self, device): - """Calculate supported features of device.""" - if device.brightness is not None: - self._features |= SUPPORT_BRIGHTNESS - self._features |= SUPPORT_FLASH - self._features |= SUPPORT_TRANSITION + if device.color_temp is not None: + self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + + if device.hue is not None and device.saturation is not None: + self._attr_supported_color_modes.add(COLOR_MODE_HS) - if device.ct is not None: - self._features |= SUPPORT_COLOR_TEMP + if device.xy is not None: + self._attr_supported_color_modes.add(COLOR_MODE_XY) - if device.xy is not None or (device.hue is not None and device.sat is not None): - self._features |= SUPPORT_COLOR + if not self._attr_supported_color_modes and device.brightness is not None: + self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + + if not self._attr_supported_color_modes: + self._attr_supported_color_modes.add(COLOR_MODE_ONOFF) + + if device.brightness is not None: + self._attr_supported_features |= SUPPORT_FLASH + self._attr_supported_features |= SUPPORT_TRANSITION if device.effect is not None: - self._features |= SUPPORT_EFFECT + self._attr_supported_features |= SUPPORT_EFFECT + self._attr_effect_list = [EFFECT_COLORLOOP] @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._device.brightness + def color_mode(self) -> str: + """Return the color mode of the light.""" + if self._device.color_mode == "ct": + color_mode = COLOR_MODE_COLOR_TEMP + elif self._device.color_mode == "hs": + color_mode = COLOR_MODE_HS + elif self._device.color_mode == "xy": + color_mode = COLOR_MODE_XY + elif self._device.brightness is not None: + color_mode = COLOR_MODE_BRIGHTNESS + else: + color_mode = COLOR_MODE_ONOFF + return color_mode @property - def effect_list(self): - """Return the list of supported effects.""" - return [EFFECT_COLORLOOP] + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + return self._device.brightness # type: ignore[no-any-return] @property - def color_temp(self): + def color_temp(self) -> int: """Return the CT color value.""" - if self._device.colormode != "ct": - return None - - return self._device.ct + return self._device.color_temp # type: ignore[no-any-return] @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs color value.""" - if self._device.colormode in ("xy", "hs"): - if self._device.xy: - return color_util.color_xy_to_hs(*self._device.xy) - if self._device.hue and self._device.sat: - return (self._device.hue / 65535 * 360, self._device.sat / 255 * 100) - return None + return (self._device.hue / 65535 * 360, self._device.saturation / 255 * 100) @property - def is_on(self): - """Return true if light is on.""" - return self._device.state + def xy_color(self) -> tuple[float, float] | None: + """Return the XY color value.""" + return self._device.xy # type: ignore[no-any-return] @property - def supported_features(self): - """Flag supported features.""" - return self._features + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.state # type: ignore[no-any-return] - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" - data = {"on": True} + data: dict[str, bool | float | int | str | tuple[float, float]] = {"on": True} + + if (attr_brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: + data["brightness"] = attr_brightness - if ATTR_COLOR_TEMP in kwargs: - data["ct"] = kwargs[ATTR_COLOR_TEMP] + if attr_color_temp := kwargs.get(ATTR_COLOR_TEMP): + data["color_temperature"] = attr_color_temp - if ATTR_HS_COLOR in kwargs: - if self._device.xy is not None: - data["xy"] = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + if attr_hs_color := kwargs.get(ATTR_HS_COLOR): + if COLOR_MODE_XY in self._attr_supported_color_modes: + data["xy"] = color_hs_to_xy(*attr_hs_color) else: - data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + data["hue"] = int(attr_hs_color[0] / 360 * 65535) + data["saturation"] = int(attr_hs_color[1] / 100 * 255) - if ATTR_BRIGHTNESS in kwargs: - data["bri"] = kwargs[ATTR_BRIGHTNESS] + if ATTR_XY_COLOR in kwargs: + data["xy"] = kwargs[ATTR_XY_COLOR] - if ATTR_TRANSITION in kwargs: - data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + if (attr_transition := kwargs.get(ATTR_TRANSITION)) is not None: + data["transition_time"] = int(attr_transition * 10) elif "IKEA" in self._device.manufacturer: - data["transitiontime"] = 0 - - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_SHORT: - data["alert"] = "select" - del data["on"] - elif kwargs[ATTR_FLASH] == FLASH_LONG: - data["alert"] = "lselect" - del data["on"] - - if ATTR_EFFECT in kwargs: - if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: - data["effect"] = "colorloop" - else: - data["effect"] = "none" + data["transition_time"] = 0 + + if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH))) is not None: + data["alert"] = alert + del data["on"] + + if (effect := EFFECT_TO_DECONZ.get(kwargs.get(ATTR_EFFECT))) is not None: + data["effect"] = effect - await self._device.async_set_state(data) + await self._device.set_state(**data) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" if not self._device.state: return - data = {"on": False} + data: dict[str, bool | int | str] = {"on": False} - if ATTR_TRANSITION in kwargs: - data["bri"] = 0 - data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + if (attr_transition := kwargs.get(ATTR_TRANSITION)) is not None: + data["brightness"] = 0 + data["transition_time"] = int(attr_transition * 10) - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_SHORT: - data["alert"] = "select" - del data["on"] - elif kwargs[ATTR_FLASH] == FLASH_LONG: - data["alert"] = "lselect" - del data["on"] + if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH))) is not None: + data["alert"] = alert + del data["on"] - await self._device.async_set_state(data) + await self._device.set_state(**data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" - return {"is_deconz_group": self._device.type == "LightGroup"} + return {DECONZ_GROUP: isinstance(self._device, Group)} class DeconzLight(DeconzBaseLight): """Representation of a deCONZ light.""" + _device: Light + @property - def max_mireds(self): + def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" - return self._device.ctmax or super().max_mireds + return self._device.max_color_temp or super().max_mireds @property - def min_mireds(self): + def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" - return self._device.ctmin or super().min_mireds + return self._device.min_color_temp or super().min_mireds class DeconzGroup(DeconzBaseLight): """Representation of a deCONZ group.""" - def __init__(self, device, gateway): + _device: Group + + def __init__(self, device: Group, gateway: DeconzGateway) -> None: """Set up group and create an unique id.""" self._unique_id = f"{gateway.bridgeid}-{device.deconz_id}" - super().__init__(device, gateway) - for light_id in device.lights: - light = gateway.api.lights[light_id] - if light.ZHATYPE == Light.ZHATYPE: - self.update_features(light) - @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this device.""" return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" - bridgeid = self.gateway.api.config.bridgeid - - return { - "identifiers": {(DECONZ_DOMAIN, self.unique_id)}, - "manufacturer": "Dresden Elektronik", - "model": "deCONZ group", - "name": self._device.name, - "via_device": (DECONZ_DOMAIN, bridgeid), - } + return DeviceInfo( + identifiers={(DECONZ_DOMAIN, self.unique_id)}, + manufacturer="Dresden Elektronik", + model="deCONZ group", + name=self._device.name, + via_device=(DECONZ_DOMAIN, self.gateway.api.config.bridge_id), + ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" attributes = dict(super().extra_state_attributes) attributes["all_on"] = self._device.all_on diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 75f6bc872dbc6..7bdae3e36edae 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -1,28 +1,44 @@ """Support for deCONZ locks.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + +from pydeconz.light import Lock +from pydeconz.sensor import DoorLock + from homeassistant.components.lock import DOMAIN, LockEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import LOCK_TYPES, NEW_LIGHT, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up locks for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_lock_from_light(lights=gateway.api.lights.values()): + def async_add_lock_from_light( + lights: list[Lock] | ValuesView[Lock] = gateway.api.lights.values(), + ) -> None: """Add lock from deCONZ.""" entities = [] for light in lights: if ( - light.type in LOCK_TYPES - and light.uniqueid not in gateway.entities[DOMAIN] + isinstance(light, Lock) + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(light, gateway)) @@ -31,20 +47,24 @@ def async_add_lock_from_light(lights=gateway.api.lights.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_lock_from_light + hass, + gateway.signal_new_light, + async_add_lock_from_light, ) ) @callback - def async_add_lock_from_sensor(sensors=gateway.api.sensors.values()): + def async_add_lock_from_sensor( + sensors: list[DoorLock] | ValuesView[DoorLock] = gateway.api.sensors.values(), + ) -> None: """Add lock from deCONZ.""" entities = [] for sensor in sensors: if ( - sensor.type in LOCK_TYPES - and sensor.uniqueid not in gateway.entities[DOMAIN] + isinstance(sensor, DoorLock) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(sensor, gateway)) @@ -54,7 +74,7 @@ def async_add_lock_from_sensor(sensors=gateway.api.sensors.values()): config_entry.async_on_unload( async_dispatcher_connect( hass, - gateway.async_signal_new_device(NEW_SENSOR), + gateway.signal_new_sensor, async_add_lock_from_sensor, ) ) @@ -67,16 +87,17 @@ class DeconzLock(DeconzDevice, LockEntity): """Representation of a deCONZ lock.""" TYPE = DOMAIN + _device: DoorLock | Lock @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is on.""" - return self._device.is_locked + return self._device.is_locked # type: ignore[no-any-return] - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self._device.lock() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self._device.unlock() diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index b36e06c0cf6dd..1c41feda7dafe 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -1,19 +1,15 @@ """Describe deCONZ logbook events.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.event import Event from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN -from .deconz_event import ( - CONF_DECONZ_ALARM_EVENT, - CONF_DECONZ_EVENT, - DeconzAlarmEvent, - DeconzEvent, -) +from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT from .device_trigger import ( CONF_BOTH_BUTTONS, CONF_BOTTOM_BUTTONS, @@ -57,7 +53,7 @@ CONF_TURN_OFF, CONF_TURN_ON, REMOTES, - _get_deconz_event_from_device_id, + _get_deconz_event_from_device, ) ACTIONS = { @@ -108,9 +104,11 @@ } -def _get_device_event_description(modelid: str, event: str) -> tuple: +def _get_device_event_description( + modelid: str, event: int +) -> tuple[str | None, str | None]: """Get device event description.""" - device_event_descriptions: dict = REMOTES[modelid] + device_event_descriptions = REMOTES[modelid] for event_type_tuple, event_dict in device_event_descriptions.items(): if event == event_dict.get(CONF_EVENT): @@ -124,16 +122,16 @@ def _get_device_event_description(modelid: str, event: str) -> tuple: @callback def async_describe_events( hass: HomeAssistant, - async_describe_event: Callable[[str, str, Callable[[Event], dict]], None], + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], ) -> None: """Describe logbook events.""" + device_registry = dr.async_get(hass) @callback - def async_describe_deconz_alarm_event(event: Event) -> dict: + def async_describe_deconz_alarm_event(event: Event) -> dict[str, str]: """Describe deCONZ logbook alarm event.""" - deconz_alarm_event: DeconzAlarmEvent | None = _get_deconz_event_from_device_id( - hass, event.data[ATTR_DEVICE_ID] - ) + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + deconz_alarm_event = _get_deconz_event_from_device(hass, device) data = event.data[CONF_EVENT] @@ -143,19 +141,18 @@ def async_describe_deconz_alarm_event(event: Event) -> dict: } @callback - def async_describe_deconz_event(event: Event) -> dict: + def async_describe_deconz_event(event: Event) -> dict[str, str]: """Describe deCONZ logbook event.""" - deconz_event: DeconzEvent | None = _get_deconz_event_from_device_id( - hass, event.data[ATTR_DEVICE_ID] - ) + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + deconz_event = _get_deconz_event_from_device(hass, device) action = None interface = None data = event.data.get(CONF_EVENT) or event.data.get(CONF_GESTURE, "") - if data and deconz_event.device.modelid in REMOTES: + if data and deconz_event.device.model_id in REMOTES: action, interface = _get_device_event_description( - deconz_event.device.modelid, data + deconz_event.device.model_id, data ) # Unknown event diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index c4dfd0d4dfce4..68b89b70b9cb9 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,13 +3,17 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==79"], + "requirements": [ + "pydeconz==85" + ], "ssdp": [ { "manufacturer": "Royal Philips Electronics" } ], - "codeowners": ["@Kane610"], + "codeowners": [ + "@Kane610" + ], "quality_scale": "platinum", "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py new file mode 100644 index 0000000000000..fff70b9f7b5e6 --- /dev/null +++ b/homeassistant/components/deconz/number.py @@ -0,0 +1,143 @@ +"""Support for configuring different deCONZ sensors.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from dataclasses import dataclass + +from pydeconz.sensor import PRESENCE_DELAY, Presence + +from homeassistant.components.number import ( + DOMAIN, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .deconz_device import DeconzDevice +from .gateway import DeconzGateway, get_gateway_from_config_entry + + +@dataclass +class DeconzNumberEntityDescriptionBase: + """Required values when describing deCONZ number entities.""" + + device_property: str + suffix: str + update_key: str + + +@dataclass +class DeconzNumberEntityDescription( + NumberEntityDescription, DeconzNumberEntityDescriptionBase +): + """Class describing deCONZ number entities.""" + + entity_category = EntityCategory.CONFIG + + +ENTITY_DESCRIPTIONS = { + Presence: [ + DeconzNumberEntityDescription( + key="delay", + device_property="delay", + suffix="Delay", + update_key=PRESENCE_DELAY, + max_value=65535, + min_value=0, + step=1, + ) + ] +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the deCONZ number entity.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_sensor( + sensors: list[Presence] | ValuesView[Presence] = gateway.api.sensors.values(), + ) -> None: + """Add number config sensor from deCONZ.""" + entities = [] + + for sensor in sensors: + + if sensor.type.startswith("CLIP"): + continue + + known_number_entities = set(gateway.entities[DOMAIN]) + for description in ENTITY_DESCRIPTIONS.get(type(sensor), []): + + if getattr(sensor, description.device_property) is None: + continue + + new_number_entity = DeconzNumber(sensor, gateway, description) + if new_number_entity.unique_id not in known_number_entities: + entities.append(new_number_entity) + + if entities: + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + gateway.signal_new_sensor, + async_add_sensor, + ) + ) + + async_add_sensor( + [gateway.api.sensors[key] for key in sorted(gateway.api.sensors, key=int)] + ) + + +class DeconzNumber(DeconzDevice, NumberEntity): + """Representation of a deCONZ number entity.""" + + TYPE = DOMAIN + _device: Presence + + def __init__( + self, + device: Presence, + gateway: DeconzGateway, + description: DeconzNumberEntityDescription, + ) -> None: + """Initialize deCONZ number entity.""" + self.entity_description: DeconzNumberEntityDescription = description + super().__init__(device, gateway) + + self._attr_name = f"{device.name} {description.suffix}" + + @callback + def async_update_callback(self) -> None: + """Update the number value.""" + keys = {self.entity_description.update_key, "reachable"} + if self._device.changed_keys.intersection(keys): + super().async_update_callback() + + @property + def value(self) -> float: + """Return the value of the sensor property.""" + return getattr(self._device, self.entity_description.device_property) # type: ignore[no-any-return] + + async def async_set_value(self, value: float) -> None: + """Set sensor config.""" + data = {self.entity_description.device_property: int(value)} + await self._device.set_config(**data) + + @property + def unique_id(self) -> str: + """Return a unique identifier for this entity.""" + return f"{self.serial}-{self.entity_description.suffix.lower()}" diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index ecd363f121a23..3d8e1aa27ba3c 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -1,20 +1,34 @@ """Support for deCONZ scenes.""" + +from __future__ import annotations + +from collections.abc import ValuesView from typing import Any +from pydeconz.group import DeconzScene as PydeconzScene + from homeassistant.components.scene import Scene -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEW_SCENE -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up scenes for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) @callback - def async_add_scene(scenes=gateway.api.scenes.values()): + def async_add_scene( + scenes: list[PydeconzScene] + | ValuesView[PydeconzScene] = gateway.api.scenes.values(), + ) -> None: """Add scene from deCONZ.""" entities = [DeconzScene(scene, gateway) for scene in scenes] @@ -23,7 +37,9 @@ def async_add_scene(scenes=gateway.api.scenes.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_SCENE), async_add_scene + hass, + gateway.signal_new_scene, + async_add_scene, ) ) @@ -33,12 +49,14 @@ def async_add_scene(scenes=gateway.api.scenes.values()): class DeconzScene(Scene): """Representation of a deCONZ scene.""" - def __init__(self, scene, gateway): + def __init__(self, scene: PydeconzScene, gateway: DeconzGateway) -> None: """Set up a scene.""" self._scene = scene self.gateway = gateway - async def async_added_to_hass(self): + self._attr_name = scene.full_name + + async def async_added_to_hass(self) -> None: """Subscribe to sensors events.""" self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id @@ -49,9 +67,4 @@ async def async_will_remove_from_hass(self) -> None: async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" - await self._scene.async_set_state({}) - - @property - def name(self): - """Return the name of the scene.""" - return self._scene.full_name + await self._scene.recall() diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index ba3be37da4205..c74867df5bbdd 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,10 +1,15 @@ """Support for deCONZ sensors.""" +from __future__ import annotations + +from collections.abc import ValuesView + from pydeconz.sensor import ( - AncillaryControl, + AirQuality, Battery, Consumption, Daylight, - DoorLock, + DeconzSensor as PydeconzSensor, + GenericStatus, Humidity, LightLevel, Power, @@ -12,18 +17,20 @@ Switch, Temperature, Thermostat, + Time, ) -from homeassistant.components.sensor import DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, @@ -31,46 +38,93 @@ PRESSURE_HPA, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR +from .const import ATTR_DARK, ATTR_ON from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry + +DECONZ_SENSORS = ( + AirQuality, + Consumption, + Daylight, + GenericStatus, + Humidity, + LightLevel, + Power, + Pressure, + Temperature, + Time, +) ATTR_CURRENT = "current" ATTR_POWER = "power" ATTR_DAYLIGHT = "daylight" ATTR_EVENT_ID = "event_id" -DEVICE_CLASS = { - Humidity: DEVICE_CLASS_HUMIDITY, - LightLevel: DEVICE_CLASS_ILLUMINANCE, - Power: DEVICE_CLASS_POWER, - Pressure: DEVICE_CLASS_PRESSURE, - Temperature: DEVICE_CLASS_TEMPERATURE, -} - -ICON = { - Daylight: "mdi:white-balance-sunny", - Pressure: "mdi:gauge", - Temperature: "mdi:thermometer", -} - -UNIT_OF_MEASUREMENT = { - Consumption: ENERGY_KILO_WATT_HOUR, - Humidity: PERCENTAGE, - LightLevel: LIGHT_LUX, - Power: POWER_WATT, - Pressure: PRESSURE_HPA, - Temperature: TEMP_CELSIUS, +ENTITY_DESCRIPTIONS = { + Battery: SensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + Consumption: SensorEntityDescription( + key="consumption", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + Daylight: SensorEntityDescription( + key="daylight", + icon="mdi:white-balance-sunny", + entity_registry_enabled_default=False, + ), + Humidity: SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + LightLevel: SensorEntityDescription( + key="lightlevel", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + ), + Power: SensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + Pressure: SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PRESSURE_HPA, + ), + Temperature: SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ sensors.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @@ -78,13 +132,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): battery_handler = DeconzBatteryHandler(gateway) @callback - def async_add_sensor(sensors=gateway.api.sensors.values()): + def async_add_sensor( + sensors: list[PydeconzSensor] + | ValuesView[PydeconzSensor] = gateway.api.sensors.values(), + ) -> None: """Add sensors from deCONZ. Create DeconzBattery if sensor has a battery attribute. Create DeconzSensor if not a battery, switch or thermostat and not a binary sensor. """ - entities = [] + entities: list[DeconzBattery | DeconzSensor | DeconzTemperature] = [] for sensor in sensors: @@ -103,14 +160,9 @@ def async_add_sensor(sensors=gateway.api.sensors.values()): battery_handler.create_tracker(sensor) if ( - not sensor.BINARY - and sensor.type - not in AncillaryControl.ZHATYPE - + Battery.ZHATYPE - + DoorLock.ZHATYPE - + Switch.ZHATYPE - + Thermostat.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] + isinstance(sensor, DECONZ_SENSORS) + and not isinstance(sensor, Thermostat) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzSensor(sensor, gateway)) @@ -125,7 +177,9 @@ def async_add_sensor(sensors=gateway.api.sensors.values()): config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + hass, + gateway.signal_new_sensor, + async_add_sensor, ) ) @@ -138,36 +192,29 @@ class DeconzSensor(DeconzDevice, SensorEntity): """Representation of a deCONZ sensor.""" TYPE = DOMAIN + _device: PydeconzSensor + + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: + """Initialize deCONZ binary sensor.""" + super().__init__(device, gateway) + + if entity_description := ENTITY_DESCRIPTIONS.get(type(device)): + self.entity_description = entity_description @callback - def async_update_callback(self, force_update=False): + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"on", "reachable", "state"} - if force_update or self._device.changed_keys.intersection(keys): - super().async_update_callback(force_update=force_update) + if self._device.changed_keys.intersection(keys): + super().async_update_callback() @property - def state(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._device.state - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS.get(type(self._device)) + return self._device.state # type: ignore[no-any-return] @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON.get(type(self._device)) - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return UNIT_OF_MEASUREMENT.get(type(self._device)) - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | float | int | None]: """Return the state attributes of the sensor.""" attr = {} @@ -177,13 +224,13 @@ def extra_state_attributes(self): if self._device.secondary_temperature is not None: attr[ATTR_TEMPERATURE] = self._device.secondary_temperature - if self._device.type in Consumption.ZHATYPE: + if isinstance(self._device, Consumption): attr[ATTR_POWER] = self._device.power - elif self._device.type in Daylight.ZHATYPE: + elif isinstance(self._device, Daylight): attr[ATTR_DAYLIGHT] = self._device.daylight - elif self._device.type in LightLevel.ZHATYPE: + elif isinstance(self._device, LightLevel): if self._device.dark is not None: attr[ATTR_DARK] = self._device.dark @@ -191,7 +238,7 @@ def extra_state_attributes(self): if self._device.daylight is not None: attr[ATTR_DAYLIGHT] = self._device.daylight - elif self._device.type in Power.ZHATYPE: + elif isinstance(self._device, Power): attr[ATTR_CURRENT] = self._device.current attr[ATTR_VOLTAGE] = self._device.voltage @@ -205,60 +252,61 @@ class DeconzTemperature(DeconzDevice, SensorEntity): """ TYPE = DOMAIN + _device: PydeconzSensor + + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: + """Initialize deCONZ temperature sensor.""" + super().__init__(device, gateway) + + self.entity_description = ENTITY_DESCRIPTIONS[Temperature] + self._attr_name = f"{self._device.name} Temperature" @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this device.""" return f"{self.serial}-temperature" @callback - def async_update_callback(self, force_update=False): + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"temperature", "reachable"} - if force_update or self._device.changed_keys.intersection(keys): - super().async_update_callback(force_update=force_update) + if self._device.changed_keys.intersection(keys): + super().async_update_callback() @property - def state(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._device.secondary_temperature - - @property - def name(self): - """Return the name of the temperature sensor.""" - return f"{self._device.name} Temperature" - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS_TEMPERATURE - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return TEMP_CELSIUS + return self._device.secondary_temperature # type: ignore[no-any-return] class DeconzBattery(DeconzDevice, SensorEntity): """Battery class for when a device is only represented as an event.""" TYPE = DOMAIN + _device: PydeconzSensor + + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: + """Initialize deCONZ battery level sensor.""" + super().__init__(device, gateway) + + self.entity_description = ENTITY_DESCRIPTIONS[Battery] + self._attr_name = f"{self._device.name} Battery Level" @callback - def async_update_callback(self, force_update=False): + def async_update_callback(self) -> None: """Update the battery's state, if needed.""" keys = {"battery", "reachable"} - if force_update or self._device.changed_keys.intersection(keys): - super().async_update_callback(force_update=force_update) + if self._device.changed_keys.intersection(keys): + super().async_update_callback() @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this device. Normally there should only be one battery sensor per device from deCONZ. With specific Danfoss devices each endpoint can report its own battery state. """ - if self._device.manufacturer == "Danfoss" and self._device.modelid in [ + if self._device.manufacturer == "Danfoss" and self._device.model_id in [ "0x8030", "0x8031", "0x8034", @@ -268,31 +316,16 @@ def unique_id(self): return f"{self.serial}-battery" @property - def state(self): + def native_value(self) -> StateType: """Return the state of the battery.""" - return self._device.battery - - @property - def name(self): - """Return the name of the battery.""" - return f"{self._device.name} Battery Level" - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS_BATTERY - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return PERCENTAGE + return self._device.battery # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the battery.""" attr = {} - if self._device.type in Switch.ZHATYPE: + if isinstance(self._device, Switch): for event in self.gateway.events: if self._device == event.device: attr[ATTR_EVENT_ID] = event.event_id @@ -303,26 +336,25 @@ def extra_state_attributes(self): class DeconzSensorStateTracker: """Track sensors without a battery state and signal when battery state exist.""" - def __init__(self, sensor, gateway): + def __init__(self, sensor: PydeconzSensor, gateway: DeconzGateway) -> None: """Set up tracker.""" self.sensor = sensor self.gateway = gateway sensor.register_callback(self.async_update_callback) @callback - def close(self): + def close(self) -> None: """Clean up tracker.""" self.sensor.remove_callback(self.async_update_callback) - self.gateway = None self.sensor = None @callback - def async_update_callback(self, ignore_update=False): + def async_update_callback(self) -> None: """Sensor state updated.""" if "battery" in self.sensor.changed_keys: async_dispatcher_send( self.gateway.hass, - self.gateway.async_signal_new_device(NEW_SENSOR), + self.gateway.signal_new_sensor, [self.sensor], ) @@ -330,13 +362,13 @@ def async_update_callback(self, ignore_update=False): class DeconzBatteryHandler: """Creates and stores trackers for sensors without a battery state.""" - def __init__(self, gateway): + def __init__(self, gateway: DeconzGateway) -> None: """Set up battery handler.""" self.gateway = gateway - self._trackers = set() + self._trackers: set[DeconzSensorStateTracker] = set() @callback - def create_tracker(self, sensor): + def create_tracker(self, sensor: PydeconzSensor) -> None: """Create new tracker for battery state.""" for tracker in self._trackers: if sensor == tracker.sensor: @@ -344,7 +376,7 @@ def create_tracker(self, sensor): self._trackers.add(DeconzSensorStateTracker(sensor, self.gateway)) @callback - def remove_tracker(self, sensor): + def remove_tracker(self, sensor: PydeconzSensor) -> None: """Remove tracker of battery state.""" for tracker in self._trackers: if sensor == tracker.sensor: diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index d524354ff0bc1..aeb528c0ac900 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,11 +1,16 @@ """deCONZ services.""" -import asyncio +from types import MappingProxyType from pydeconz.utils import normalize_bridge_id import voluptuous as vol -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity_registry import ( async_entries_for_config_entry, @@ -13,15 +18,8 @@ ) from .config_flow import get_master_gateway -from .const import ( - CONF_BRIDGE_ID, - DOMAIN, - LOGGER, - NEW_GROUP, - NEW_LIGHT, - NEW_SCENE, - NEW_SENSOR, -) +from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER +from .gateway import DeconzGateway DECONZ_SERVICES = "deconz_services" @@ -46,63 +44,76 @@ SERVICE_REMOVE_ORPHANED_ENTRIES = "remove_orphaned_entries" SELECT_GATEWAY_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGE_ID): str})) +SUPPORTED_SERVICES = ( + SERVICE_CONFIGURE_DEVICE, + SERVICE_DEVICE_REFRESH, + SERVICE_REMOVE_ORPHANED_ENTRIES, +) -async def async_setup_services(hass): - """Set up services for deCONZ integration.""" - if hass.data.get(DECONZ_SERVICES, False): - return +SERVICE_TO_SCHEMA = { + SERVICE_CONFIGURE_DEVICE: SERVICE_CONFIGURE_DEVICE_SCHEMA, + SERVICE_DEVICE_REFRESH: SELECT_GATEWAY_SCHEMA, + SERVICE_REMOVE_ORPHANED_ENTRIES: SELECT_GATEWAY_SCHEMA, +} - hass.data[DECONZ_SERVICES] = True - async def async_call_deconz_service(service_call): +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for deCONZ integration.""" + + async def async_call_deconz_service(service_call: ServiceCall) -> None: """Call correct deCONZ service.""" service = service_call.service service_data = service_call.data + if CONF_BRIDGE_ID in service_data: + found_gateway = False + bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) + + for possible_gateway in hass.data[DOMAIN].values(): + if possible_gateway.bridgeid == bridge_id: + gateway = possible_gateway + found_gateway = True + break + + if not found_gateway: + LOGGER.error("Could not find the gateway %s", bridge_id) + return + else: + try: + gateway = get_master_gateway(hass) + except ValueError: + LOGGER.error("No master gateway available") + return + if service == SERVICE_CONFIGURE_DEVICE: - await async_configure_service(hass, service_data) + await async_configure_service(gateway, service_data) elif service == SERVICE_DEVICE_REFRESH: - await async_refresh_devices_service(hass, service_data) + await async_refresh_devices_service(gateway) elif service == SERVICE_REMOVE_ORPHANED_ENTRIES: - await async_remove_orphaned_entries_service(hass, service_data) - - hass.services.async_register( - DOMAIN, - SERVICE_CONFIGURE_DEVICE, - async_call_deconz_service, - schema=SERVICE_CONFIGURE_DEVICE_SCHEMA, - ) + await async_remove_orphaned_entries_service(gateway) - hass.services.async_register( - DOMAIN, - SERVICE_DEVICE_REFRESH, - async_call_deconz_service, - schema=SELECT_GATEWAY_SCHEMA, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_REMOVE_ORPHANED_ENTRIES, - async_call_deconz_service, - schema=SELECT_GATEWAY_SCHEMA, - ) + for service in SUPPORTED_SERVICES: + hass.services.async_register( + DOMAIN, + service, + async_call_deconz_service, + schema=SERVICE_TO_SCHEMA[service], + ) -async def async_unload_services(hass): +@callback +def async_unload_services(hass: HomeAssistant) -> None: """Unload deCONZ services.""" - if not hass.data.get(DECONZ_SERVICES): - return - - hass.data[DECONZ_SERVICES] = False - - hass.services.async_remove(DOMAIN, SERVICE_CONFIGURE_DEVICE) - hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE_ORPHANED_ENTRIES) + for service in SUPPORTED_SERVICES: + hass.services.async_remove(DOMAIN, service) -async def async_configure_service(hass, data): +async def async_configure_service( + gateway: DeconzGateway, data: MappingProxyType +) -> None: """Set attribute of device in deCONZ. Entity is used to resolve to a device path (e.g. '/lights/1'). @@ -118,10 +129,6 @@ async def async_configure_service(hass, data): See Dresden Elektroniks REST API documentation for details: http://dresden-elektronik.github.io/deconz-rest-doc/rest/ """ - gateway = get_master_gateway(hass) - if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] - field = data.get(SERVICE_FIELD, "") entity_id = data.get(SERVICE_ENTITY) data = data[SERVICE_DATA] @@ -136,32 +143,20 @@ async def async_configure_service(hass, data): await gateway.api.request("put", field, json=data) -async def async_refresh_devices_service(hass, data): +async def async_refresh_devices_service(gateway: DeconzGateway) -> None: """Refresh available devices from deCONZ.""" - gateway = get_master_gateway(hass) - if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] - gateway.ignore_state_updates = True await gateway.api.refresh_state() gateway.ignore_state_updates = False - gateway.async_add_device_callback(NEW_GROUP, force=True) - gateway.async_add_device_callback(NEW_LIGHT, force=True) - gateway.async_add_device_callback(NEW_SCENE, force=True) - gateway.async_add_device_callback(NEW_SENSOR, force=True) + for resource_type in gateway.deconz_resource_type_to_signal_new_device: + gateway.async_add_device_callback(resource_type, force=True) -async def async_remove_orphaned_entries_service(hass, data): +async def async_remove_orphaned_entries_service(gateway: DeconzGateway) -> None: """Remove orphaned deCONZ entries from device and entity registries.""" - gateway = get_master_gateway(hass) - if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] - - device_registry, entity_registry = await asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), - ) + device_registry = dr.async_get(gateway.hass) + entity_registry = er.async_get(gateway.hass) entity_entries = async_entries_for_config_entry( entity_registry, gateway.config_entry.entry_id @@ -179,14 +174,14 @@ async def async_remove_orphaned_entries_service(hass, data): connections={(CONNECTION_NETWORK_MAC, gateway.api.config.mac)}, identifiers=set(), ) - if gateway_host.id in devices_to_be_removed: + if gateway_host and gateway_host.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_host.id) # Don't remove the Gateway service entry gateway_service = device_registry.async_get_device( - identifiers={(DOMAIN, gateway.api.config.bridgeid)}, connections=set() + identifiers={(DOMAIN, gateway.api.config.bridge_id)}, connections=set() ) - if gateway_service.id in devices_to_be_removed: + if gateway_service and gateway_service.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_service.id) # Don't remove devices belonging to available events diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index cd234376e22ae..9084728a216f6 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -7,35 +7,34 @@ configure: entity: name: Entity description: Represents a specific device endpoint in deCONZ. - example: "light.rgb_light" selector: entity: integration: deconz field: name: Path - selector: - text: description: >- String representing a full path to deCONZ endpoint (when entity is not specified) or a subpath of the device path for the entity (when entity is specified). example: '"/lights/1/state" or "/state"' + selector: + text: data: name: Configuration payload + description: JSON object with what data you want to alter. required: true + example: '{"on": true}' selector: object: - description: JSON object with what data you want to alter. - example: '{"on": true}' bridgeid: name: Bridge identifier - selector: - text: description: >- Unique string for each deCONZ hardware. It can be found as part of the integration name. Useful if you run multiple deCONZ integrations. example: "00212EFFFF012345" + selector: + text: device_refresh: name: Device refresh @@ -43,13 +42,13 @@ device_refresh: fields: bridgeid: name: Bridge identifier - selector: - text: description: >- Unique string for each deCONZ hardware. It can be found as part of the integration name. Useful if you run multiple deCONZ integrations. example: "00212EFFFF012345" + selector: + text: remove_orphaned_entries: name: Remove orphaned entries @@ -57,32 +56,10 @@ remove_orphaned_entries: fields: bridgeid: name: Bridge identifier - selector: - text: description: >- Unique string for each deCONZ hardware. It can be found as part of the integration name. Useful if you run multiple deCONZ integrations. example: "00212EFFFF012345" - -alarm_panel_state: - name: Alarm panel state - description: Put keypad panel in an intermediate state, to help with visual and audible cues to the user. - target: - entity: - integration: deconz - domain: alarm_control_panel - fields: - panel_state: - name: Panel state - description: >- - - "entry_delay": make panel beep until panel is disarmed. Beep interval is long. - - "exit_delay": make panel beep until panel is set to armed state. Beep interval is short. - - "not_ready_to_arm": turn on yellow status led on the panel. Indicate not all conditions for arming are met. - required: true selector: - select: - options: - - "entry_delay" - - "exit_delay" - - "not_ready_to_arm" + text: diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py new file mode 100644 index 0000000000000..7f0f6cc39ed14 --- /dev/null +++ b/homeassistant/components/deconz/siren.py @@ -0,0 +1,93 @@ +"""Support for deCONZ siren.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + +from pydeconz.light import Siren + +from homeassistant.components.siren import ( + ATTR_DURATION, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SirenEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .deconz_device import DeconzDevice +from .gateway import DeconzGateway, get_gateway_from_config_entry + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sirens for deCONZ component.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_siren( + lights: list[Siren] | ValuesView[Siren] = gateway.api.lights.values(), + ) -> None: + """Add siren from deCONZ.""" + entities = [] + + for light in lights: + + if ( + isinstance(light, Siren) + and light.unique_id not in gateway.entities[DOMAIN] + ): + entities.append(DeconzSiren(light, gateway)) + + if entities: + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + gateway.signal_new_light, + async_add_siren, + ) + ) + + async_add_siren() + + +class DeconzSiren(DeconzDevice, SirenEntity): + """Representation of a deCONZ siren.""" + + TYPE = DOMAIN + _device: Siren + + def __init__(self, device: Siren, gateway: DeconzGateway) -> None: + """Set up siren.""" + super().__init__(device, gateway) + + self._attr_supported_features = ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_DURATION + ) + + @property + def is_on(self) -> bool: + """Return true if siren is on.""" + return self._device.is_on # type: ignore[no-any-return] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on siren.""" + data = {} + if (duration := kwargs.get(ATTR_DURATION)) is not None: + data["duration"] = duration * 10 + await self._device.turn_on(**data) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off siren.""" + await self._device.turn_off() diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 492872ecca0ac..ab4577a427ca6 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -1,14 +1,29 @@ """Support for deCONZ switches.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + +from pydeconz.light import Light, Siren + from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEW_LIGHT, POWER_PLUGS, SIRENS +from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up switches for deCONZ component. Switches are based on the same device class as lights in deCONZ. @@ -16,8 +31,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() + entity_registry = er.async_get(hass) + + # Siren platform replacing sirens in switch platform added in 2021.10 + for light in gateway.api.lights.values(): + if isinstance(light, Siren) and ( + entity_id := entity_registry.async_get_entity_id( + DOMAIN, DECONZ_DOMAIN, light.unique_id + ) + ): + entity_registry.async_remove(entity_id) + @callback - def async_add_switch(lights=gateway.api.lights.values()): + def async_add_switch( + lights: list[Light] | ValuesView[Light] = gateway.api.lights.values(), + ) -> None: """Add switch from deCONZ.""" entities = [] @@ -25,21 +53,18 @@ def async_add_switch(lights=gateway.api.lights.values()): if ( light.type in POWER_PLUGS - and light.uniqueid not in gateway.entities[DOMAIN] + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzPowerPlug(light, gateway)) - elif ( - light.type in SIRENS and light.uniqueid not in gateway.entities[DOMAIN] - ): - entities.append(DeconzSiren(light, gateway)) - if entities: async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect( - hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_switch + hass, + gateway.signal_new_light, + async_add_switch, ) ) @@ -50,37 +75,17 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): """Representation of a deCONZ power plug.""" TYPE = DOMAIN + _device: Light @property - def is_on(self): - """Return true if switch is on.""" - return self._device.state - - async def async_turn_on(self, **kwargs): - """Turn on switch.""" - data = {"on": True} - await self._device.async_set_state(data) - - async def async_turn_off(self, **kwargs): - """Turn off switch.""" - data = {"on": False} - await self._device.async_set_state(data) - - -class DeconzSiren(DeconzDevice, SwitchEntity): - """Representation of a deCONZ siren.""" - - TYPE = DOMAIN - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.is_on + return self._device.state # type: ignore[no-any-return] - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self._device.turn_on() + await self._device.set_state(on=True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self._device.turn_off() + await self._device.set_state(on=False) diff --git a/homeassistant/components/deconz/translations/ar.json b/homeassistant/components/deconz/translations/ar.json new file mode 100644 index 0000000000000..9624f9c47c96e --- /dev/null +++ b/homeassistant/components/deconz/translations/ar.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0627\u0644\u062c\u0633\u0631 \u062a\u0645 \u062a\u0643\u0648\u064a\u0646\u0647 \u0645\u0633\u0628\u0642\u0627", + "no_bridges": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0643\u062a\u0634\u0627\u0641 \u062c\u0633\u0648\u0631 deCONZ" + }, + "error": { + "no_key": "\u062a\u0639\u0630\u0631 \u0627\u0644\u062d\u0635\u0648\u0644 \u0639\u0644\u0649 \u0645\u0641\u062a\u0627\u062d API" + }, + "step": { + "link": { + "description": "\u0627\u0641\u062a\u062d \u0628\u0648\u0627\u0628\u0629 deCONZ \u0644\u0644\u062a\u0633\u062c\u064a\u0644 \u0641\u064a Home Assistant. \n\n 1. \u0627\u0646\u062a\u0642\u0644 \u0625\u0644\u0649 \u0625\u0639\u062f\u0627\u062f\u0627\u062a deCONZ - > \u0627\u0644\u0628\u0648\u0627\u0628\u0629 - > \u062e\u064a\u0627\u0631\u0627\u062a \u0645\u062a\u0642\u062f\u0645\u0629\n 2. \u0627\u0636\u063a\u0637 \u0639\u0644\u0649 \u0632\u0631 \"\u0645\u0635\u0627\u062f\u0642\u0629 \u0627\u0644\u062a\u0637\u0628\u064a\u0642\"", + "title": "\u0627\u0644\u0627\u0631\u062a\u0628\u0627\u0637 \u0645\u0639 deCONZ" + } + } + }, + "device_automation": { + "trigger_type": { + "remote_flip_180_degrees": "\u0627\u0646\u0642\u0644\u0628 \u0627\u0644\u062c\u0647\u0627\u0632 180 \u062f\u0631\u062c\u0629", + "remote_flip_90_degrees": "\u0627\u0646\u0642\u0644\u0628 \u0627\u0644\u062c\u0647\u0627\u0632 90 \u062f\u0631\u062c\u0629" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/bg.json b/homeassistant/components/deconz/translations/bg.json index 24e36ecbe5582..3fe700efd3efd 100644 --- a/homeassistant/components/deconz/translations/bg.json +++ b/homeassistant/components/deconz/translations/bg.json @@ -19,6 +19,12 @@ "link": { "description": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438 deCONZ \u0448\u043b\u044e\u0437\u0430 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430 \u0441 Home Assistant.\n\n1. \u041e\u0442\u0438\u0434\u0435\u0442\u0435 \u043d\u0430 deCONZ Settings -> Gateway -> Advanced\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Authenticate app\"", "title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ" + }, + "manual_input": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } } } }, @@ -73,7 +79,8 @@ "deconz_devices": { "data": { "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ CLIP \u0441\u0435\u043d\u0437\u043e\u0440\u0438", - "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 deCONZ \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u043d\u0438 \u0433\u0440\u0443\u043f\u0438" + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 deCONZ \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u043d\u0438 \u0433\u0440\u0443\u043f\u0438", + "allow_new_devices": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u043d\u043e\u0432\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" }, "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0442\u0430 \u043d\u0430 \u0442\u0438\u043f\u043e\u0432\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 deCONZ" } diff --git a/homeassistant/components/deconz/translations/ca.json b/homeassistant/components/deconz/translations/ca.json index 60d91a83db8b3..2f839b209ebfb 100644 --- a/homeassistant/components/deconz/translations/ca.json +++ b/homeassistant/components/deconz/translations/ca.json @@ -11,7 +11,7 @@ "error": { "no_key": "No s'ha pogut obtenir una clau API" }, - "flow_title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb la passarel\u00b7la deCONZ proporcionada pel complement {addon}?", diff --git a/homeassistant/components/deconz/translations/da.json b/homeassistant/components/deconz/translations/da.json index be165a206bf6d..6f63540c92406 100644 --- a/homeassistant/components/deconz/translations/da.json +++ b/homeassistant/components/deconz/translations/da.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Bridge er allerede konfigureret", "already_in_progress": "Konfigurationsflow for bro er allerede i gang.", - "no_bridges": "Ingen deConz-bridge fundet", + "no_bridges": "Ingen deConz-bro fundet", "not_deconz_bridge": "Ikke en deCONZ-bro", "updated_instance": "Opdaterede deCONZ-instans med ny v\u00e6rtadresse" }, @@ -19,12 +19,19 @@ "link": { "description": "L\u00e5s din deCONZ-gateway op for at registrere dig med Home Assistant. \n\n 1. G\u00e5 til deCONZ settings -> Gateway -> Advanced\n 2. Tryk p\u00e5 knappen \"Authenticate app\"", "title": "Forbind med deCONZ" + }, + "manual_input": { + "data": { + "host": "V\u00e6rt", + "port": "Port" + } } } }, "device_automation": { "trigger_subtype": { "both_buttons": "Begge knapper", + "bottom_buttons": "Nederste knapper", "button_1": "F\u00f8rste knap", "button_2": "Anden knap", "button_3": "Tredje knap", @@ -41,6 +48,7 @@ "side_4": "Side 4", "side_5": "Side 5", "side_6": "Side 6", + "top_buttons": "\u00d8verste knapper", "turn_off": "Sluk", "turn_on": "T\u00e6nd" }, diff --git a/homeassistant/components/deconz/translations/de.json b/homeassistant/components/deconz/translations/de.json index 99d9e8d1e9213..a24dbb44ad483 100644 --- a/homeassistant/components/deconz/translations/de.json +++ b/homeassistant/components/deconz/translations/de.json @@ -11,7 +11,7 @@ "error": { "no_key": "Es konnte kein API-Schl\u00fcssel abgerufen werden" }, - "flow_title": "deCONZ Zigbee Gateway", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem deCONZ Gateway herstellt, der vom Supervisor Add-on {addon} bereitgestellt wird?", diff --git a/homeassistant/components/deconz/translations/es-419.json b/homeassistant/components/deconz/translations/es-419.json index e439d1da94950..ceb0ca39d2c4f 100644 --- a/homeassistant/components/deconz/translations/es-419.json +++ b/homeassistant/components/deconz/translations/es-419.json @@ -4,6 +4,7 @@ "already_configured": "El Bridge ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en progreso.", "no_bridges": "No se descubrieron puentes deCONZ", + "no_hardware_available": "No hay hardware de radio conectado a deCONZ", "not_deconz_bridge": "No es un puente deCONZ", "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" }, @@ -41,6 +42,10 @@ "button_2": "Segundo bot\u00f3n", "button_3": "Tercer bot\u00f3n", "button_4": "Cuarto bot\u00f3n", + "button_5": "Quinto bot\u00f3n", + "button_6": "Sexto bot\u00f3n", + "button_7": "S\u00e9ptimo bot\u00f3n", + "button_8": "Octavo bot\u00f3n", "close": "Cerrar", "dim_down": "Bajar la intensidad", "dim_up": "Aumentar intensidad", @@ -65,6 +70,7 @@ "remote_button_quadruple_press": "El bot\u00f3n \"{subtype}\" ha sido pulsado 4 veces", "remote_button_quintuple_press": "El bot\u00f3n \"{subtype}\" ha sido pulsado 5 veces", "remote_button_rotated": "Bot\u00f3n girado \"{subtype}\"", + "remote_button_rotated_fast": "Bot\u00f3n girado r\u00e1pidamente \"{subtype}\"", "remote_button_rotation_stopped": "Se detuvo la rotaci\u00f3n del bot\u00f3n \"{subtype}\"", "remote_button_short_press": "Se presion\u00f3 el bot\u00f3n \"{subtype}\"", "remote_button_short_release": "Se solt\u00f3 el bot\u00f3n \"{subtype}\"", @@ -92,7 +98,8 @@ "deconz_devices": { "data": { "allow_clip_sensor": "Permitir sensores deCONZ CLIP", - "allow_deconz_groups": "Permitir grupos de luz deCONZ" + "allow_deconz_groups": "Permitir grupos de luz deCONZ", + "allow_new_devices": "Permitir la adici\u00f3n autom\u00e1tica de nuevos dispositivos" }, "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ", "title": "Opciones de deCONZ" diff --git a/homeassistant/components/deconz/translations/es.json b/homeassistant/components/deconz/translations/es.json index 3670caf18d08a..5608b95288da4 100644 --- a/homeassistant/components/deconz/translations/es.json +++ b/homeassistant/components/deconz/translations/es.json @@ -81,7 +81,7 @@ "remote_flip_180_degrees": "Dispositivo volteado 180 grados", "remote_flip_90_degrees": "Dispositivo volteado 90 grados", "remote_gyro_activated": "Dispositivo sacudido", - "remote_moved": "Dispositivo movido con \"{subtipo}\" hacia arriba", + "remote_moved": "Dispositivo movido con \"{subtype}\" hacia arriba", "remote_moved_any_side": "Dispositivo movido con cualquier lado hacia arriba", "remote_rotate_from_side_1": "Dispositivo girado del \"lado 1\" al \" {subtype} \"", "remote_rotate_from_side_2": "Dispositivo girado del \"lado 2\" al \" {subtype} \"", diff --git a/homeassistant/components/deconz/translations/et.json b/homeassistant/components/deconz/translations/et.json index e52b54166a14b..6be6a7fbdc6f6 100644 --- a/homeassistant/components/deconz/translations/et.json +++ b/homeassistant/components/deconz/translations/et.json @@ -11,7 +11,7 @@ "error": { "no_key": "API v\u00f5tit ei leitud" }, - "flow_title": "deCONZ Zigbee l\u00fc\u00fcs ( {host} )", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Kas soovid seadistada Home Assistant-i \u00fchenduse deCONZ-l\u00fc\u00fcsiga, mida pakub lisandmoodul {addon} ?", diff --git a/homeassistant/components/deconz/translations/fr.json b/homeassistant/components/deconz/translations/fr.json index 05d53405e54df..464cc2e139ebf 100644 --- a/homeassistant/components/deconz/translations/fr.json +++ b/homeassistant/components/deconz/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", "no_hardware_available": "Aucun mat\u00e9riel radio connect\u00e9 \u00e0 deCONZ", "not_deconz_bridge": "Pas un pont deCONZ", @@ -11,7 +11,7 @@ "error": { "no_key": "Impossible d'obtenir une cl\u00e9 d'API" }, - "flow_title": "Passerelle deCONZ Zigbee ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 la passerelle deCONZ fournie par le module compl\u00e9mentaire Hass.io {addon} ?", @@ -23,7 +23,7 @@ }, "manual_input": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" } }, diff --git a/homeassistant/components/deconz/translations/he.json b/homeassistant/components/deconz/translations/he.json index 163cd813dc3c3..3e2b350a0d96e 100644 --- a/homeassistant/components/deconz/translations/he.json +++ b/homeassistant/components/deconz/translations/he.json @@ -2,16 +2,51 @@ "config": { "abort": { "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", - "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ" + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ", + "updated_instance": "\u05de\u05d5\u05e4\u05e2 deCONZ \u05e2\u05d5\u05d3\u05db\u05df \u05e2\u05dd \u05db\u05ea\u05d5\u05d1\u05ea \u05de\u05d0\u05e8\u05d7\u05ea \u05d7\u05d3\u05e9\u05d4" }, "error": { "no_key": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05d1\u05dc \u05de\u05e4\u05ea\u05d7 API" }, + "flow_title": "{host}", "step": { "link": { "description": "\u05d1\u05d8\u05dc \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea \u05d4\u05de\u05e9\u05e8 deCONZ \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd Home Assistant.\n\n 1. \u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea deCONZ \n .2 \u05dc\u05d7\u05e5 \u05e2\u05dc \"Unlock Gateway\"", "title": "\u05e7\u05e9\u05e8 \u05e2\u05dd deCONZ" + }, + "manual_input": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + }, + "user": { + "data": { + "host": "\u05d1\u05d7\u05e8 \u05e9\u05e2\u05e8 deCONZ \u05e9\u05d4\u05ea\u05d2\u05dc\u05d4" + } } } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u05e9\u05e0\u05d9 \u05d4\u05dc\u05d7\u05e6\u05e0\u05d9\u05dd", + "button_1": "\u05dc\u05d7\u05e6\u05df \u05e8\u05d0\u05e9\u05d5\u05df", + "button_2": "\u05dc\u05d7\u05e6\u05df \u05e9\u05e0\u05d9", + "button_3": "\u05dc\u05d7\u05e6\u05df \u05e9\u05dc\u05d9\u05e9\u05d9", + "button_4": "\u05dc\u05d7\u05e6\u05df \u05e8\u05d1\u05d9\u05e2\u05d9", + "button_5": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05d7\u05de\u05d9\u05e9\u05d9", + "button_6": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05d9\u05e9\u05d9", + "button_7": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05d1\u05d9\u05e2\u05d9", + "button_8": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05de\u05d9\u05e0\u05d9", + "close": "\u05e1\u05d2\u05d5\u05e8", + "dim_down": "\u05e2\u05de\u05e2\u05d5\u05dd \u05dc\u05de\u05d8\u05d4", + "dim_up": "\u05e2\u05de\u05e2\u05d5\u05dd \u05dc\u05de\u05e2\u05dc\u05d4", + "left": "\u05e9\u05de\u05d0\u05dc", + "open": "\u05e4\u05ea\u05d5\u05d7", + "right": "\u05d9\u05de\u05d9\u05df", + "turn_off": "\u05db\u05d9\u05d1\u05d5\u05d9", + "turn_on": "\u05d4\u05e4\u05e2\u05dc\u05d4" + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index 0463463c0b3f3..c71689a555d73 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", + "no_bridges": "Nem tal\u00e1lhat\u00f3 deCONZ \u00e1tj\u00e1r\u00f3", "no_hardware_available": "Nincs deCONZ-hoz csatlakoztatott r\u00e1di\u00f3hardver", "not_deconz_bridge": "Nem egy deCONZ \u00e1tj\u00e1r\u00f3", "updated_instance": "A deCONZ-p\u00e9ld\u00e1ny \u00faj \u00e1llom\u00e1sc\u00edmmel friss\u00edtve" @@ -11,26 +11,33 @@ "error": { "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" }, - "flow_title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { - "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 a Supervisor kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot, hogy csatlakozzon {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", + "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "link": { - "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot", + "description": "Enged\u00e9lyezze fel a deCONZ \u00e1tj\u00e1r\u00f3ban a Home Assistanthoz val\u00f3 regisztr\u00e1l\u00e1st.\n\n1. V\u00e1lassza ki a deCONZ rendszer be\u00e1ll\u00edt\u00e1sait\n2. Nyomja meg az \"Authenticate app\" gombot", "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" }, "manual_input": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } + }, + "user": { + "data": { + "host": "V\u00e1lassza ki a felfedezett deCONZ \u00e1tj\u00e1r\u00f3t" + } } } }, "device_automation": { "trigger_subtype": { "both_buttons": "Mindk\u00e9t gomb", + "bottom_buttons": "Als\u00f3 gombok", "button_1": "Els\u0151 gomb", "button_2": "M\u00e1sodik gomb", "button_3": "Harmadik gomb", @@ -51,6 +58,7 @@ "side_4": "4. oldal", "side_5": "5. oldal", "side_6": "6. oldal", + "top_buttons": "Fels\u0151 gombok", "turn_off": "Kikapcsolva", "turn_on": "Bekapcsolva" }, @@ -62,7 +70,8 @@ "remote_button_quadruple_press": "\"{subtype}\" gombra n\u00e9gyszer kattintottak", "remote_button_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak", "remote_button_rotated": "A gomb elforgatva: \"{subtype}\"", - "remote_button_rotation_stopped": "A (z) \"{subtype}\" gomb forg\u00e1sa le\u00e1llt", + "remote_button_rotated_fast": "A gomb gyorsan elfordult: \"{subtype}\"", + "remote_button_rotation_stopped": "\"{subtype}\" gomb forg\u00e1sa le\u00e1llt", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", "remote_button_short_release": "\"{subtype}\" gomb elengedve", "remote_button_triple_press": "\"{subtype}\" gombra h\u00e1romszor kattintottak", @@ -89,9 +98,11 @@ "deconz_devices": { "data": { "allow_clip_sensor": "Enged\u00e9lyezze a deCONZ CLIP \u00e9rz\u00e9kel\u0151ket", - "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se" + "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se", + "allow_new_devices": "Enged\u00e9lyezze az \u00faj eszk\u00f6z\u00f6k automatikus hozz\u00e1ad\u00e1s\u00e1t" }, - "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" + "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa", + "title": "deCONZ opci\u00f3k" } } } diff --git a/homeassistant/components/deconz/translations/id.json b/homeassistant/components/deconz/translations/id.json index c6d54beaec2f3..f63261e6e87ed 100644 --- a/homeassistant/components/deconz/translations/id.json +++ b/homeassistant/components/deconz/translations/id.json @@ -11,11 +11,11 @@ "error": { "no_key": "Tidak bisa mendapatkan kunci API" }, - "flow_title": "Gateway Zigbee deCONZ ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke gateway deCONZ yang disediakan oleh add-on Supervisor {addon}?", - "title": "Gateway Zigbee deCONZ melalui add-on Supervisor" + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke gateway deCONZ yang disediakan oleh add-on: {addon}?", + "title": "Gateway Zigbee deCONZ melalui add-on Home Assistant" }, "link": { "description": "Buka gateway deCONZ Anda untuk mendaftarkan ke Home Assistant. \n\n1. Buka pengaturan sistem deCONZ \n2. Tekan tombol \"Authenticate app\"", diff --git a/homeassistant/components/deconz/translations/it.json b/homeassistant/components/deconz/translations/it.json index cb445ac4f764b..61e5e3b5e96a4 100644 --- a/homeassistant/components/deconz/translations/it.json +++ b/homeassistant/components/deconz/translations/it.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "already_configured": "Il bridge \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "no_bridges": "Nessun bridge deCONZ rilevato", "no_hardware_available": "Nessun hardware radio collegato a deCONZ", @@ -11,14 +11,14 @@ "error": { "no_key": "Impossibile ottenere una API key" }, - "flow_title": "Gateway Zigbee deCONZ ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Vuoi configurare Home Assistant per connettersi al gateway deCONZ fornito dal componente aggiuntivo: {addon}?", "title": "Gateway deCONZ Zigbee tramite il componente aggiuntivo di Home Assistant" }, "link": { - "description": "Sblocca il tuo gateway deCONZ per registrarti con Home Assistant.\n\n1. Vai a Impostazioni deCONZ -> Gateway -> Avanzate\n2. Premere il pulsante \"Autentica app\"", + "description": "Sblocca il tuo gateway deCONZ per registrarti con Home Assistant.\n\n1. Vai a Impostazioni deCONZ -> Gateway -> Avanzate\n2. Premi il pulsante \"Autentica app\"", "title": "Collega con deCONZ" }, "manual_input": { @@ -48,7 +48,7 @@ "button_8": "Ottavo pulsante", "close": "Chiudere", "dim_down": "Diminuire luminosit\u00e0", - "dim_up": "Aumentare luminosit\u00e0", + "dim_up": "Aumenta luminosit\u00e0", "left": "Sinistra", "open": "Aperto", "right": "Destra", @@ -101,7 +101,7 @@ "allow_deconz_groups": "Consentire gruppi luce deCONZ", "allow_new_devices": "Consentire l'aggiunta automatica di nuovi dispositivi" }, - "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ", + "description": "Configura la visibilit\u00e0 dei tipi di dispositivi deCONZ", "title": "Opzioni deCONZ" } } diff --git a/homeassistant/components/deconz/translations/ja.json b/homeassistant/components/deconz/translations/ja.json index 240e04262e4c5..8b6730ab33771 100644 --- a/homeassistant/components/deconz/translations/ja.json +++ b/homeassistant/components/deconz/translations/ja.json @@ -1,7 +1,109 @@ { "config": { + "abort": { + "already_configured": "\u30d6\u30ea\u30c3\u30b8\u306f\u3059\u3067\u306b\u69cb\u6210\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "no_bridges": "deCONZ\u30d6\u30ea\u30c3\u30b8\u306f\u691c\u51fa\u3055\u308c\u307e\u305b\u3093\u3067\u3057\u305f", + "no_hardware_available": "deCONZ\u306b\u63a5\u7d9a\u3055\u308c\u3066\u3044\u308b\u7121\u7dda\u30cf\u30fc\u30c9\u30a6\u30a7\u30a2\u304c\u3042\u308a\u307e\u305b\u3093", + "not_deconz_bridge": "deCONZ bridge\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "updated_instance": "\u65b0\u3057\u3044\u30db\u30b9\u30c8\u30a2\u30c9\u30ec\u30b9\u3067deCONZ\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u3092\u66f4\u65b0\u3057\u307e\u3057\u305f" + }, "error": { "no_key": "API\u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "flow_title": "{host}", + "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308bdeCONZ gateway\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306e\u3001deCONZ Zigbee gateway" + }, + "link": { + "description": "deCONZ gateway\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664\u3057\u3066\u3001Home Assistant\u306b\u767b\u9332\u3057\u307e\u3059\u3002 \n\n1. deCONZ\u8a2d\u5b9a -> \u30b2\u30fc\u30c8\u30a6\u30a7\u30a4 -> \u8a73\u7d30\u306b\u79fb\u52d5\n2. \"\u30a2\u30d7\u30ea\u306e\u8a8d\u8a3c(Authenticate app)\" \u30dc\u30bf\u30f3\u3092\u62bc\u3059", + "title": "deCONZ\u3068\u30ea\u30f3\u30af\u3059\u308b" + }, + "manual_input": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + } + }, + "user": { + "data": { + "host": "\u691c\u51fa\u3055\u308c\u305fdeCONZ gateway\u3092\u9078\u629e\u3057\u307e\u3059" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u4e21\u65b9\u306e\u30dc\u30bf\u30f3", + "bottom_buttons": "\u4e0b\u90e8\u306e\u30dc\u30bf\u30f3", + "button_1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_2": "2\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_3": "3\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_4": "4\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_5": "5\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_6": "6\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_7": "7\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_8": "8\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "close": "\u30af\u30ed\u30fc\u30ba", + "dim_down": "\u8584\u6697\u304f\u3059\u308b", + "dim_up": "\u5fae\u304b\u306b\u660e\u308b\u304f\u3059\u308b", + "left": "\u5de6", + "open": "\u30aa\u30fc\u30d7\u30f3", + "right": "\u53f3", + "side_1": "\u30b5\u30a4\u30c91", + "side_2": "\u30b5\u30a4\u30c92", + "side_3": "\u30b5\u30a4\u30c93", + "side_4": "\u30b5\u30a4\u30c94", + "side_5": "\u30b5\u30a4\u30c95", + "side_6": "\u30b5\u30a4\u30c96", + "top_buttons": "\u30c8\u30c3\u30d7\u30dc\u30bf\u30f3", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b" + }, + "trigger_type": { + "remote_awakened": "\u30c7\u30d0\u30a4\u30b9\u304c\u76ee\u899a\u3081\u305f", + "remote_button_double_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af", + "remote_button_long_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u62bc\u3057\u7d9a\u3051\u308b", + "remote_button_long_release": "\u9577\u62bc\u3057\u3059\u308b\u3068 \"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u308b", + "remote_button_quadruple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30924\u56de(quadruple)\u30af\u30ea\u30c3\u30af", + "remote_button_quintuple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30925\u56de(quintuple)\u30af\u30ea\u30c3\u30af", + "remote_button_rotated": "\u30dc\u30bf\u30f3\u304c\u56de\u8ee2\u3057\u305f \"{subtype}\"", + "remote_button_rotated_fast": "\u30dc\u30bf\u30f3\u304c\u9ad8\u901f\u56de\u8ee2\u3057\u305f \"{subtype}\"", + "remote_button_rotation_stopped": "\u30dc\u30bf\u30f3\u306e\u56de\u8ee2 \"{subtype}\" \u304c\u505c\u6b62\u3057\u307e\u3057\u305f", + "remote_button_short_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u62bc\u3055\u308c\u307e\u3057\u305f\u3002", + "remote_button_short_release": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u307e\u3057\u305f", + "remote_button_triple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30923\u56de\u30af\u30ea\u30c3\u30af", + "remote_double_tap": "\u30c7\u30d0\u30a4\u30b9 \"{subtype}\" \u304c\u30c0\u30d6\u30eb\u30bf\u30c3\u30d7\u3055\u308c\u307e\u3057\u305f", + "remote_double_tap_any_side": "\u30c7\u30d0\u30a4\u30b9\u306e\u3044\u305a\u308c\u304b\u306e\u9762\u3092\u30c0\u30d6\u30eb\u30bf\u30c3\u30d7\u3057\u305f", + "remote_falling": "\u81ea\u7531\u843d\u4e0b\u6642\u306e\u30c7\u30d0\u30a4\u30b9(Device in free fall)", + "remote_flip_180_degrees": "\u30c7\u30d0\u30a4\u30b9\u304c180\u5ea6\u53cd\u8ee2", + "remote_flip_90_degrees": "\u30c7\u30d0\u30a4\u30b9\u304c90\u5ea6\u53cd\u8ee2", + "remote_gyro_activated": "\u30c7\u30d0\u30a4\u30b9\u304c\u63fa\u308c\u308b", + "remote_moved": "\u30c7\u30d0\u30a4\u30b9\u306f \"{subtype}\" \u3092\u4e0a\u306b\u3057\u3066\u79fb\u52d5\u3057\u307e\u3057\u305f", + "remote_moved_any_side": "\u30c7\u30d0\u30a4\u30b9\u304c\u4efb\u610f\u306e\u9762\u3092\u4e0a\u306b\u3057\u3066\u79fb\u52d5\u3057\u305f", + "remote_rotate_from_side_1": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\"side 1\" \u304b\u3089 \"{subtype} \"\u306b\u56de\u8ee2\u3057\u305f", + "remote_rotate_from_side_2": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\"side 2\" \u304b\u3089 \"{subtype} \"\u306b\u56de\u8ee2\u3057\u305f", + "remote_rotate_from_side_3": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\"side 3\" \u304b\u3089 \"{subtype} \"\u306b\u56de\u8ee2\u3057\u305f", + "remote_rotate_from_side_4": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\"side 4\" \u304b\u3089 \"{subtype} \"\u306b\u56de\u8ee2\u3057\u305f", + "remote_rotate_from_side_5": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\"side 5\" \u304b\u3089 \"{subtype} \"\u306b\u56de\u8ee2\u3057\u305f", + "remote_rotate_from_side_6": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\"side 6\" \u304b\u3089 \"{subtype} \"\u306b\u56de\u8ee2\u3057\u305f", + "remote_turned_clockwise": "\u30c7\u30d0\u30a4\u30b9\u304c\u6642\u8a08\u56de\u308a\u306b", + "remote_turned_counter_clockwise": "\u30c7\u30d0\u30a4\u30b9\u304c\u53cd\u6642\u8a08\u56de\u308a\u306b" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP\u30bb\u30f3\u30b5\u30fc\u3092\u8a31\u53ef\u3059\u308b", + "allow_deconz_groups": "deCONZ light\u30b0\u30eb\u30fc\u30d7\u3092\u8a31\u53ef\u3059\u308b", + "allow_new_devices": "\u65b0\u3057\u3044\u30c7\u30d0\u30a4\u30b9\u306e\u81ea\u52d5\u8ffd\u52a0\u3092\u8a31\u53ef\u3059\u308b" + }, + "description": "deCONZ \u30c7\u30d0\u30a4\u30b9\u30bf\u30a4\u30d7\u306e\u53ef\u8996\u6027\u3092\u8a2d\u5b9a\u3057\u307e\u3059", + "title": "deCONZ\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/nl.json b/homeassistant/components/deconz/translations/nl.json index 0d0a745bc1b26..9c83fb806e8a2 100644 --- a/homeassistant/components/deconz/translations/nl.json +++ b/homeassistant/components/deconz/translations/nl.json @@ -3,15 +3,15 @@ "abort": { "already_configured": "Bridge is al geconfigureerd", "already_in_progress": "De configuratiestroom is al aan de gang", - "no_bridges": "Geen deCONZ apparaten ontdekt", + "no_bridges": "Geen deCONZ bridges ontdekt", "no_hardware_available": "Geen radiohardware aangesloten op deCONZ", - "not_deconz_bridge": "Dit is geen deCONZ bridge", + "not_deconz_bridge": "Geen deCONZ bridge", "updated_instance": "DeCONZ-instantie bijgewerkt met nieuw host-adres" }, "error": { "no_key": "Kon geen API-sleutel ophalen" }, - "flow_title": "deCONZ Zigbee gateway ( {host} )", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Wilt u Home Assistant configureren om verbinding te maken met de deCONZ gateway van de Home Assistant add-on {addon}?", @@ -67,7 +67,7 @@ "remote_button_double_press": "\"{subtype}\" knop dubbel geklikt", "remote_button_long_press": "\" {subtype} \" knop continu ingedrukt", "remote_button_long_release": "\"{subtype}\" knop losgelaten na lang indrukken van de knop", - "remote_button_quadruple_press": "\" {subtype} \" knop viervoudig aangeklikt", + "remote_button_quadruple_press": "\" {subtype} \" knop vier keer aangeklikt", "remote_button_quintuple_press": "\" {subtype} \" knop vijf keer aangeklikt", "remote_button_rotated": "Knop gedraaid \" {subtype} \"", "remote_button_rotated_fast": "Knop is snel gedraaid \" {subtype} \"", @@ -76,13 +76,13 @@ "remote_button_short_release": "\"{subtype}\" knop losgelaten", "remote_button_triple_press": "\" {subtype} \" knop driemaal geklikt", "remote_double_tap": "Apparaat \"{subtype}\" dubbel getikt", - "remote_double_tap_any_side": "Apparaat dubbel getikt aan elke kant", + "remote_double_tap_any_side": "Apparaat dubbel getikt op willekeurige zijde", "remote_falling": "Apparaat in vrije val", - "remote_flip_180_degrees": "Apparaat 180 graden omgedraaid", - "remote_flip_90_degrees": "Apparaat 90 graden omgedraaid", + "remote_flip_180_degrees": "Apparaat 180 graden gedraaid", + "remote_flip_90_degrees": "Apparaat 90 graden gedraaid", "remote_gyro_activated": "Apparaat geschud", "remote_moved": "Apparaat verplaatst met \"{subtype}\" omhoog", - "remote_moved_any_side": "Apparaat gedraaid met elke kant naar boven", + "remote_moved_any_side": "Apparaat gedraaid met willekeurige zijde boven", "remote_rotate_from_side_1": "Apparaat gedraaid van \"zijde 1\" naar \"{subtype}\"\".", "remote_rotate_from_side_2": "Apparaat gedraaid van \"zijde 2\" naar \"{subtype}\"\".", "remote_rotate_from_side_3": "Apparaat gedraaid van \"zijde 3\" naar \" {subtype} \"", diff --git a/homeassistant/components/deconz/translations/no.json b/homeassistant/components/deconz/translations/no.json index f27e7235f4068..06c03b8b5850a 100644 --- a/homeassistant/components/deconz/translations/no.json +++ b/homeassistant/components/deconz/translations/no.json @@ -11,7 +11,7 @@ "error": { "no_key": "Kunne ikke f\u00e5 en API-n\u00f8kkel" }, - "flow_title": "", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Vil du konfigurere Home Assistant til \u00e5 koble til deCONZ gateway levert av tillegget {addon} ?", diff --git a/homeassistant/components/deconz/translations/pl.json b/homeassistant/components/deconz/translations/pl.json index d2352bdb9731b..72d62aff959af 100644 --- a/homeassistant/components/deconz/translations/pl.json +++ b/homeassistant/components/deconz/translations/pl.json @@ -11,7 +11,7 @@ "error": { "no_key": "Nie mo\u017cna uzyska\u0107 klucza API" }, - "flow_title": "Bramka deCONZ Zigbee ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "Czy chcesz skonfigurowa\u0107 Home Assistanta, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek {addon}?", diff --git a/homeassistant/components/deconz/translations/ru.json b/homeassistant/components/deconz/translations/ru.json index de97d799381cd..412c12198f3f0 100644 --- a/homeassistant/components/deconz/translations/ru.json +++ b/homeassistant/components/deconz/translations/ru.json @@ -11,7 +11,7 @@ "error": { "no_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API." }, - "flow_title": "\u0428\u043b\u044e\u0437 Zigbee deCONZ ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a deCONZ (\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 \"{addon}\")?", diff --git a/homeassistant/components/deconz/translations/tr.json b/homeassistant/components/deconz/translations/tr.json index 22eea1278d744..021c48459896d 100644 --- a/homeassistant/components/deconz/translations/tr.json +++ b/homeassistant/components/deconz/translations/tr.json @@ -2,12 +2,28 @@ "config": { "abort": { "already_configured": "K\u00f6pr\u00fc zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_bridges": "DeCONZ k\u00f6pr\u00fcs\u00fc bulunamad\u0131", + "no_hardware_available": "deCONZ'a ba\u011fl\u0131 radyo donan\u0131m\u0131 yok", + "not_deconz_bridge": "deCONZ k\u00f6pr\u00fcs\u00fc de\u011fil", + "updated_instance": "DeCONZ yeni ana bilgisayar adresiyle g\u00fcncelle\u015ftirildi" }, + "error": { + "no_key": "API anahtar\u0131 al\u0131namad\u0131" + }, + "flow_title": "{host}", "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan deCONZ a\u011f ge\u00e7idine ba\u011flanacak \u015fekilde yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla deCONZ Zigbee a\u011f ge\u00e7idi" + }, + "link": { + "description": "Home Assistant'a kaydolmak i\u00e7in deCONZ a\u011f ge\u00e7idinizin kilidini a\u00e7\u0131n. \n\n 1. deCONZ Ayarlar\u0131 - > A\u011f Ge\u00e7idi - > Geli\u015fmi\u015f se\u00e7ene\u011fine gidin\n 2. \"Uygulaman\u0131n kimli\u011fini do\u011frula\" d\u00fc\u011fmesine bas\u0131n", + "title": "deCONZ ile ba\u011flant\u0131" + }, "manual_input": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "port": "Port" } }, @@ -20,17 +36,51 @@ }, "device_automation": { "trigger_subtype": { + "both_buttons": "\u00c7ift d\u00fc\u011fmeler", + "bottom_buttons": "Alt d\u00fc\u011fmeler", + "button_1": "\u0130lk d\u00fc\u011fme", + "button_2": "\u0130kinci d\u00fc\u011fme", + "button_3": "\u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fme", + "button_4": "D\u00f6rd\u00fcnc\u00fc d\u00fc\u011fme", + "button_5": "Be\u015finci d\u00fc\u011fme", + "button_6": "Alt\u0131nc\u0131 d\u00fc\u011fme", + "button_7": "Yedinci d\u00fc\u011fme", + "button_8": "Sekizinci d\u00fc\u011fme", + "close": "Kapat", + "dim_down": "K\u0131sma", + "dim_up": "A\u00e7ma", + "left": "Sol", + "open": "A\u00e7\u0131k", + "right": "Sa\u011f", + "side_1": "Yan 1", + "side_2": "Yan 2", + "side_3": "Yan 3", "side_4": "Yan 4", "side_5": "Yan 5", - "side_6": "Yan 6" + "side_6": "Yan 6", + "top_buttons": "\u00dcst d\u00fc\u011fmeler", + "turn_off": "Kapat", + "turn_on": "A\u00e7\u0131n" }, "trigger_type": { "remote_awakened": "Cihaz uyand\u0131", + "remote_button_double_press": "\" {subtype} \" d\u00fc\u011fmesine \u00e7ift t\u0131kland\u0131", + "remote_button_long_press": "\" {subtype} \" d\u00fc\u011fmesi s\u00fcrekli bas\u0131l\u0131", + "remote_button_long_release": "\" {subtype} \" d\u00fc\u011fmesi uzun bas\u0131ld\u0131ktan sonra b\u0131rak\u0131ld\u0131", + "remote_button_quadruple_press": "\" {subtype} \" d\u00fc\u011fmesi d\u00f6rt kez t\u0131kland\u0131", + "remote_button_quintuple_press": "\" {subtype} \" d\u00fc\u011fmesi be\u015f kez t\u0131kland\u0131", + "remote_button_rotated": "D\u00fc\u011fme d\u00f6nd\u00fcr\u00fcld\u00fc \" {subtype} \"", + "remote_button_rotated_fast": "D\u00fc\u011fme h\u0131zl\u0131 d\u00f6nd\u00fcr\u00fcld\u00fc \" {subtype} \"", + "remote_button_rotation_stopped": "{subtype} \" d\u00fc\u011fmesinin d\u00f6nd\u00fcr\u00fclmesi durduruldu", + "remote_button_short_press": "\" {subtype} \" d\u00fc\u011fmesine bas\u0131ld\u0131", + "remote_button_short_release": "\" {subtype} \" d\u00fc\u011fmesi b\u0131rak\u0131ld\u0131", + "remote_button_triple_press": "\" {subtype} \" d\u00fc\u011fmesine \u00fc\u00e7 kez t\u0131kland\u0131", "remote_double_tap": "\" {subtype} \" cihaz\u0131na iki kez hafif\u00e7e vuruldu", "remote_double_tap_any_side": "Cihaz herhangi bir tarafta \u00e7ift dokundu", "remote_falling": "Serbest d\u00fc\u015f\u00fc\u015fte cihaz", "remote_flip_180_degrees": "Cihaz 180 derece d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_flip_90_degrees": "Cihaz 90 derece d\u00f6nd\u00fcr\u00fcld\u00fc", + "remote_gyro_activated": "Cihaz salland\u0131", "remote_moved": "Cihaz \" {subtype} \" yukar\u0131 ta\u015f\u0131nd\u0131", "remote_moved_any_side": "Cihaz herhangi bir taraf\u0131 yukar\u0131 gelecek \u015fekilde ta\u015f\u0131nd\u0131", "remote_rotate_from_side_1": "Cihaz, \"1. taraftan\" \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", @@ -38,6 +88,7 @@ "remote_rotate_from_side_3": "Cihaz \"3. taraftan\" \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_rotate_from_side_4": "Cihaz, \"4. taraf\" dan \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_rotate_from_side_5": "Cihaz, \"5. taraf\" dan \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", + "remote_rotate_from_side_6": "Cihaz \"yan 6\" \" {subtype} \" konumuna d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_turned_clockwise": "Cihaz saat y\u00f6n\u00fcnde d\u00f6nd\u00fc", "remote_turned_counter_clockwise": "Cihaz saat y\u00f6n\u00fcn\u00fcn tersine d\u00f6nd\u00fc" } @@ -45,6 +96,12 @@ "options": { "step": { "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP sens\u00f6rlerine izin ver", + "allow_deconz_groups": "deCONZ \u0131\u015f\u0131k gruplar\u0131na izin ver", + "allow_new_devices": "Yeni cihazlar\u0131n otomatik eklenmesine izin ver" + }, + "description": "deCONZ cihaz t\u00fcrlerinin g\u00f6r\u00fcn\u00fcrl\u00fc\u011f\u00fcn\u00fc yap\u0131land\u0131r\u0131n", "title": "deCONZ se\u00e7enekleri" } } diff --git a/homeassistant/components/deconz/translations/zh-Hant.json b/homeassistant/components/deconz/translations/zh-Hant.json index a80afaf46954f..b93cb320993db 100644 --- a/homeassistant/components/deconz/translations/zh-Hant.json +++ b/homeassistant/components/deconz/translations/zh-Hant.json @@ -11,7 +11,7 @@ "error": { "no_key": "\u7121\u6cd5\u53d6\u5f97 API key" }, - "flow_title": "deCONZ Zigbee \u9598\u9053\u5668\uff08{host}\uff09", + "flow_title": "{host}", "step": { "hassio_confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u9023\u7dda\u81f3 deCONZ \u9598\u9053\u5668\u3002\u9644\u52a0\u5143\u4ef6\u70ba\uff1a{addon} \uff1f", diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index 45c42c4bb1c2e..2564ff0cd9e2a 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -8,6 +8,7 @@ import decora # pylint: disable=import-error import voluptuous as vol +from homeassistant import util from homeassistant.components.light import ( ATTR_BRIGHTNESS, PLATFORM_SCHEMA, @@ -16,7 +17,6 @@ ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME import homeassistant.helpers.config_validation as cv -import homeassistant.util as util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 74c6b228a6f44..88f86034aeaae 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -7,9 +7,11 @@ "cloud", "counter", "dhcp", + "energy", "frontend", "history", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", @@ -19,6 +21,7 @@ "media_source", "mobile_app", "my", + "network", "person", "scene", "script", @@ -27,6 +30,7 @@ "system_health", "tag", "timer", + "usb", "updater", "webhook", "zeroconf", diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index cff93c899541e..0abb94e6deff4 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -5,8 +5,12 @@ from pydelijn.common import HttpException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) +from homeassistant.const import CONF_API_KEY from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -32,6 +36,15 @@ } ) +AUTO_ATTRIBUTES = ( + "line_number_public", + "line_transport_type", + "final_destination", + "due_at_schedule", + "due_at_realtime", + "is_realtime", +) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Create the sensor.""" @@ -60,71 +73,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class DeLijnPublicTransportSensor(SensorEntity): """Representation of a Ruter sensor.""" + _attr_attribution = ATTRIBUTION + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_icon = "mdi:bus" + def __init__(self, line): """Initialize the sensor.""" self.line = line - self._attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - self._name = None - self._state = None - self._available = True + self._attr_extra_state_attributes = {} async def async_update(self): """Get the latest data from the De Lijn API.""" try: await self.line.get_passages() - self._name = await self.line.get_stopname() + self._attr_name = await self.line.get_stopname() except HttpException: - self._available = False + self._attr_available = False _LOGGER.error("De Lijn http error") return - self._attributes["stopname"] = self._name + self._attr_extra_state_attributes["stopname"] = self._attr_name + + if not self.line.passages: + self._attr_available = False + return try: first = self.line.passages[0] - if first["due_at_realtime"] is not None: - first_passage = first["due_at_realtime"] - else: + if (first_passage := first["due_at_realtime"]) is None: first_passage = first["due_at_schedule"] - self._state = first_passage - self._attributes["line_number_public"] = first["line_number_public"] - self._attributes["line_transport_type"] = first["line_transport_type"] - self._attributes["final_destination"] = first["final_destination"] - self._attributes["due_at_schedule"] = first["due_at_schedule"] - self._attributes["due_at_realtime"] = first["due_at_realtime"] - self._attributes["is_realtime"] = first["is_realtime"] - self._attributes["next_passages"] = self.line.passages - self._available = True - except (KeyError, IndexError): - _LOGGER.error("Invalid data received from De Lijn") - self._available = False - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:bus" - - @property - def extra_state_attributes(self): - """Return attributes for the sensor.""" - return self._attributes + self._attr_native_value = first_passage + + for key in AUTO_ATTRIBUTES: + self._attr_extra_state_attributes[key] = first[key] + self._attr_extra_state_attributes["next_passages"] = self.line.passages + + self._attr_available = True + except (KeyError) as error: + _LOGGER.error("Invalid data received from De Lijn: %s", error) + self._attr_available = False diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 0c79e6f835e8c..a5667f350647e 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -1,10 +1,16 @@ """Support for monitoring the Deluge BitTorrent client API.""" +from __future__ import annotations + import logging from deluge_client import DelugeRPCClient, FailedToReconnectException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, @@ -25,11 +31,24 @@ DEFAULT_PORT = 58846 DHT_UPLOAD = 1000 DHT_DOWNLOAD = 1000 -SENSOR_TYPES = { - "current_status": ["Status", None], - "download_speed": ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - "upload_speed": ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_status", + name="Status", + ), + SensorEntityDescription( + key="download_speed", + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key="upload_speed", + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -39,7 +58,7 @@ vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -60,46 +79,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except ConnectionRefusedError as err: _LOGGER.error("Connection to Deluge Daemon failed") raise PlatformNotReady from err - dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - dev.append(DelugeSensor(variable, deluge_api, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + DelugeSensor(deluge_api, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev) + add_entities(entities) class DelugeSensor(SensorEntity): """Representation of a Deluge sensor.""" - def __init__(self, sensor_type, deluge_client, client_name): + def __init__( + self, deluge_client, client_name, description: SensorEntityDescription + ): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = deluge_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self.data = None - self._available = False - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_available = False + self._attr_name = f"{client_name} {description.name}" def update(self): """Get the latest data from Deluge and updates the state.""" @@ -114,34 +116,35 @@ def update(self): "dht_download_rate", ], ) - self._available = True + self._attr_available = True except FailedToReconnectException: _LOGGER.error("Connection to Deluge Daemon Lost") - self._available = False + self._attr_available = False return upload = self.data[b"upload_rate"] - self.data[b"dht_upload_rate"] download = self.data[b"download_rate"] - self.data[b"dht_download_rate"] - if self.type == "current_status": + sensor_type = self.entity_description.key + if sensor_type == "current_status": if self.data: if upload > 0 and download > 0: - self._state = "Up/Down" + self._attr_native_value = "Up/Down" elif upload > 0 and download == 0: - self._state = "Seeding" + self._attr_native_value = "Seeding" elif upload == 0 and download > 0: - self._state = "Downloading" + self._attr_native_value = "Downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE else: - self._state = None + self._attr_native_value = None if self.data: - if self.type == "download_speed": + if sensor_type == "download_speed": kb_spd = float(download) kb_spd = kb_spd / 1024 - self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) - elif self.type == "upload_speed": + self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) + elif sensor_type == "upload_speed": kb_spd = float(upload) kb_spd = kb_spd / 1024 - self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) + self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 2aff1b5266c25..bc94b7ad014c5 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -68,11 +68,6 @@ def name(self): """Return the name of the switch.""" return self._name - @property - def state(self): - """Return the state of the device.""" - return self._state - @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b32537ae44e01..3a1fbff2f87f7 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -1,9 +1,20 @@ """Set up the demo environment that mimics interaction with devices.""" import asyncio +import datetime +from random import random from homeassistant import bootstrap, config_entries -from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_START, + SOUND_PRESSURE_DB, +) import homeassistant.core as ha +import homeassistant.util.dt as dt_util DOMAIN = "demo" @@ -11,6 +22,7 @@ "air_quality", "alarm_control_panel", "binary_sensor", + "button", "camera", "climate", "cover", @@ -20,7 +32,9 @@ "lock", "media_player", "number", + "select", "sensor", + "siren", "switch", "vacuum", "water_heater", @@ -105,6 +119,22 @@ async def async_setup(hass, config): ) ) + # Set up input button + tasks.append( + bootstrap.async_setup_component( + hass, + "input_button", + { + "input_button": { + "bell": { + "icon": "mdi:bell-ring-outline", + "name": "Ring bell", + } + } + }, + ) + ) + # Set up input number tasks.append( bootstrap.async_setup_component( @@ -117,7 +147,7 @@ async def async_setup(hass, config): "min": 0, "max": 10, "name": "Allowed Noise", - "unit_of_measurement": "dB", + "unit_of_measurement": SOUND_PRESSURE_DB, } } }, @@ -143,6 +173,82 @@ async def demo_start_listener(_event): return True +def _generate_mean_statistics(start, end, init_value, max_diff): + statistics = [] + mean = init_value + now = start + while now < end: + mean = mean + random() * max_diff - max_diff / 2 + statistics.append( + { + "start": now, + "mean": mean, + "min": mean - random() * max_diff, + "max": mean + random() * max_diff, + } + ) + now = now + datetime.timedelta(hours=1) + + return statistics + + +def _generate_sum_statistics(start, end, init_value, max_diff): + statistics = [] + now = start + sum_ = init_value + while now < end: + sum_ = sum_ + random() * max_diff + statistics.append( + { + "start": now, + "sum": sum_, + } + ) + now = now + datetime.timedelta(hours=1) + + return statistics + + +async def _insert_statistics(hass): + """Insert some fake statistics.""" + now = dt_util.now() + yesterday = now - datetime.timedelta(days=1) + yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + + # Fake yesterday's temperatures + metadata = { + "source": DOMAIN, + "statistic_id": f"{DOMAIN}:temperature_outdoor", + "unit_of_measurement": "°C", + "has_mean": True, + "has_sum": False, + } + statistics = _generate_mean_statistics( + yesterday_midnight, yesterday_midnight + datetime.timedelta(days=1), 15, 1 + ) + async_add_external_statistics(hass, metadata, statistics) + + # Fake yesterday's energy consumption + metadata = { + "source": DOMAIN, + "statistic_id": f"{DOMAIN}:energy_consumption", + "unit_of_measurement": "kWh", + "has_mean": False, + "has_sum": True, + } + statistic_id = f"{DOMAIN}:energy_consumption" + sum_ = 0 + last_stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True + ) + if "domain:energy_consumption" in last_stats: + sum_ = last_stats["domain.electricity_total"]["sum"] or 0 + statistics = _generate_sum_statistics( + yesterday_midnight, yesterday_midnight + datetime.timedelta(days=1), sum_, 1 + ) + async_add_external_statistics(hass, metadata, statistics) + + async def async_setup_entry(hass, config_entry): """Set the config entry up.""" # Set up demo platforms with config entry @@ -150,6 +256,8 @@ async def async_setup_entry(hass, config_entry): hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, platform) ) + if "recorder" in hass.config.components: + await _insert_statistics(hass) return True diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index d5bb71da67b01..5a4485c736590 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -10,6 +10,7 @@ STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -42,6 +43,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, + STATE_ALARM_ARMED_VACATION: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, STATE_ALARM_DISARMED: { CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index c4186eae5056b..8710308fc678e 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -1,9 +1,9 @@ """Demo platform that has two fake binary sensors.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, + BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN @@ -13,10 +13,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities( [ DemoBinarySensor( - "binary_1", "Basement Floor Wet", False, DEVICE_CLASS_MOISTURE + "binary_1", + "Basement Floor Wet", + False, + BinarySensorDeviceClass.MOISTURE, ), DemoBinarySensor( - "binary_2", "Movement Backyard", True, DEVICE_CLASS_MOTION + "binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION ), ] ) @@ -30,7 +33,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoBinarySensor(BinarySensorEntity): """representation of a Demo binary sensor.""" - def __init__(self, unique_id, name, state, device_class): + def __init__( + self, + unique_id: str, + name: str, + state: bool, + device_class: BinarySensorDeviceClass, + ) -> None: """Initialize the demo sensor.""" self._unique_id = unique_id self._name = name @@ -38,15 +47,15 @@ def __init__(self, unique_id, name, state, device_class): self._sensor_type = device_class @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - "name": self.name, - } + name=self.name, + ) @property def unique_id(self): @@ -54,7 +63,7 @@ def unique_id(self): return self._unique_id @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass: """Return the class of this sensor.""" return self._sensor_type diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py new file mode 100644 index 0000000000000..9ef54f30db3bc --- /dev/null +++ b/homeassistant/components/demo/button.py @@ -0,0 +1,65 @@ +"""Demo platform that offers a fake button entity.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the demo Button entity.""" + async_add_entities( + [ + DemoButton( + unique_id="push", + name="Push", + icon="mdi:gesture-tap-button", + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoButton(ButtonEntity): + """Representation of a demo button entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + icon: str, + ) -> None: + """Initialize the Demo button entity.""" + self._attr_unique_id = unique_id + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_icon = icon + self._attr_device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": name, + } + + async def async_press(self) -> None: + """Send out a persistent notification.""" + self.hass.components.persistent_notification.async_create( + "Button pressed", title="Button" + ) diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 56726bba8b7ea..5131741617ed5 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -1,4 +1,6 @@ """Demo camera platform that has a fake camera.""" +from __future__ import annotations + from pathlib import Path from homeassistant.components.camera import SUPPORT_ON_OFF, Camera @@ -6,7 +8,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Demo camera platform.""" - async_add_entities([DemoCamera("Demo camera")]) + async_add_entities( + [ + DemoCamera("Demo camera", "image/jpg"), + DemoCamera("Demo camera png", "image/png"), + ] + ) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -17,57 +24,45 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoCamera(Camera): """The representation of a Demo camera.""" - def __init__(self, name): + _attr_is_streaming = True + _attr_motion_detection_enabled = False + _attr_supported_features = SUPPORT_ON_OFF + + def __init__(self, name, content_type): """Initialize demo camera component.""" super().__init__() - self._name = name - self._motion_status = False - self.is_streaming = True + self._attr_name = name + self.content_type = content_type self._images_index = 0 - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes: """Return a faked still image response.""" self._images_index = (self._images_index + 1) % 4 - image_path = Path(__file__).parent / f"demo_{self._images_index}.jpg" + ext = "jpg" if self.content_type == "image/jpg" else "png" + image_path = Path(__file__).parent / f"demo_{self._images_index}.{ext}" return await self.hass.async_add_executor_job(image_path.read_bytes) - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def supported_features(self): - """Camera support turn on/off features.""" - return SUPPORT_ON_OFF - - @property - def is_on(self): - """Whether camera is on (streaming).""" - return self.is_streaming - - @property - def motion_detection_enabled(self): - """Camera Motion Detection Status.""" - return self._motion_status - async def async_enable_motion_detection(self): """Enable the Motion detection in base station (Arm).""" - self._motion_status = True + self._attr_motion_detection_enabled = True self.async_write_ha_state() async def async_disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" - self._motion_status = False + self._attr_motion_detection_enabled = False self.async_write_ha_state() async def async_turn_off(self): """Turn off camera.""" - self.is_streaming = False + self._attr_is_streaming = False + self._attr_is_on = False self.async_write_ha_state() async def async_turn_on(self): """Turn on camera.""" - self.is_streaming = True + self._attr_is_streaming = True + self._attr_is_on = True self.async_write_ha_state() diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index a6d166662ae3d..ff8e0c256c62a 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -20,6 +20,7 @@ SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN @@ -154,15 +155,15 @@ def __init__( self._target_temperature_low = target_temp_low @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - "name": self.name, - } + name=self.name, + ) @property def unique_id(self): diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 35f25df5a965d..b4ca55416906f 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -1,4 +1,6 @@ """Demo platform for the cover component.""" +from __future__ import annotations + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -8,9 +10,11 @@ SUPPORT_OPEN_TILT, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP_TILT, + CoverDeviceClass, CoverEntity, ) from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_utc_time_change from . import DOMAIN @@ -27,7 +31,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hass, "cover_4", "Garage Door", - device_class="garage", + device_class=CoverDeviceClass.GARAGE, supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE), ), DemoCover( @@ -86,15 +90,15 @@ def __init__( self._closed = self.current_cover_position <= 0 @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - "name": self.name, - } + name=self.name, + ) @property def unique_id(self): @@ -137,7 +141,7 @@ def is_opening(self): return self._is_opening @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Return the class of this device, from component DEVICE_CLASSES.""" return self._device_class diff --git a/homeassistant/components/demo/demo_0.png b/homeassistant/components/demo/demo_0.png new file mode 100644 index 0000000000000..f45852e3b20f9 Binary files /dev/null and b/homeassistant/components/demo/demo_0.png differ diff --git a/homeassistant/components/demo/demo_1.png b/homeassistant/components/demo/demo_1.png new file mode 100644 index 0000000000000..0a2131a773e5a Binary files /dev/null and b/homeassistant/components/demo/demo_1.png differ diff --git a/homeassistant/components/demo/demo_2.png b/homeassistant/components/demo/demo_2.png new file mode 100644 index 0000000000000..97a8e49025d4a Binary files /dev/null and b/homeassistant/components/demo/demo_2.png differ diff --git a/homeassistant/components/demo/demo_3.png b/homeassistant/components/demo/demo_3.png new file mode 100644 index 0000000000000..0a2131a773e5a Binary files /dev/null and b/homeassistant/components/demo/demo_3.png differ diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index c406ffdb2143e..cf5cb7708d2b9 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -2,10 +2,6 @@ from __future__ import annotations from homeassistant.components.fan import ( - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_PRESET_MODE, @@ -26,33 +22,25 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the demo fan platform.""" async_add_entities( [ - # These fans implement the old model - DemoFan( + DemoPercentageFan( hass, "fan1", "Living Room Fan", FULL_SUPPORT, - None, [ - SPEED_OFF, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, PRESET_MODE_AUTO, PRESET_MODE_SMART, PRESET_MODE_SLEEP, PRESET_MODE_ON, ], ), - DemoFan( + DemoPercentageFan( hass, "fan2", "Ceiling Fan", LIMITED_SUPPORT, None, - [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], ), - # These fans implement the newer model AsyncDemoPercentageFan( hass, "fan3", @@ -64,7 +52,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= PRESET_MODE_SLEEP, PRESET_MODE_ON, ], - None, ), DemoPercentageFan( hass, @@ -77,7 +64,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= PRESET_MODE_SLEEP, PRESET_MODE_ON, ], - None, ), AsyncDemoPercentageFan( hass, @@ -90,7 +76,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= PRESET_MODE_SLEEP, PRESET_MODE_ON, ], - [], ), ] ) @@ -111,15 +96,12 @@ def __init__( name: str, supported_features: int, preset_modes: list[str] | None, - speed_list: list[str] | None, ) -> None: """Initialize the entity.""" self.hass = hass self._unique_id = unique_id self._supported_features = supported_features - self._speed = SPEED_OFF self._percentage = None - self._speed_list = speed_list self._preset_modes = preset_modes self._preset_mode = None self._oscillating = None @@ -161,52 +143,6 @@ def supported_features(self) -> int: return self._supported_features -class DemoFan(BaseDemoFan, FanEntity): - """A demonstration fan component that uses legacy fan speeds.""" - - @property - def speed(self) -> str: - """Return the current speed.""" - return self._speed - - @property - def speed_list(self): - """Return the speed list.""" - return self._speed_list - - def turn_on( - self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, - **kwargs, - ) -> None: - """Turn on the entity.""" - if speed is None: - speed = SPEED_MEDIUM - self.set_speed(speed) - - def turn_off(self, **kwargs) -> None: - """Turn off the entity.""" - self.oscillate(False) - self.set_speed(SPEED_OFF) - - def set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - self._speed = speed - self.schedule_update_ha_state() - - def set_direction(self, direction: str) -> None: - """Set the direction of the fan.""" - self._direction = direction - self.schedule_update_ha_state() - - def oscillate(self, oscillating: bool) -> None: - """Set oscillation.""" - self._oscillating = oscillating - self.schedule_update_ha_state() - - class DemoPercentageFan(BaseDemoFan, FanEntity): """A demonstration fan component that uses percentages.""" @@ -238,7 +174,7 @@ def preset_modes(self) -> list[str] | None: def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if preset_mode in self.preset_modes: + if self.preset_modes and preset_mode in self.preset_modes: self._preset_mode = preset_mode self._percentage = None self.schedule_update_ha_state() @@ -266,6 +202,16 @@ def turn_off(self, **kwargs) -> None: """Turn off the entity.""" self.set_percentage(0) + def set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._direction = direction + self.schedule_update_ha_state() + + def oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + self._oscillating = oscillating + self.schedule_update_ha_state() + class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): """An async demonstration fan component that uses percentages.""" diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 35eb6e1853753..9065c5971fbe4 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -1,10 +1,8 @@ """Demo platform that offers a fake humidifier device.""" -from homeassistant.components.humidifier import HumidifierEntity -from homeassistant.components.humidifier.const import ( - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, - SUPPORT_MODES, -) +from __future__ import annotations + +from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity +from homeassistant.components.humidifier.const import SUPPORT_MODES SUPPORT_FLAGS = 0 @@ -17,13 +15,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name="Humidifier", mode=None, target_humidity=68, - device_class=DEVICE_CLASS_HUMIDIFIER, + device_class=HumidifierDeviceClass.HUMIDIFIER, ), DemoHumidifier( name="Dehumidifier", mode=None, target_humidity=54, - device_class=DEVICE_CLASS_DEHUMIDIFIER, + device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), DemoHumidifier( name="Hygrostat", @@ -43,82 +41,46 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoHumidifier(HumidifierEntity): """Representation of a demo humidifier device.""" + _attr_should_poll = False + def __init__( self, - name, - mode, - target_humidity, - available_modes=None, - is_on=True, - device_class=None, - ): + name: str, + mode: str | None, + target_humidity: int, + available_modes: list[str] | None = None, + is_on: bool = True, + device_class: HumidifierDeviceClass | None = None, + ) -> None: """Initialize the humidifier device.""" - self._name = name - self._state = is_on - self._support_flags = SUPPORT_FLAGS + self._attr_name = name + self._attr_is_on = is_on + self._attr_supported_features = SUPPORT_FLAGS if mode is not None: - self._support_flags = self._support_flags | SUPPORT_MODES - self._target_humidity = target_humidity - self._mode = mode - self._available_modes = available_modes - self._device_class = device_class - - @property - def supported_features(self): - """Return the list of supported features.""" - return self._support_flags - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the humidity device.""" - return self._name - - @property - def target_humidity(self): - """Return the humidity we try to reach.""" - return self._target_humidity - - @property - def mode(self): - """Return current mode.""" - return self._mode - - @property - def available_modes(self): - """Return available modes.""" - return self._available_modes - - @property - def is_on(self): - """Return true if the humidifier is on.""" - return self._state - - @property - def device_class(self): - """Return the device class of the humidifier.""" - return self._device_class + self._attr_supported_features = ( + self._attr_supported_features | SUPPORT_MODES + ) + self._attr_target_humidity = target_humidity + self._attr_mode = mode + self._attr_available_modes = available_modes + self._attr_device_class = device_class async def async_turn_on(self, **kwargs): """Turn the device on.""" - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off.""" - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_set_humidity(self, humidity): """Set new humidity level.""" - self._target_humidity = humidity + self._attr_target_humidity = humidity self.async_write_ha_state() async def async_set_mode(self, mode): """Update mode.""" - self._mode = mode + self._attr_mode = mode self.async_write_ha_state() diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 6680cd238743b..d14f7ffba0e60 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -10,13 +10,16 @@ ATTR_HS_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, + ATTR_WHITE, COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, + COLOR_MODE_WHITE, SUPPORT_EFFECT, LightEntity, ) +from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN @@ -27,6 +30,7 @@ LIGHT_TEMPS = [240, 380] SUPPORT_DEMO = {COLOR_MODE_HS, COLOR_MODE_COLOR_TEMP} +SUPPORT_DEMO_HS_WHITE = {COLOR_MODE_HS, COLOR_MODE_WHITE} async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -72,6 +76,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= supported_color_modes={COLOR_MODE_RGBWW}, unique_id="light_5", ), + DemoLight( + available=True, + name="Entrance Color + White Lights", + hs_color=LIGHT_COLORS[1], + state=True, + supported_color_modes=SUPPORT_DEMO_HS_WHITE, + unique_id="light_6", + ), ] ) @@ -91,7 +103,7 @@ def __init__( state, available=False, brightness=180, - ct=None, + ct=None, # pylint: disable=invalid-name effect_list=None, effect=None, hs_color=None, @@ -127,15 +139,15 @@ def __init__( self._features |= SUPPORT_EFFECT @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - "name": self.name, - } + name=self.name, + ) @property def should_poll(self) -> bool: @@ -218,27 +230,31 @@ async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" self._state = True - if ATTR_RGBW_COLOR in kwargs: - self._color_mode = COLOR_MODE_RGBW - self._rgbw_color = kwargs[ATTR_RGBW_COLOR] + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGBWW_COLOR in kwargs: - self._color_mode = COLOR_MODE_RGBWW - self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] + if ATTR_COLOR_TEMP in kwargs: + self._color_mode = COLOR_MODE_COLOR_TEMP + self._ct = kwargs[ATTR_COLOR_TEMP] + + if ATTR_EFFECT in kwargs: + self._effect = kwargs[ATTR_EFFECT] if ATTR_HS_COLOR in kwargs: self._color_mode = COLOR_MODE_HS self._hs_color = kwargs[ATTR_HS_COLOR] - if ATTR_COLOR_TEMP in kwargs: - self._color_mode = COLOR_MODE_COLOR_TEMP - self._ct = kwargs[ATTR_COLOR_TEMP] + if ATTR_RGBW_COLOR in kwargs: + self._color_mode = COLOR_MODE_RGBW + self._rgbw_color = kwargs[ATTR_RGBW_COLOR] - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] + if ATTR_RGBWW_COLOR in kwargs: + self._color_mode = COLOR_MODE_RGBWW + self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] - if ATTR_EFFECT in kwargs: - self._effect = kwargs[ATTR_EFFECT] + if ATTR_WHITE in kwargs: + self._color_mode = COLOR_MODE_WHITE + self._brightness = kwargs[ATTR_WHITE] # As we have disabled polling, we need to inform # Home Assistant about updates in our state ourselves. diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 63f2d2189579f..c46e9f5eebe1a 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -1,6 +1,16 @@ """Demo lock platform that has two fake locks.""" +import asyncio + from homeassistant.components.lock import SUPPORT_OPEN, LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) + +LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in frontend async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -9,6 +19,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [ DemoLock("Front Door", STATE_LOCKED), DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), DemoLock("Openable Lock", STATE_LOCKED, True), ] ) @@ -22,44 +33,70 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoLock(LockEntity): """Representation of a Demo lock.""" - def __init__(self, name, state, openable=False): + _attr_should_poll = False + + def __init__( + self, + name: str, + state: str, + openable: bool = False, + jam_on_operation: bool = False, + ) -> None: """Initialize the lock.""" - self._name = name + self._attr_name = name + if openable: + self._attr_supported_features = SUPPORT_OPEN self._state = state self._openable = openable + self._jam_on_operation = jam_on_operation + + @property + def is_locking(self): + """Return true if lock is locking.""" + return self._state == STATE_LOCKING @property - def should_poll(self): - """No polling needed for a demo lock.""" - return False + def is_unlocking(self): + """Return true if lock is unlocking.""" + return self._state == STATE_UNLOCKING @property - def name(self): - """Return the name of the lock if any.""" - return self._name + def is_jammed(self): + """Return true if lock is jammed.""" + return self._state == STATE_JAMMED @property def is_locked(self): """Return true if lock is locked.""" return self._state == STATE_LOCKED - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the device.""" - self._state = STATE_LOCKED - self.schedule_update_ha_state() + self._state = STATE_LOCKING + self.async_write_ha_state() + await asyncio.sleep(LOCK_UNLOCK_DELAY) + if self._jam_on_operation: + self._state = STATE_JAMMED + else: + self._state = STATE_LOCKED + self.async_write_ha_state() - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" + self._state = STATE_UNLOCKING + self.async_write_ha_state() + await asyncio.sleep(LOCK_UNLOCK_DELAY) self._state = STATE_UNLOCKED - self.schedule_update_ha_state() + self.async_write_ha_state() - def open(self, **kwargs): + async def async_open(self, **kwargs): """Open the door latch.""" self._state = STATE_UNLOCKED - self.schedule_update_ha_state() + self.async_write_ha_state() @property def supported_features(self): """Flag supported features.""" if self._openable: return SUPPORT_OPEN + return 0 diff --git a/homeassistant/components/demo/manifest.json b/homeassistant/components/demo/manifest.json index 0997868fbfd9f..df6fa494079b4 100644 --- a/homeassistant/components/demo/manifest.json +++ b/homeassistant/components/demo/manifest.json @@ -2,7 +2,8 @@ "domain": "demo", "name": "Demo", "documentation": "https://www.home-assistant.io/integrations/demo", - "dependencies": ["conversation", "zone", "group"], + "after_dependencies": ["recorder"], + "dependencies": ["conversation", "group", "zone"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "calculated" diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index ea315707deae9..cc02ecba175a8 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -17,6 +17,7 @@ SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, @@ -66,6 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): | SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SEEK + | SUPPORT_STOP ) MUSIC_PLAYER_SUPPORT = ( @@ -83,6 +85,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOUND_MODE + | SUPPORT_STOP ) NETFLIX_PLAYER_SUPPORT = ( @@ -95,6 +98,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOUND_MODE + | SUPPORT_STOP ) @@ -199,6 +203,11 @@ def media_pause(self): self._player_state = STATE_PAUSED self.schedule_update_ha_state() + def media_stop(self): + """Send stop command.""" + self._player_state = STATE_OFF + self.schedule_update_ha_state() + def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" self._shuffle = shuffle diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index f3fd815f621b2..2625d8bca05b9 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -1,8 +1,9 @@ """Demo platform that offers a fake Number entity.""" -import voluptuous as vol +from __future__ import annotations -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN @@ -17,6 +18,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 42.0, "mdi:volume-high", False, + mode=NumberMode.SLIDER, ), DemoNumber( "pwm1", @@ -27,6 +29,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 0.0, 1.0, 0.01, + NumberMode.BOX, + ), + DemoNumber( + "large_range", + "Large Range", + 500, + "mdi:square-wave", + False, + 1, + 1000, + 1, + ), + DemoNumber( + "small_range", + "Small Range", + 128, + "mdi:square-wave", + False, + 1, + 255, + 1, ), ] ) @@ -40,91 +63,47 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoNumber(NumberEntity): """Representation of a demo Number entity.""" + _attr_should_poll = False + def __init__( self, - unique_id, - name, - state, - icon, - assumed, - min_value=None, - max_value=None, - step=None, - ): + unique_id: str, + name: str, + state: float, + icon: str, + assumed: bool, + min_value: float | None = None, + max_value: float | None = None, + step: float | None = None, + mode: NumberMode = NumberMode.AUTO, + ) -> None: """Initialize the Demo Number entity.""" - self._unique_id = unique_id - self._name = name or DEVICE_DEFAULT_NAME - self._state = state - self._icon = icon - self._assumed = assumed - self._min_value = min_value - self._max_value = max_value - self._step = step + self._attr_assumed_state = assumed + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_unique_id = unique_id + self._attr_value = state + self._attr_mode = mode + + if min_value is not None: + self._attr_min_value = min_value + if max_value is not None: + self._attr_max_value = max_value + if step is not None: + self._attr_step = step @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - "name": self.name, - } - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def should_poll(self): - """No polling needed for a demo Number entity.""" - return False - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def assumed_state(self): - """Return if the state is based on assumptions.""" - return self._assumed - - @property - def value(self): - """Return the current value.""" - return self._state - - @property - def min_value(self): - """Return the minimum value.""" - return self._min_value or super().min_value - - @property - def max_value(self): - """Return the maximum value.""" - return self._max_value or super().max_value - - @property - def step(self): - """Return the value step.""" - return self._step or super().step + name=self.name, + ) async def async_set_value(self, value): """Update the current value.""" - num_value = float(value) - - if num_value < self.min_value or num_value > self.max_value: - raise vol.Invalid( - f"Invalid value for {self.entity_id}: {value} (range {self.min_value} - {self.max_value})" - ) - - self._state = num_value + self._attr_value = value self.async_write_ha_state() diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index 98e949f38c33a..c8e54aa65f3e9 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -1,14 +1,32 @@ """Demo platform that has two fake remotes.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + from homeassistant.components.remote import RemoteEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Demo config entry.""" setup_platform(hass, {}, async_add_entities) -def setup_platform(hass, config, add_entities_callback, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities_callback: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: """Set up the demo remotes.""" add_entities_callback( [ @@ -21,50 +39,33 @@ def setup_platform(hass, config, add_entities_callback, discovery_info=None): class DemoRemote(RemoteEntity): """Representation of a demo remote.""" - def __init__(self, name, state, icon): + _attr_should_poll = False + + def __init__(self, name: str | None, state: bool, icon: str | None) -> None: """Initialize the Demo Remote.""" - self._name = name or DEVICE_DEFAULT_NAME - self._state = state - self._icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_is_on = state + self._attr_icon = icon self._last_command_sent = None @property - def should_poll(self): - """No polling needed for a demo remote.""" - return False - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def is_on(self): - """Return true if remote is on.""" - return self._state - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return device state attributes.""" if self._last_command_sent is not None: return {"last_command_sent": self._last_command_sent} + return None - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the remote off.""" - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - def send_command(self, command, **kwargs): + def send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device.""" for com in command: self._last_command_sent = com diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py new file mode 100644 index 0000000000000..6a768f80ba79f --- /dev/null +++ b/homeassistant/components/demo/select.py @@ -0,0 +1,78 @@ +"""Demo platform that offers a fake select entity.""" +from __future__ import annotations + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the demo Select entity.""" + async_add_entities( + [ + DemoSelect( + unique_id="speed", + name="Speed", + icon="mdi:speedometer", + device_class="demo__speed", + current_option="ridiculous_speed", + options=[ + "light_speed", + "ridiculous_speed", + "ludicrous_speed", + ], + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSelect(SelectEntity): + """Representation of a demo select entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + icon: str, + device_class: str | None, + current_option: str | None, + options: list[str], + ) -> None: + """Initialize the Demo select entity.""" + self._attr_unique_id = unique_id + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_current_option = current_option + self._attr_icon = icon + self._attr_device_class = device_class + self._attr_options = options + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 7607bad4e1ce7..20631e3eee65c 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -1,20 +1,36 @@ """Demo platform that has a couple of fake sensors.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, StateType from . import DOMAIN -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: """Set up the Demo sensors.""" async_add_entities( [ @@ -22,7 +38,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "sensor_1", "Outside Temperature", 15.6, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, TEMP_CELSIUS, 12, ), @@ -30,7 +47,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "sensor_2", "Outside Humidity", 54, - DEVICE_CLASS_HUMIDITY, + SensorDeviceClass.HUMIDITY, + SensorStateClass.MEASUREMENT, PERCENTAGE, None, ), @@ -38,7 +56,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "sensor_3", "Carbon monoxide", 54, - DEVICE_CLASS_CO, + SensorDeviceClass.CO, + SensorStateClass.MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, None, ), @@ -46,15 +65,38 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "sensor_4", "Carbon dioxide", 54, - DEVICE_CLASS_CO2, + SensorDeviceClass.CO2, + SensorStateClass.MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, 14, ), + DemoSensor( + "sensor_5", + "Power consumption", + 100, + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, + POWER_WATT, + None, + ), + DemoSensor( + "sensor_6", + "Today energy", + 15, + SensorDeviceClass.ENERGY, + SensorStateClass.MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + None, + ), ] ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Demo config entry.""" await async_setup_platform(hass, {}, async_add_entities) @@ -62,60 +104,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" + _attr_should_poll = False + def __init__( - self, unique_id, name, state, device_class, unit_of_measurement, battery - ): + self, + unique_id: str, + name: str, + state: StateType, + device_class: SensorDeviceClass, + state_class: SensorStateClass | None, + unit_of_measurement: str | None, + battery: StateType, + ) -> None: """Initialize the sensor.""" - self._unique_id = unique_id - self._name = name - self._state = state - self._device_class = device_class - self._unit_of_measurement = unit_of_measurement - self._battery = battery - - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self.unique_id) - }, - "name": self.name, - } - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def should_poll(self): - """No polling needed for a demo sensor.""" - return False - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self._battery: - return {ATTR_BATTERY_LEVEL: self._battery} + self._attr_device_class = device_class + self._attr_name = name + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_native_value = state + self._attr_state_class = state_class + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) + + if battery: + self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery} diff --git a/homeassistant/components/demo/services.yaml b/homeassistant/components/demo/services.yaml index aed23eed95aa6..a09b4498035bf 100644 --- a/homeassistant/components/demo/services.yaml +++ b/homeassistant/components/demo/services.yaml @@ -1,2 +1,3 @@ randomize_device_tracker_data: + name: Randomize device tracker data description: Demonstrates using a device tracker to see where devices are located diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py new file mode 100644 index 0000000000000..b810e48f95484 --- /dev/null +++ b/homeassistant/components/demo/siren.py @@ -0,0 +1,83 @@ +"""Demo platform that offers a fake siren device.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.siren import SirenEntity +from homeassistant.components.siren.const import ( + SUPPORT_DURATION, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +SUPPORT_FLAGS = SUPPORT_TURN_OFF | SUPPORT_TURN_ON + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the Demo siren devices.""" + async_add_entities( + [ + DemoSiren(name="Siren"), + DemoSiren( + name="Siren with all features", + available_tones=["fire", "alarm"], + support_volume_set=True, + support_duration=True, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo siren devices config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSiren(SirenEntity): + """Representation of a demo siren device.""" + + def __init__( + self, + name: str, + available_tones: str | None = None, + support_volume_set: bool = False, + support_duration: bool = False, + is_on: bool = True, + ) -> None: + """Initialize the siren device.""" + self._attr_name = name + self._attr_should_poll = False + self._attr_supported_features = SUPPORT_FLAGS + self._attr_is_on = is_on + if available_tones is not None: + self._attr_supported_features |= SUPPORT_TONES + if support_volume_set: + self._attr_supported_features |= SUPPORT_VOLUME_SET + if support_duration: + self._attr_supported_features |= SUPPORT_DURATION + self._attr_available_tones = available_tones + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/demo/strings.select.json b/homeassistant/components/demo/strings.select.json new file mode 100644 index 0000000000000..f797ab562bcd9 --- /dev/null +++ b/homeassistant/components/demo/strings.select.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Light Speed", + "ludicrous_speed": "Ludicrous Speed", + "ridiculous_speed": "Ridiculous Speed" + } + } +} diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index cdbeb142677b9..09389f7c8cdbe 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -1,6 +1,9 @@ """Demo platform that has two fake switches.""" -from homeassistant.components.switch import SwitchEntity +from __future__ import annotations + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN @@ -9,14 +12,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the demo switches.""" async_add_entities( [ - DemoSwitch("swith1", "Decorative Lights", True, None, True), + DemoSwitch("switch1", "Decorative Lights", True, None, True), DemoSwitch( - "swith2", + "switch2", "AC", False, "mdi:air-conditioner", False, - device_class="outlet", + device_class=SwitchDeviceClass.OUTLET, ), ] ) @@ -30,78 +33,39 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoSwitch(SwitchEntity): """Representation of a demo switch.""" - def __init__(self, unique_id, name, state, icon, assumed, device_class=None): + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: bool, + icon: str | None, + assumed: bool, + device_class: SwitchDeviceClass | None = None, + ) -> None: """Initialize the Demo switch.""" - self._unique_id = unique_id - self._name = name or DEVICE_DEFAULT_NAME - self._state = state - self._icon = icon - self._assumed = assumed - self._device_class = device_class + self._attr_assumed_state = assumed + self._attr_device_class = device_class + self._attr_icon = icon + self._attr_is_on = state + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_unique_id = unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self.unique_id) - }, - "name": self.name, - } - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def should_poll(self): - """No polling needed for a demo switch.""" - return False - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def assumed_state(self): - """Return if the state is based on assumptions.""" - return self._assumed - - @property - def current_power_w(self): - """Return the current power usage in W.""" - if self._state: - return 100 - - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - return 15 - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state - - @property - def device_class(self): - """Return device of entity.""" - return self._device_class + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.name, + ) def turn_on(self, **kwargs): """Turn the switch on.""" - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/translations/de.json b/homeassistant/components/demo/translations/de.json index 8d737e5e4c91e..fd6239fa78711 100644 --- a/homeassistant/components/demo/translations/de.json +++ b/homeassistant/components/demo/translations/de.json @@ -1,12 +1,6 @@ { "options": { "step": { - "init": { - "data": { - "one": "eins", - "other": "andere" - } - }, "options_1": { "data": { "bool": "Optionaler Boolescher Wert", @@ -17,7 +11,7 @@ "options_2": { "data": { "multi": "Mehrfachauswahl", - "select": "W\u00e4hlen Sie eine Option", + "select": "W\u00e4hle eine Option", "string": "String-Wert" } } diff --git a/homeassistant/components/demo/translations/es-419.json b/homeassistant/components/demo/translations/es-419.json index 8057621520ae9..d7c6160bc30fb 100644 --- a/homeassistant/components/demo/translations/es-419.json +++ b/homeassistant/components/demo/translations/es-419.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Booleano opcional", + "constant": "Constante", "int": "Entrada num\u00e9rica" } }, diff --git a/homeassistant/components/demo/translations/he.json b/homeassistant/components/demo/translations/he.json new file mode 100644 index 0000000000000..7e3349d3abcc1 --- /dev/null +++ b/homeassistant/components/demo/translations/he.json @@ -0,0 +1,21 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u05d1\u05d5\u05dc\u05d9\u05d0\u05e0\u05d9 \u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9", + "constant": "\u05e7\u05d1\u05d5\u05e2", + "int": "\u05e7\u05dc\u05d8 \u05de\u05e1\u05e4\u05e8\u05d9" + } + }, + "options_2": { + "data": { + "multi": "\u05d1\u05d7\u05d9\u05e8\u05d4 \u05de\u05e8\u05d5\u05d1\u05d4", + "select": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea", + "string": "\u05e2\u05e8\u05da \u05de\u05d7\u05e8\u05d5\u05d6\u05ea" + } + } + } + }, + "title": "\u05d4\u05d3\u05d2\u05de\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json index 0f8f1673d432e..87810814aacdf 100644 --- a/homeassistant/components/demo/translations/hu.json +++ b/homeassistant/components/demo/translations/hu.json @@ -1,16 +1,23 @@ { "options": { "step": { + "init": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + } + }, "options_1": { "data": { - "bool": "Opcion\u00e1lis logikai \u00e9rt\u00e9k", + "bool": "Opcion\u00e1lis logikai v\u00e1lt\u00f3", + "constant": "\u00c1lland\u00f3", "int": "Numerikus bemenet" } }, "options_2": { "data": { "multi": "T\u00f6bbsz\u00f6r\u00f6s kijel\u00f6l\u00e9s", - "select": "V\u00e1lassz egy lehet\u0151s\u00e9get", + "select": "V\u00e1lasszon egy lehet\u0151s\u00e9get", "string": "Karakterl\u00e1nc \u00e9rt\u00e9k" } } diff --git a/homeassistant/components/demo/translations/it.json b/homeassistant/components/demo/translations/it.json index dc3e218895bf8..0fb3f52b916ad 100644 --- a/homeassistant/components/demo/translations/it.json +++ b/homeassistant/components/demo/translations/it.json @@ -1,6 +1,12 @@ { "options": { "step": { + "init": { + "data": { + "one": "Pi\u00f9", + "other": "Altri" + } + }, "options_1": { "data": { "bool": "Valore booleano facoltativo", @@ -11,7 +17,7 @@ "options_2": { "data": { "multi": "Selezione multipla", - "select": "Selezionare un'opzione", + "select": "Seleziona un'opzione", "string": "Valore stringa" } } diff --git a/homeassistant/components/demo/translations/ja.json b/homeassistant/components/demo/translations/ja.json index 713cdd6ae3509..d987ee472e256 100644 --- a/homeassistant/components/demo/translations/ja.json +++ b/homeassistant/components/demo/translations/ja.json @@ -1,3 +1,21 @@ { + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u771f\u507d\u5024(booleans)", + "constant": "\u5b9a\u6570", + "int": "\u6570\u5024\u5165\u529b" + } + }, + "options_2": { + "data": { + "multi": "\u30de\u30eb\u30c1\u30bb\u30ec\u30af\u30c8", + "select": "\u9078\u629e\u80a2\u4e00\u3064\u3092\u9078\u629e", + "string": "\u6587\u5b57\u5217\u5024" + } + } + } + }, "title": "\u30c7\u30e2" } \ No newline at end of file diff --git a/homeassistant/components/demo/translations/nl.json b/homeassistant/components/demo/translations/nl.json index 8e7c97f7c3f1b..37b23b2aaac48 100644 --- a/homeassistant/components/demo/translations/nl.json +++ b/homeassistant/components/demo/translations/nl.json @@ -3,8 +3,8 @@ "step": { "init": { "data": { - "one": "Empty", - "other": "" + "one": "Leeg", + "other": "Leeg" } }, "options_1": { diff --git a/homeassistant/components/demo/translations/ro.json b/homeassistant/components/demo/translations/ro.json new file mode 100644 index 0000000000000..96e182c6d5455 --- /dev/null +++ b/homeassistant/components/demo/translations/ro.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "few": "Cateva", + "one": "Unu", + "other": "Altele" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.ar.json b/homeassistant/components/demo/translations/select.ar.json new file mode 100644 index 0000000000000..c151c29acfbd9 --- /dev/null +++ b/homeassistant/components/demo/translations/select.ar.json @@ -0,0 +1,7 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u0633\u0631\u0639\u0629 \u0627\u0644\u0636\u0648\u0621" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.ca.json b/homeassistant/components/demo/translations/select.ca.json new file mode 100644 index 0000000000000..c66c285ffda7a --- /dev/null +++ b/homeassistant/components/demo/translations/select.ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Velocitat de la llum", + "ludicrous_speed": "Velocitat Ludicrous", + "ridiculous_speed": "Velocitat rid\u00edcula" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.de.json b/homeassistant/components/demo/translations/select.de.json new file mode 100644 index 0000000000000..2d801f47c135f --- /dev/null +++ b/homeassistant/components/demo/translations/select.de.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Lichtgeschwindigkeit", + "ludicrous_speed": "Wahnsinnige Geschwindigkeit", + "ridiculous_speed": "L\u00e4cherliche Geschwindigkeit" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.en.json b/homeassistant/components/demo/translations/select.en.json new file mode 100644 index 0000000000000..e7f7c67f4525a --- /dev/null +++ b/homeassistant/components/demo/translations/select.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Light Speed", + "ludicrous_speed": "Ludicrous Speed", + "ridiculous_speed": "Ridiculous Speed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.es-419.json b/homeassistant/components/demo/translations/select.es-419.json new file mode 100644 index 0000000000000..bc66e11847ae4 --- /dev/null +++ b/homeassistant/components/demo/translations/select.es-419.json @@ -0,0 +1,8 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Velocidad de la luz", + "ridiculous_speed": "Velocidad rid\u00edcula" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.es.json b/homeassistant/components/demo/translations/select.es.json new file mode 100644 index 0000000000000..b2a20fdcfeca9 --- /dev/null +++ b/homeassistant/components/demo/translations/select.es.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Velocidad de la luz", + "ludicrous_speed": "Velocidad rid\u00edcula", + "ridiculous_speed": "Velocidad rid\u00edcula" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.et.json b/homeassistant/components/demo/translations/select.et.json new file mode 100644 index 0000000000000..eee34b646cca2 --- /dev/null +++ b/homeassistant/components/demo/translations/select.et.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Valguse kiirus", + "ludicrous_speed": "Meeletu kiirus", + "ridiculous_speed": "Naeruv\u00e4\u00e4rne kiirus" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.fr.json b/homeassistant/components/demo/translations/select.fr.json new file mode 100644 index 0000000000000..d2b214e407896 --- /dev/null +++ b/homeassistant/components/demo/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Vitesse de la lumi\u00e8re", + "ludicrous_speed": "Vitesse ridicule", + "ridiculous_speed": "Vitesse ridicule" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.he.json b/homeassistant/components/demo/translations/select.he.json new file mode 100644 index 0000000000000..bb4bb95d3d540 --- /dev/null +++ b/homeassistant/components/demo/translations/select.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u05de\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05d0\u05d5\u05e8", + "ludicrous_speed": "\u05de\u05d4\u05d9\u05e8\u05d5\u05ea \u05de\u05d2\u05d5\u05d7\u05db\u05ea", + "ridiculous_speed": "\u05de\u05d4\u05d9\u05e8\u05d5\u05ea \u05de\u05d2\u05d5\u05d7\u05db\u05ea" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.hu.json b/homeassistant/components/demo/translations/select.hu.json new file mode 100644 index 0000000000000..4afeff7b1d3a4 --- /dev/null +++ b/homeassistant/components/demo/translations/select.hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "F\u00e9nysebess\u00e9g", + "ludicrous_speed": "Hihetetlen sebess\u00e9g", + "ridiculous_speed": "K\u00e9ptelen sebess\u00e9g" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.id.json b/homeassistant/components/demo/translations/select.id.json new file mode 100644 index 0000000000000..7f3e910999568 --- /dev/null +++ b/homeassistant/components/demo/translations/select.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Kecepatan Cahaya", + "ludicrous_speed": "Kecepatan Menggelikan", + "ridiculous_speed": "Kecepatan Konyol" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.it.json b/homeassistant/components/demo/translations/select.it.json new file mode 100644 index 0000000000000..ba49e1ab60a4f --- /dev/null +++ b/homeassistant/components/demo/translations/select.it.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Velocit\u00e0 della luce", + "ludicrous_speed": "Velocit\u00e0 comica", + "ridiculous_speed": "Velocit\u00e0 ridicola" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.ja.json b/homeassistant/components/demo/translations/select.ja.json new file mode 100644 index 0000000000000..92f56768485b3 --- /dev/null +++ b/homeassistant/components/demo/translations/select.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u30e9\u30a4\u30c8\u306e\u901f\u5ea6", + "ludicrous_speed": "\u3070\u304b\u3052\u305f\u901f\u5ea6", + "ridiculous_speed": "\u3068\u3093\u3067\u3082\u306a\u3044\u901f\u5ea6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.nl.json b/homeassistant/components/demo/translations/select.nl.json new file mode 100644 index 0000000000000..4312d8c4d3498 --- /dev/null +++ b/homeassistant/components/demo/translations/select.nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Lichtsnelheid", + "ludicrous_speed": "Lachwekkende snelheid", + "ridiculous_speed": "Belachelijke snelheid" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.no.json b/homeassistant/components/demo/translations/select.no.json new file mode 100644 index 0000000000000..246195bfd264d --- /dev/null +++ b/homeassistant/components/demo/translations/select.no.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Lyshastighet", + "ludicrous_speed": "Ludicrous Speed", + "ridiculous_speed": "Latterlig hastighet" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.pl.json b/homeassistant/components/demo/translations/select.pl.json new file mode 100644 index 0000000000000..276095d21fb04 --- /dev/null +++ b/homeassistant/components/demo/translations/select.pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "pr\u0119dko\u015b\u0107 \u015bwiat\u0142a", + "ludicrous_speed": "absurdalna pr\u0119dko\u015b\u0107", + "ridiculous_speed": "niewiarygodna pr\u0119dko\u015b\u0107" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.ru.json b/homeassistant/components/demo/translations/select.ru.json new file mode 100644 index 0000000000000..0de63078eebcc --- /dev/null +++ b/homeassistant/components/demo/translations/select.ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u0421\u043a\u043e\u0440\u043e\u0441\u0442\u044c \u0441\u0432\u0435\u0442\u0430", + "ludicrous_speed": "\u0427\u0443\u0434\u043e\u0432\u0438\u0449\u043d\u0430\u044f \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c", + "ridiculous_speed": "\u041d\u0435\u0432\u0435\u0440\u043e\u044f\u0442\u043d\u0430\u044f \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.tr.json b/homeassistant/components/demo/translations/select.tr.json new file mode 100644 index 0000000000000..b24f9d925b924 --- /dev/null +++ b/homeassistant/components/demo/translations/select.tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "I\u015f\u0131k h\u0131z\u0131", + "ludicrous_speed": "Sa\u00e7ma H\u0131z", + "ridiculous_speed": "Anlams\u0131z H\u0131z" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.zh-Hant.json b/homeassistant/components/demo/translations/select.zh-Hant.json new file mode 100644 index 0000000000000..6190837b1f171 --- /dev/null +++ b/homeassistant/components/demo/translations/select.zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u5149\u901f", + "ludicrous_speed": "\u53ef\u7b11\u7684\u901f\u5ea6", + "ridiculous_speed": "\u8352\u8b2c\u7684\u901f\u5ea6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/tr.json b/homeassistant/components/demo/translations/tr.json index 1ca389b0b979b..1eea23e1bc536 100644 --- a/homeassistant/components/demo/translations/tr.json +++ b/homeassistant/components/demo/translations/tr.json @@ -1,9 +1,24 @@ { "options": { "step": { + "init": { + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" + } + }, "options_1": { "data": { - "constant": "Sabit" + "bool": "\u0130ste\u011fe ba\u011fl\u0131 boolean", + "constant": "Sabit", + "int": "Say\u0131sal giri\u015f" + } + }, + "options_2": { + "data": { + "multi": "\u00c7oklu se\u00e7im", + "select": "Bir se\u00e7enek se\u00e7in", + "string": "Dize de\u011feri" } } } diff --git a/homeassistant/components/demo/translations/zh-Hans.json b/homeassistant/components/demo/translations/zh-Hans.json index 9155b5066c533..1c40afabb6e53 100644 --- a/homeassistant/components/demo/translations/zh-Hans.json +++ b/homeassistant/components/demo/translations/zh-Hans.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "\u5e03\u5c14\u9009\u9879", + "constant": "\u5e38\u91cf", "int": "\u6570\u503c\u8f93\u5165" } }, @@ -15,5 +16,6 @@ } } } - } + }, + "title": "\u6f14\u793a" } \ No newline at end of file diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 39413a1b9f7e9..a5cffed1be80f 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -22,6 +22,7 @@ StateVacuumEntity, VacuumEntity, ) +from homeassistant.helpers import event SUPPORT_MINIMAL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF @@ -328,7 +329,7 @@ def return_to_base(self, **kwargs): self._state = STATE_RETURNING self.schedule_update_ha_state() - self.hass.loop.call_later(30, self.__set_state_to_dock) + event.call_later(self.hass, 30, self.__set_state_to_dock) def clean_spot(self, **kwargs): """Perform a spot clean-up.""" @@ -349,6 +350,6 @@ def set_fan_speed(self, fan_speed, **kwargs): self._fan_speed = fan_speed self.schedule_update_ha_state() - def __set_state_to_dock(self): + def __set_state_to_dock(self, _): self._state = STATE_DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 0b96bbf75f857..2311f0f457b27 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -30,23 +30,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoWaterHeater(WaterHeaterEntity): """Representation of a demo water_heater device.""" + _attr_should_poll = False + _attr_supported_features = SUPPORT_FLAGS_HEATER + def __init__( self, name, target_temperature, unit_of_measurement, away, current_operation ): """Initialize the water_heater device.""" - self._name = name - self._support_flags = SUPPORT_FLAGS_HEATER + self._attr_name = name if target_temperature is not None: - self._support_flags = self._support_flags | SUPPORT_TARGET_TEMPERATURE + self._attr_supported_features = ( + self.supported_features | SUPPORT_TARGET_TEMPERATURE + ) if away is not None: - self._support_flags = self._support_flags | SUPPORT_AWAY_MODE + self._attr_supported_features = self.supported_features | SUPPORT_AWAY_MODE if current_operation is not None: - self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE - self._target_temperature = target_temperature - self._unit_of_measurement = unit_of_measurement - self._away = away - self._current_operation = current_operation - self._operation_list = [ + self._attr_supported_features = ( + self.supported_features | SUPPORT_OPERATION_MODE + ) + self._attr_target_temperature = target_temperature + self._attr_temperature_unit = unit_of_measurement + self._attr_is_away_mode_on = away + self._attr_current_operation = current_operation + self._attr_operation_list = [ "eco", "electric", "performance", @@ -56,62 +62,22 @@ def __init__( "off", ] - @property - def supported_features(self): - """Return the list of supported features.""" - return self._support_flags - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the water_heater device.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._current_operation - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return self._operation_list - - @property - def is_away_mode_on(self): - """Return if away mode is on.""" - return self._away - def set_temperature(self, **kwargs): """Set new target temperatures.""" - self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self._attr_target_temperature = kwargs.get(ATTR_TEMPERATURE) self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): """Set new operation mode.""" - self._current_operation = operation_mode + self._attr_current_operation = operation_mode self.schedule_update_ha_state() def turn_away_mode_on(self): """Turn away mode on.""" - self._away = True + self._attr_is_away_mode_on = True self.schedule_update_ha_state() def turn_away_mode_off(self): """Turn away mode off.""" - self._away = False + self._attr_is_away_mode_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 818c005b1cd89..d58bbe963d738 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -4,7 +4,7 @@ from denonavr.exceptions import AvrNetworkError, AvrTimoutError from homeassistant import config_entries, core -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client @@ -23,7 +23,7 @@ CONF_RECEIVER = "receiver" UNDO_UPDATE_LISTENER = "undo_update_listener" -PLATFORMS = ["media_player"] +PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) @@ -72,7 +72,7 @@ async def async_unload_entry( hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() # Remove zone2 and zone3 entities if needed - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) unique_id = config_entry.unique_id or config_entry.entry_id zone2_id = f"{unique_id}-Zone2" diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index a58b0ae991f09..2d5cef14f5b52 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -45,7 +45,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Options for the component.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Init object.""" self.config_entry = config_entry @@ -111,8 +111,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): errors = {} if user_input is not None: # check if IP address is set manually - host = user_input.get(CONF_HOST) - if host: + if host := user_input.get(CONF_HOST): self.host = host return await self.async_step_connect() @@ -199,9 +198,7 @@ async def async_step_connect( "unique_id's will not be available", self.host, ) - for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == self.host: - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: self.host}) return self.async_create_entry( title=receiver.name, @@ -214,7 +211,7 @@ async def async_step_connect( }, ) - async def async_step_ssdp(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Denon AVR. This flow is triggered by the SSDP component. It will check if the @@ -222,21 +219,23 @@ async def async_step_ssdp(self, discovery_info: dict[str, Any]) -> FlowResult: """ # Filter out non-Denon AVRs#1 if ( - discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER) + discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) not in SUPPORTED_MANUFACTURERS ): return self.async_abort(reason="not_denonavr_manufacturer") # Check if required information is present to set the unique_id if ( - ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info - or ssdp.ATTR_UPNP_SERIAL not in discovery_info + ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp ): return self.async_abort(reason="not_denonavr_missing") - self.model_name = discovery_info[ssdp.ATTR_UPNP_MODEL_NAME].replace("*", "") - self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] - self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + self.model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME].replace( + "*", "" + ) + self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.host = urlparse(discovery_info.ssdp_location).hostname if self.model_name in IGNORED_MODELS: return self.async_abort(reason="not_denonavr_manufacturer") @@ -248,7 +247,9 @@ async def async_step_ssdp(self, discovery_info: dict[str, Any]) -> FlowResult: self.context.update( { "title_placeholders": { - "name": discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host) + "name": discovery_info.upnp.get( + ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host + ) } } ) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index ed6b94e207aba..1eb4cef9d85eb 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -3,8 +3,8 @@ "name": "Denon AVR Network Receivers", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.10.7"], - "codeowners": ["@scarface-4711", "@starkillerOG"], + "requirements": ["denonavr==0.10.9"], + "codeowners": ["@ol-iver", "@starkillerOG"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 2b8420e6774de..c3106a98c726c 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -35,9 +35,10 @@ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) -from homeassistant.const import ATTR_COMMAND, STATE_PAUSED, STATE_PLAYING +from homeassistant.const import ATTR_COMMAND, CONF_HOST, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from . import CONF_RECEIVER from .config_flow import ( @@ -142,11 +143,20 @@ def __init__( unique_id: str, config_entry: config_entries.ConfigEntry, update_audyssey: bool, - ): + ) -> None: """Initialize the device.""" + self._attr_name = receiver.name + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{config_entry.data[CONF_HOST]}/", + identifiers={(DOMAIN, config_entry.unique_id)}, + manufacturer=config_entry.data[CONF_MANUFACTURER], + model=f"{config_entry.data[CONF_MODEL]}-{config_entry.data[CONF_TYPE]}", + name=config_entry.title, + ) + self._attr_sound_mode_list = receiver.sound_mode_list + self._receiver = receiver - self._unique_id = unique_id - self._config_entry = config_entry self._update_audyssey = update_audyssey self._supported_features_base = SUPPORT_DENON @@ -230,36 +240,16 @@ def available(self): """Return True if entity is available.""" return self._available - @property - def unique_id(self): - """Return the unique id of the zone.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info of the receiver.""" - if self._config_entry.data[CONF_SERIAL_NUMBER] is None: - return None - - device_info = { - "identifiers": {(DOMAIN, self._config_entry.unique_id)}, - "manufacturer": self._config_entry.data[CONF_MANUFACTURER], - "name": self._config_entry.title, - "model": f"{self._config_entry.data[CONF_MODEL]}-{self._config_entry.data[CONF_TYPE]}", - } - - return device_info - - @property - def name(self): - """Return the name of the device.""" - return self._receiver.name - @property def state(self): """Return the state of the device.""" return self._receiver.state + @property + def source_list(self): + """Return a list of available input sources.""" + return self._receiver.input_func_list + @property def is_volume_muted(self): """Return boolean if volume is currently muted.""" @@ -279,21 +269,11 @@ def source(self): """Return the current input source.""" return self._receiver.input_func - @property - def source_list(self): - """Return a list of available input sources.""" - return self._receiver.input_func_list - @property def sound_mode(self): """Return the current matched sound mode.""" return self._receiver.sound_mode - @property - def sound_mode_list(self): - """Return a list of available sound modes.""" - return self._receiver.sound_mode_list - @property def supported_features(self): """Flag media player features that are supported.""" @@ -309,10 +289,7 @@ def media_content_id(self): @property def media_content_type(self): """Content type of current playing media.""" - if ( - self._receiver.state == STATE_PLAYING - or self._receiver.state == STATE_PAUSED - ): + if self._receiver.state in (STATE_PLAYING, STATE_PAUSED): return MEDIA_TYPE_MUSIC return MEDIA_TYPE_CHANNEL diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index c5d4661b1a802..5c15468e6d4aa 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -1,8 +1,8 @@ """Code to handle a DenonAVR receiver.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Callable from denonavr import DenonAVR @@ -20,7 +20,7 @@ def __init__( zone2: bool, zone3: bool, async_client_getter: Callable, - ): + ) -> None: """Initialize the class.""" self._async_client_getter = async_client_getter self._receiver = None diff --git a/homeassistant/components/denonavr/services.yaml b/homeassistant/components/denonavr/services.yaml index d79652dd1f8ad..ee35732e3117e 100644 --- a/homeassistant/components/denonavr/services.yaml +++ b/homeassistant/components/denonavr/services.yaml @@ -1,15 +1,22 @@ # Describes the format for available denonavr services get_command: + name: Get command description: "Send a generic HTTP get command." + target: + entity: + integration: denonavr + domain: media_player fields: - entity_id: - description: Name(s) of the denonavr entities where to run the API method. - example: "media_player.living_room_receiver" command: + name: Command description: Endpoint of the command, including associated parameters. example: "/goform/formiPhoneAppDirect.xml?RCKSK0410370" + required: true + selector: + text: set_dynamic_eq: + name: Set dynamic equalizer description: "Enable or disable DynamicEQ." target: entity: @@ -19,10 +26,10 @@ set_dynamic_eq: dynamic_eq: description: "True/false for enable/disable." default: true - example: true selector: boolean: update_audyssey: + name: Update audyssey description: "Update Audyssey settings." target: entity: diff --git a/homeassistant/components/denonavr/translations/ar.json b/homeassistant/components/denonavr/translations/ar.json new file mode 100644 index 0000000000000..18369967b90a8 --- /dev/null +++ b/homeassistant/components/denonavr/translations/ar.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u0641\u0634\u0644 \u0627\u0644\u0627\u062a\u0635\u0627\u0644\u060c \u0627\u0644\u0631\u062c\u0627\u0621 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649\u060c \u0642\u062f \u064a\u0633\u0627\u0639\u062f \u0642\u0637\u0639 \u0627\u0644\u062a\u064a\u0627\u0631 \u0627\u0644\u0643\u0647\u0631\u0628\u0627\u0626\u064a \u0648\u0643\u0627\u0628\u0644\u0627\u062a \u0625\u064a\u062b\u0631\u0646\u062a \u0648\u0625\u0639\u0627\u062f\u0629 \u062a\u0648\u0635\u064a\u0644\u0647\u0627" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/bg.json b/homeassistant/components/denonavr/translations/bg.json new file mode 100644 index 0000000000000..6ec6215c6e17a --- /dev/null +++ b/homeassistant/components/denonavr/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/ca.json b/homeassistant/components/denonavr/translations/ca.json index 3f0c846e10f16..d74705086a20c 100644 --- a/homeassistant/components/denonavr/translations/ca.json +++ b/homeassistant/components/denonavr/translations/ca.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "No s'ha pogut descobrir un receptor de xarxa AVR de Denon" }, - "flow_title": "Receptor de xarxa AVR de Denon: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Confirma l'addici\u00f3 del receptor", diff --git a/homeassistant/components/denonavr/translations/de.json b/homeassistant/components/denonavr/translations/de.json index f8896e5a08f52..300131280ac0f 100644 --- a/homeassistant/components/denonavr/translations/de.json +++ b/homeassistant/components/denonavr/translations/de.json @@ -3,14 +3,14 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", - "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuchen Sie es noch einmal. Trennen Sie ggf. Strom- und Ethernetkabel und verbinden Sie diese erneut.", + "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuche es noch einmal. Trenne ggf. Strom- und Ethernetkabel und verbinde diese erneut.", "not_denonavr_manufacturer": "Kein Denon AVR-Netzwerkempf\u00e4nger, entdeckter Hersteller stimmte nicht \u00fcberein", "not_denonavr_missing": "Kein Denon AVR-Netzwerk-Receiver, Erkennungsinformationen nicht vollst\u00e4ndig" }, "error": { "discovery_error": "Denon AVR-Netzwerk-Receiver konnte nicht gefunden werden" }, - "flow_title": "Denon AVR-Netzwerk-Receiver: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Bitte best\u00e4tige das Hinzuf\u00fcgen des Receivers", diff --git a/homeassistant/components/denonavr/translations/es-419.json b/homeassistant/components/denonavr/translations/es-419.json new file mode 100644 index 0000000000000..c506f9f6aac7d --- /dev/null +++ b/homeassistant/components/denonavr/translations/es-419.json @@ -0,0 +1,15 @@ +{ + "options": { + "step": { + "init": { + "data": { + "update_audyssey": "Actualizar la configuraci\u00f3n de Audyssey", + "zone2": "Configurar Zona 2", + "zone3": "Configurar Zona 3" + }, + "description": "Especificar configuraciones opcionales", + "title": "Receptores de red Denon AVR" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/et.json b/homeassistant/components/denonavr/translations/et.json index edba2158e6912..5dc9f3cc77174 100644 --- a/homeassistant/components/denonavr/translations/et.json +++ b/homeassistant/components/denonavr/translations/et.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Denon AVR Network Receiver'i avastamine nurjus" }, - "flow_title": "Denon AVR v\u00f5rguvastuv\u00f5tja: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Palun kinnita vastuv\u00f5tja lisamine", diff --git a/homeassistant/components/denonavr/translations/fr.json b/homeassistant/components/denonavr/translations/fr.json index 797f10fe06f6f..474f02f5e2148 100644 --- a/homeassistant/components/denonavr/translations/fr.json +++ b/homeassistant/components/denonavr/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour ce Denon AVR est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de la connexion, veuillez r\u00e9essayer, d\u00e9brancher l'alimentation secteur et les c\u00e2bles ethernet et les reconnecter peut aider", "not_denonavr_manufacturer": "Ce n'est pas un r\u00e9cepteur r\u00e9seau Denon AVR, le fabricant d\u00e9couvert ne correspondait pas", "not_denonavr_missing": "Ce n'est pas un r\u00e9cepteur r\u00e9seau Denon AVR, les informations d\u00e9couvertes ne sont pas compl\u00e8tes" @@ -10,7 +10,7 @@ "error": { "discovery_error": "Impossible de d\u00e9couvrir un r\u00e9cepteur r\u00e9seau Denon AVR" }, - "flow_title": "R\u00e9cepteur r\u00e9seau Denon AVR: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Veuillez confirmer l'ajout du r\u00e9cepteur", diff --git a/homeassistant/components/denonavr/translations/he.json b/homeassistant/components/denonavr/translations/he.json new file mode 100644 index 0000000000000..3d080ab97dc9f --- /dev/null +++ b/homeassistant/components/denonavr/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "flow_title": "{name}", + "step": { + "select": { + "data": { + "select_host": "\u05db\u05ea\u05d5\u05d1\u05ea IP \u05e9\u05dc \u05de\u05e7\u05dc\u05d8" + } + }, + "user": { + "data": { + "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/hu.json b/homeassistant/components/denonavr/translations/hu.json index 41a1910bd56d0..874f190ff011e 100644 --- a/homeassistant/components/denonavr/translations/hu.json +++ b/homeassistant/components/denonavr/translations/hu.json @@ -2,17 +2,47 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "cannot_connect": "Nem siker\u00fclt csatlakozni, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra. A h\u00e1l\u00f3zati \u00e9s Ethernet k\u00e1belek kih\u00faz\u00e1sa \u00e9s \u00fajracsatlakoztat\u00e1sa seg\u00edthet" + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", + "cannot_connect": "Nem siker\u00fclt csatlakozni, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra. A h\u00e1l\u00f3zati \u00e9s Ethernet k\u00e1belek kih\u00faz\u00e1sa \u00e9s \u00fajracsatlakoztat\u00e1sa seg\u00edthet", + "not_denonavr_manufacturer": "Nem egy Denon AVR h\u00e1l\u00f3zati vev\u0151, felfedezett gy\u00e1rt\u00f3 nem egyezik", + "not_denonavr_missing": "Nem Denon AVR h\u00e1l\u00f3zati vev\u0151, a felfedez\u00e9si inform\u00e1ci\u00f3k nem teljesek" }, "error": { "discovery_error": "Nem siker\u00fclt megtal\u00e1lni a Denon AVR h\u00e1l\u00f3zati er\u0151s\u00edt\u0151t" }, + "flow_title": "{name}", "step": { + "confirm": { + "description": "K\u00e9rj\u00fck, er\u0151s\u00edtse meg a vev\u0151 hozz\u00e1ad\u00e1s\u00e1t", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" + }, + "select": { + "data": { + "select_host": "Vev\u0151 IP-c\u00edme" + }, + "description": "Futtassa \u00fajra a be\u00e1ll\u00edt\u00e1st, ha tov\u00e1bbi vev\u0151k\u00e9sz\u00fcl\u00e9keket szeretne csatlakoztatni", + "title": "V\u00e1lassza ki a csatlakoztatni k\u00edv\u00e1nt vev\u0151t" + }, "user": { "data": { "host": "IP c\u00edm" - } + }, + "description": "Csatlakozzon a vev\u0151h\u00f6z, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, az automatikus felder\u00edt\u00e9st haszn\u00e1lja", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_all_sources": "Az \u00f6sszes forr\u00e1s megjelen\u00edt\u00e9se", + "update_audyssey": "Friss\u00edtse az Audyssey be\u00e1ll\u00edt\u00e1sait", + "zone2": "\u00c1ll\u00edtsa be a 2. z\u00f3n\u00e1t", + "zone3": "\u00c1ll\u00edtsa be a 3. z\u00f3n\u00e1t" + }, + "description": "Adja meg az opcion\u00e1lis be\u00e1ll\u00edt\u00e1sokat", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" } } } diff --git a/homeassistant/components/denonavr/translations/id.json b/homeassistant/components/denonavr/translations/id.json index d78f547ef3593..b2543d1d877ff 100644 --- a/homeassistant/components/denonavr/translations/id.json +++ b/homeassistant/components/denonavr/translations/id.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Gagal menemukan Network Receiver AVR Denon" }, - "flow_title": "Network Receiver Denon AVR: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Konfirmasikan penambahan Receiver", @@ -37,6 +37,7 @@ "init": { "data": { "show_all_sources": "Tampilkan semua sumber", + "update_audyssey": "Perbarui pengaturan Audyssey", "zone2": "Siapkan Zona 2", "zone3": "Siapkan Zona 3" }, diff --git a/homeassistant/components/denonavr/translations/it.json b/homeassistant/components/denonavr/translations/it.json index 6f6714387776a..76d0d627ca399 100644 --- a/homeassistant/components/denonavr/translations/it.json +++ b/homeassistant/components/denonavr/translations/it.json @@ -3,31 +3,31 @@ "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", - "cannot_connect": "Impossibile connettersi, riprovare, scollegare l'alimentazione della rete elettrica e i cavi ethernet e ricollegarli potrebbe essere d'aiuto", + "cannot_connect": "Impossibile connettersi, riprova, scollega l'alimentazione della rete elettrica e i cavi ethernet e ricollegali potrebbe essere d'aiuto", "not_denonavr_manufacturer": "Non \u00e8 un ricevitore di rete Denon AVR, il produttore rilevato non corrisponde", "not_denonavr_missing": "Non \u00e8 un ricevitore di rete Denon AVR, le informazioni di rilevamento non sono complete" }, "error": { "discovery_error": "Impossibile rilevare un ricevitore di rete Denon AVR" }, - "flow_title": "Ricevitore di rete Denon AVR: {name}", + "flow_title": "{name}", "step": { "confirm": { - "description": "Si prega di confermare l'aggiunta del ricevitore", + "description": "Conferma l'aggiunta del ricevitore", "title": "Ricevitori di rete Denon AVR" }, "select": { "data": { "select_host": "Indirizzo IP del ricevitore" }, - "description": "Eseguire nuovamente il setup se si desidera collegare altri ricevitori", - "title": "Selezionare il ricevitore che si desidera collegare" + "description": "Esegui nuovamente la configurazione se desideri collegare altri ricevitori", + "title": "Seleziona il ricevitore che desideri collegare" }, "user": { "data": { "host": "Indirizzo IP" }, - "description": "Collegare il ricevitore, se l'indirizzo IP non \u00e8 impostato, sar\u00e0 utilizzato il rilevamento automatico", + "description": "Collega il ricevitore, se l'indirizzo IP non \u00e8 impostato, sar\u00e0 utilizzato il rilevamento automatico", "title": "Ricevitori di rete Denon AVR" } } @@ -38,8 +38,8 @@ "data": { "show_all_sources": "Mostra tutte le fonti", "update_audyssey": "Aggiorna le impostazioni di Audyssey", - "zone2": "Imposta la Zona 2", - "zone3": "Imposta la Zona 3" + "zone2": "Configura la zona 2", + "zone3": "Configura la zona 3" }, "description": "Specificare le impostazioni opzionali", "title": "Ricevitori di rete Denon AVR" diff --git a/homeassistant/components/denonavr/translations/ja.json b/homeassistant/components/denonavr/translations/ja.json new file mode 100644 index 0000000000000..4300e1f515b09 --- /dev/null +++ b/homeassistant/components/denonavr/translations/ja.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4e3b\u96fb\u6e90\u30b1\u30fc\u30d6\u30eb\u3068\u30a4\u30fc\u30b5\u30cd\u30c3\u30c8\u30b1\u30fc\u30d6\u30eb\u3092\u53d6\u308a\u5916\u3057\u3066\u3001\u518d\u63a5\u7d9a\u3059\u308b\u3068\u554f\u984c\u304c\u89e3\u6c7a\u3059\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002", + "not_denonavr_manufacturer": "Denon AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc\u3067\u306f\u306a\u304f\u3001\u691c\u51fa\u3055\u308c\u305f\u30e1\u30fc\u30ab\u30fc\u304c\u4e00\u81f4\u3057\u307e\u305b\u3093\u3067\u3057\u305f", + "not_denonavr_missing": "Denon AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc\u3067\u306f\u306a\u304f\u3001\u691c\u51fa\u60c5\u5831\u304c\u5b8c\u5168\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "discovery_error": "Denon AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc\u306e\u691c\u51fa\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u53d7\u4fe1\u6a5f\u306e\u8ffd\u52a0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30c7\u30ce\u30f3(Denon)AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc" + }, + "select": { + "data": { + "select_host": "\u53d7\u4fe1\u6a5f\u306eIP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u8ffd\u52a0\u306e\u53d7\u4fe1\u6a5f\u3092\u63a5\u7d9a\u3059\u308b\u5834\u5408\u306f\u3001\u518d\u5ea6\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u63a5\u7d9a\u3057\u305f\u3044\u53d7\u4fe1\u6a5f\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u53d7\u4fe1\u6a5f\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002IP\u30a2\u30c9\u30ec\u30b9\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u81ea\u52d5\u691c\u51fa\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059", + "title": "\u30c7\u30ce\u30f3(Denon)AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_all_sources": "\u3059\u3079\u3066\u306e\u30bd\u30fc\u30b9\u3092\u8868\u793a", + "update_audyssey": "Audyssey\u8a2d\u5b9a\u3092\u66f4\u65b0", + "zone2": "\u30be\u30fc\u30f32\u306e\u8a2d\u5b9a", + "zone3": "\u30be\u30fc\u30f33\u306e\u8a2d\u5b9a" + }, + "description": "\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a\u306e\u6307\u5b9a", + "title": "\u30c7\u30ce\u30f3(Denon)AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/nl.json b/homeassistant/components/denonavr/translations/nl.json index fc4d17fe1048f..47da10106f32c 100644 --- a/homeassistant/components/denonavr/translations/nl.json +++ b/homeassistant/components/denonavr/translations/nl.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Kan geen Denon AVR netwerkontvanger vinden" }, - "flow_title": "Denon AVR Network Receiver: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Bevestig het toevoegen van de ontvanger", diff --git a/homeassistant/components/denonavr/translations/no.json b/homeassistant/components/denonavr/translations/no.json index c2cac347e77d1..646e24d60317e 100644 --- a/homeassistant/components/denonavr/translations/no.json +++ b/homeassistant/components/denonavr/translations/no.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Kunne ikke oppdage en Denon AVR Network Receiver" }, - "flow_title": "Denon AVR nettverksmottaker: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Bekreft at du legger til mottakeren", diff --git a/homeassistant/components/denonavr/translations/pl.json b/homeassistant/components/denonavr/translations/pl.json index c874cc6fb7ee5..6b09baf7d4cfe 100644 --- a/homeassistant/components/denonavr/translations/pl.json +++ b/homeassistant/components/denonavr/translations/pl.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 urz\u0105dzenia AVR firmy Denon" }, - "flow_title": "Denon AVR: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Prosz\u0119 potwierdzi\u0107 dodanie urz\u0105dzenia", diff --git a/homeassistant/components/denonavr/translations/ru.json b/homeassistant/components/denonavr/translations/ru.json index 6a3397023d35a..c1fb25a9889c7 100644 --- a/homeassistant/components/denonavr/translations/ru.json +++ b/homeassistant/components/denonavr/translations/ru.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0440\u0435\u0441\u0438\u0432\u0435\u0440 Denon." }, - "flow_title": "\u0420\u0435\u0441\u0438\u0432\u0435\u0440 Denon: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0440\u0435\u0441\u0438\u0432\u0435\u0440\u0430", diff --git a/homeassistant/components/denonavr/translations/tr.json b/homeassistant/components/denonavr/translations/tr.json index f618d3a303847..2c32de293b887 100644 --- a/homeassistant/components/denonavr/translations/tr.json +++ b/homeassistant/components/denonavr/translations/tr.json @@ -3,13 +3,46 @@ "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", - "cannot_connect": "Ba\u011flan\u0131lamad\u0131, l\u00fctfen tekrar deneyin, ana g\u00fc\u00e7 ve ethernet kablolar\u0131n\u0131n ba\u011flant\u0131s\u0131n\u0131 kesip yeniden ba\u011flamak yard\u0131mc\u0131 olabilir" + "cannot_connect": "Ba\u011flan\u0131lamad\u0131, l\u00fctfen tekrar deneyin, ana g\u00fc\u00e7 ve ethernet kablolar\u0131n\u0131n ba\u011flant\u0131s\u0131n\u0131 kesip yeniden ba\u011flamak yard\u0131mc\u0131 olabilir", + "not_denonavr_manufacturer": "Denon AVR A\u011f Al\u0131c\u0131s\u0131 de\u011fil, \u00fcreticinin e\u015fle\u015fmedi\u011fi ke\u015ffedildi", + "not_denonavr_missing": "Denon AVR A\u011f Al\u0131c\u0131s\u0131 de\u011fil, ke\u015fif bilgileri tamamlanmad\u0131" }, + "error": { + "discovery_error": "Denon AVR A\u011f Al\u0131c\u0131s\u0131 bulunamad\u0131" + }, + "flow_title": "{name}", "step": { + "confirm": { + "description": "L\u00fctfen al\u0131c\u0131y\u0131 eklemeyi onaylay\u0131n", + "title": "Denon AVR A\u011f Al\u0131c\u0131lar\u0131" + }, + "select": { + "data": { + "select_host": "Al\u0131c\u0131 IP adresi" + }, + "description": "Ek al\u0131c\u0131lar ba\u011flamak istiyorsan\u0131z kurulumu yeniden \u00e7al\u0131\u015ft\u0131r\u0131n", + "title": "Ba\u011flamak istedi\u011finiz al\u0131c\u0131y\u0131 se\u00e7in" + }, "user": { "data": { - "host": "\u0130p Adresi" - } + "host": "IP Adresi" + }, + "description": "Al\u0131c\u0131n\u0131za ba\u011flan\u0131n, IP adresi ayarlanmazsa otomatik bulma kullan\u0131l\u0131r", + "title": "Denon AVR A\u011f Al\u0131c\u0131lar\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_all_sources": "T\u00fcm kaynaklar\u0131 g\u00f6ster", + "update_audyssey": "Audyssey ayarlar\u0131n\u0131 g\u00fcncelleyin", + "zone2": "B\u00f6lge 2'yi kurun", + "zone3": "B\u00f6lge 3'\u00fc kurun" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 ayarlar\u0131 belirtin", + "title": "Denon AVR A\u011f Al\u0131c\u0131lar\u0131" } } } diff --git a/homeassistant/components/denonavr/translations/zh-Hant.json b/homeassistant/components/denonavr/translations/zh-Hant.json index 96bf7b00f92f2..a8ee7f87fd8b3 100644 --- a/homeassistant/components/denonavr/translations/zh-Hant.json +++ b/homeassistant/components/denonavr/translations/zh-Hant.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "\u7121\u6cd5\u627e\u5230 Denon AVR \u7db2\u8def\u63a5\u6536\u5668" }, - "flow_title": "Denon AVR \u7db2\u8def\u63a5\u6536\u5668\uff1a{name}", + "flow_title": "{name}", "step": { "confirm": { "description": "\u8acb\u78ba\u8a8d\u65b0\u589e\u63a5\u6536\u5668", diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 12fc4ddd7ba2b..f6de217ff2b5f 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -122,8 +122,7 @@ def __init__( async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None: + if (state := await self.async_get_last_state()) is not None: try: self._state = Decimal(state.state) except SyntaxError as err: @@ -136,8 +135,8 @@ def calc_derivative(event): new_state = event.data.get("new_state") if ( old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): return @@ -196,12 +195,12 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self._state, self._round_digits) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 33fd9a8224fab..34711a9a2d78a 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -59,7 +59,7 @@ def icon(self): return ICON @property - def state(self): + def native_value(self): """Return the departure time of the next train.""" return self._state diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 12083a8d139ce..67ef17dc3792c 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -2,10 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import MutableMapping +from collections.abc import Iterable, Mapping +from enum import Enum from functools import wraps +import logging from types import ModuleType -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol import voluptuous_serialize @@ -13,9 +15,13 @@ from homeassistant.components import websocket_api from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.loader import IntegrationNotFound +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.frame import report +from homeassistant.loader import IntegrationNotFound, bind_hass from homeassistant.requirements import async_get_integration_with_requirements from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig @@ -24,8 +30,7 @@ DOMAIN = "device_automation" - -TRIGGER_BASE_SCHEMA = vol.Schema( +DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "device", vol.Required(CONF_DOMAIN): str, @@ -33,22 +38,61 @@ } ) -TYPES = { - # platform name, get automations function, get capabilities function - "trigger": ( + +class DeviceAutomationDetails(NamedTuple): + """Details for device automation.""" + + section: str + get_automations_func: str + get_capabilities_func: str + + +class DeviceAutomationType(Enum): + """Device automation type.""" + + TRIGGER = DeviceAutomationDetails( "device_trigger", "async_get_triggers", "async_get_trigger_capabilities", - ), - "condition": ( + ) + CONDITION = DeviceAutomationDetails( "device_condition", "async_get_conditions", "async_get_condition_capabilities", - ), - "action": ("device_action", "async_get_actions", "async_get_action_capabilities"), + ) + ACTION = DeviceAutomationDetails( + "device_action", + "async_get_actions", + "async_get_action_capabilities", + ) + + +# TYPES is deprecated as of Home Assistant 2022.2, use DeviceAutomationType instead +TYPES = { + "trigger": DeviceAutomationType.TRIGGER.value, + "condition": DeviceAutomationType.CONDITION.value, + "action": DeviceAutomationType.ACTION.value, } +@bind_hass +async def async_get_device_automations( + hass: HomeAssistant, + automation_type: DeviceAutomationType | str, + device_ids: Iterable[str] | None = None, +) -> Mapping[str, Any]: + """Return all the device automations for a type optionally limited to specific device ids.""" + if isinstance(automation_type, str): + report( + "uses str for async_get_device_automations automation_type. This is " + "deprecated and will stop working in Home Assistant 2022.4, it should be " + "updated to use DeviceAutomationType instead", + error_if_core=False, + ) + automation_type = DeviceAutomationType[automation_type.upper()] + return await _async_get_device_automations(hass, automation_type, device_ids) + + async def async_setup(hass, config): """Set up device automation.""" hass.components.websocket_api.async_register_command( @@ -73,13 +117,21 @@ async def async_setup(hass, config): async def async_get_device_automation_platform( - hass: HomeAssistant, domain: str, automation_type: str + hass: HomeAssistant, domain: str, automation_type: DeviceAutomationType | str ) -> ModuleType: """Load device automation platform for integration. Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation. """ - platform_name = TYPES[automation_type][0] + if isinstance(automation_type, str): + report( + "uses str for async_get_device_automation_platform automation_type. This " + "is deprecated and will stop working in Home Assistant 2022.4, it should " + "be updated to use DeviceAutomationType instead", + error_if_core=False, + ) + automation_type = DeviceAutomationType[automation_type.upper()] + platform_name = automation_type.value.section try: integration = await async_get_integration_with_requirements(hass, domain) platform = integration.get_platform(platform_name) @@ -89,14 +141,15 @@ async def async_get_device_automation_platform( ) from err except ImportError as err: raise InvalidDeviceAutomationConfig( - f"Integration '{domain}' does not support device automation {automation_type}s" + f"Integration '{domain}' does not support device automation " + f"{automation_type.name.lower()}s" ) from err return platform async def _async_get_device_automations_from_domain( - hass, domain, automation_type, device_id + hass, domain, automation_type, device_ids, return_exceptions ): """List device automations.""" try: @@ -104,51 +157,82 @@ async def _async_get_device_automations_from_domain( hass, domain, automation_type ) except InvalidDeviceAutomationConfig: - return None - - function_name = TYPES[automation_type][1] - - return await getattr(platform, function_name)(hass, device_id) + return {} + function_name = automation_type.value.get_automations_func -async def _async_get_device_automations(hass, automation_type, device_id): - """List device automations.""" - device_registry, entity_registry = await asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), + return await asyncio.gather( + *( + getattr(platform, function_name)(hass, device_id) + for device_id in device_ids + ), + return_exceptions=return_exceptions, ) - domains = set() - automations: list[MutableMapping[str, Any]] = [] - device = device_registry.async_get(device_id) - - if device is None: - raise DeviceNotFound - - for entry_id in device.config_entries: - config_entry = hass.config_entries.async_get_entry(entry_id) - domains.add(config_entry.domain) - - entity_entries = async_entries_for_device(entity_registry, device_id) - for entity_entry in entity_entries: - domains.add(entity_entry.domain) - device_automations = await asyncio.gather( +async def _async_get_device_automations( + hass: HomeAssistant, + automation_type: DeviceAutomationType, + device_ids: Iterable[str] | None, +) -> Mapping[str, list[dict[str, Any]]]: + """List device automations.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + domain_devices: dict[str, set[str]] = {} + device_entities_domains: dict[str, set[str]] = {} + match_device_ids = set(device_ids or device_registry.devices) + combined_results: dict[str, list[dict[str, Any]]] = {} + + for entry in entity_registry.entities.values(): + if not entry.disabled_by and entry.device_id in match_device_ids: + device_entities_domains.setdefault(entry.device_id, set()).add(entry.domain) + + for device_id in match_device_ids: + combined_results[device_id] = [] + if (device := device_registry.async_get(device_id)) is None: + raise DeviceNotFound + for entry_id in device.config_entries: + if config_entry := hass.config_entries.async_get_entry(entry_id): + domain_devices.setdefault(config_entry.domain, set()).add(device_id) + for domain in device_entities_domains.get(device_id, []): + domain_devices.setdefault(domain, set()).add(device_id) + + # If specific device ids were requested, we allow + # InvalidDeviceAutomationConfig to be thrown, otherwise we skip + # devices that do not have valid triggers + return_exceptions = not bool(device_ids) + + for domain_results in await asyncio.gather( *( _async_get_device_automations_from_domain( - hass, domain, automation_type, device_id + hass, domain, automation_type, domain_device_ids, return_exceptions ) - for domain in domains + for domain, domain_device_ids in domain_devices.items() ) - ) - for device_automation in device_automations: - if device_automation is not None: - automations.extend(device_automation) - - return automations - - -async def _async_get_device_automation_capabilities(hass, automation_type, automation): + ): + for device_results in domain_results: + if device_results is None or isinstance( + device_results, InvalidDeviceAutomationConfig + ): + continue + if isinstance(device_results, Exception): + logging.getLogger(__name__).error( + "Unexpected error fetching device %ss", + automation_type.name.lower(), + exc_info=device_results, + ) + continue + for automation in device_results: + combined_results[automation["device_id"]].append(automation) + + return combined_results + + +async def _async_get_device_automation_capabilities( + hass: HomeAssistant, + automation_type: DeviceAutomationType, + automation: Mapping[str, Any], +) -> dict[str, Any]: """List device automations.""" try: platform = await async_get_device_automation_platform( @@ -157,7 +241,7 @@ async def _async_get_device_automation_capabilities(hass, automation_type, autom except InvalidDeviceAutomationConfig: return {} - function_name = TYPES[automation_type][2] + function_name = automation_type.value.get_capabilities_func if not hasattr(platform, function_name): # The device automation has no capabilities @@ -170,15 +254,14 @@ async def _async_get_device_automation_capabilities(hass, automation_type, autom capabilities = capabilities.copy() - extra_fields = capabilities.get("extra_fields") - if extra_fields is None: + if (extra_fields := capabilities.get("extra_fields")) is None: capabilities["extra_fields"] = [] else: capabilities["extra_fields"] = voluptuous_serialize.convert( extra_fields, custom_serializer=cv.custom_serializer ) - return capabilities + return capabilities # type: ignore[no-any-return] def handle_device_errors(func): @@ -207,7 +290,11 @@ async def with_error_handling(hass, connection, msg): async def websocket_device_automation_list_actions(hass, connection, msg): """Handle request for device actions.""" device_id = msg["device_id"] - actions = await _async_get_device_automations(hass, "action", device_id) + actions = ( + await _async_get_device_automations( + hass, DeviceAutomationType.ACTION, [device_id] + ) + ).get(device_id) connection.send_result(msg["id"], actions) @@ -222,7 +309,11 @@ async def websocket_device_automation_list_actions(hass, connection, msg): async def websocket_device_automation_list_conditions(hass, connection, msg): """Handle request for device conditions.""" device_id = msg["device_id"] - conditions = await _async_get_device_automations(hass, "condition", device_id) + conditions = ( + await _async_get_device_automations( + hass, DeviceAutomationType.CONDITION, [device_id] + ) + ).get(device_id) connection.send_result(msg["id"], conditions) @@ -237,7 +328,11 @@ async def websocket_device_automation_list_conditions(hass, connection, msg): async def websocket_device_automation_list_triggers(hass, connection, msg): """Handle request for device triggers.""" device_id = msg["device_id"] - triggers = await _async_get_device_automations(hass, "trigger", device_id) + triggers = ( + await _async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, [device_id] + ) + ).get(device_id) connection.send_result(msg["id"], triggers) @@ -253,7 +348,7 @@ async def websocket_device_automation_get_action_capabilities(hass, connection, """Handle request for device action capabilities.""" action = msg["action"] capabilities = await _async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) connection.send_result(msg["id"], capabilities) @@ -261,7 +356,9 @@ async def websocket_device_automation_get_action_capabilities(hass, connection, @websocket_api.websocket_command( { vol.Required("type"): "device_automation/condition/capabilities", - vol.Required("condition"): dict, + vol.Required("condition"): cv.DEVICE_CONDITION_BASE_SCHEMA.extend( + {}, extra=vol.ALLOW_EXTRA + ), } ) @websocket_api.async_response @@ -270,7 +367,7 @@ async def websocket_device_automation_get_condition_capabilities(hass, connectio """Handle request for device condition capabilities.""" condition = msg["condition"] capabilities = await _async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) connection.send_result(msg["id"], capabilities) @@ -278,7 +375,9 @@ async def websocket_device_automation_get_condition_capabilities(hass, connectio @websocket_api.websocket_command( { vol.Required("type"): "device_automation/trigger/capabilities", - vol.Required("trigger"): dict, + vol.Required("trigger"): DEVICE_TRIGGER_BASE_SCHEMA.extend( + {}, extra=vol.ALLOW_EXTRA + ), } ) @websocket_api.async_response @@ -287,6 +386,6 @@ async def websocket_device_automation_get_trigger_capabilities(hass, connection, """Handle request for device trigger capabilities.""" trigger = msg["trigger"] capabilities = await _async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) connection.send_result(msg["id"], capabilities) diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 72fcc9790b2d7..8128eca9dbcfe 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -5,15 +5,9 @@ import voluptuous as vol -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation.const import ( - CONF_IS_OFF, - CONF_IS_ON, - CONF_TOGGLE, - CONF_TURN_OFF, - CONF_TURN_ON, - CONF_TURNED_OFF, - CONF_TURNED_ON, +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, ) from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -29,7 +23,16 @@ from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import TRIGGER_BASE_SCHEMA +from . import DEVICE_TRIGGER_BASE_SCHEMA +from .const import ( + CONF_IS_OFF, + CONF_IS_ON, + CONF_TOGGLE, + CONF_TURN_OFF, + CONF_TURN_ON, + CONF_TURNED_OFF, + CONF_TURNED_ON, +) # mypy: allow-untyped-calls, allow-untyped-defs @@ -91,7 +94,7 @@ } ) -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]), @@ -124,10 +127,11 @@ async def async_call_action_from_config( @callback -def async_condition_from_config(config: ConfigType) -> condition.ConditionCheckerType: +def async_condition_from_config( + hass: HomeAssistant, config: ConfigType +) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" - condition_type = config[CONF_TYPE] - if condition_type == CONF_IS_ON: + if config[CONF_TYPE] == CONF_IS_ON: stat = "on" else: stat = "off" @@ -139,6 +143,8 @@ def async_condition_from_config(config: ConfigType) -> condition.ConditionChecke if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] + state_config = cv.STATE_CONDITION_SCHEMA(state_config) + state_config = condition.state_validate_config(hass, state_config) return condition.state_from_config(state_config) @@ -146,11 +152,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - trigger_type = config[CONF_TYPE] - if trigger_type == CONF_TURNED_ON: + if config[CONF_TYPE] == CONF_TURNED_ON: to_state = "on" else: to_state = "off" @@ -162,17 +167,20 @@ async def async_attach_trigger( if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) async def _async_get_automations( - hass: HomeAssistant, device_id: str, automation_templates: list[dict], domain: str -) -> list[dict]: + hass: HomeAssistant, + device_id: str, + automation_templates: list[dict[str, str]], + domain: str, +) -> list[dict[str, str]]: """List device automations.""" - automations: list[dict[str, Any]] = [] + automations: list[dict[str, str]] = [] entity_registry = await hass.helpers.entity_registry.async_get_registry() entries = [ @@ -197,7 +205,7 @@ async def _async_get_automations( async def async_get_actions( hass: HomeAssistant, device_id: str, domain: str -) -> list[dict]: +) -> list[dict[str, str]]: """List device actions.""" return await _async_get_automations(hass, device_id, ENTITY_ACTIONS, domain) @@ -211,12 +219,14 @@ async def async_get_conditions( async def async_get_triggers( hass: HomeAssistant, device_id: str, domain: str -) -> list[dict]: +) -> list[dict[str, Any]]: """List device triggers.""" return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return { "extra_fields": vol.Schema( @@ -225,7 +235,9 @@ async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> } -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index b2892d1abaa83..008a7603dba60 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -1,31 +1,37 @@ """Offer device oriented automation.""" import voluptuous as vol -from homeassistant.components.device_automation import ( - TRIGGER_BASE_SCHEMA, +from homeassistant.const import CONF_DOMAIN + +from . import ( + DEVICE_TRIGGER_BASE_SCHEMA, + DeviceAutomationType, async_get_device_automation_platform, ) -from homeassistant.const import CONF_DOMAIN +from .exceptions import InvalidDeviceAutomationConfig # mypy: allow-untyped-defs, no-check-untyped-defs -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) async def async_validate_trigger_config(hass, config): """Validate config.""" platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "trigger" + hass, config[CONF_DOMAIN], DeviceAutomationType.TRIGGER ) - if hasattr(platform, "async_validate_trigger_config"): - return await getattr(platform, "async_validate_trigger_config")(hass, config) + if not hasattr(platform, "async_validate_trigger_config"): + return platform.TRIGGER_SCHEMA(config) - return platform.TRIGGER_SCHEMA(config) + try: + return await getattr(platform, "async_validate_trigger_config")(hass, config) + except InvalidDeviceAutomationConfig as err: + raise vol.Invalid(str(err) or "Invalid trigger configuration") from err async def async_attach_trigger(hass, config, action, automation_info): """Listen for trigger.""" platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "trigger" + hass, config[CONF_DOMAIN], DeviceAutomationType.TRIGGER ) return await platform.async_attach_trigger(hass, config, action, automation_info) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index dfdfd678c0f4a..035b1923c4c01 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -19,6 +19,7 @@ CONF_SCAN_INTERVAL, CONF_TRACK_NEW, DOMAIN, + ENTITY_ID_FORMAT, SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS, @@ -37,12 +38,12 @@ @bind_hass -def is_on(hass: HomeAssistant, entity_id: str): +def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return the state if any or a specified device is home.""" return hass.states.is_state(entity_id, STATE_HOME) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the device tracker.""" await async_setup_legacy_integration(hass, config) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 9a8c77686a16f..97b79306e7bcc 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -4,6 +4,7 @@ from typing import final from homeassistant.components import zone +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_GPS_ACCURACY, @@ -12,13 +13,15 @@ STATE_HOME, STATE_NOT_HOME, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import StateType from .const import ATTR_HOST_NAME, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, DOMAIN, LOGGER -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an entry.""" component: EntityComponent | None = hass.data.get(DOMAIN) @@ -28,16 +31,17 @@ async def async_setup_entry(hass, entry): return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) class BaseTrackerEntity(Entity): """Represent a tracked device.""" @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the device. Percentage from 0-100. @@ -45,16 +49,16 @@ def battery_level(self): return None @property - def source_type(self): + def source_type(self) -> str: """Return the source type, eg gps or router, of the device.""" raise NotImplementedError @property - def state_attributes(self): + def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attr = {ATTR_SOURCE_TYPE: self.source_type} + attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type} - if self.battery_level: + if self.battery_level is not None: attr[ATTR_BATTERY_LEVEL] = self.battery_level return attr @@ -64,17 +68,17 @@ class TrackerEntity(BaseTrackerEntity): """Base class for a tracked device.""" @property - def should_poll(self): + def should_poll(self) -> bool: """No polling for entities that have location pushed.""" return False @property - def force_update(self): + def force_update(self) -> bool: """All updates need to be written to the state machine if we're not polling.""" return not self.should_poll @property - def location_accuracy(self): + def location_accuracy(self) -> int: """Return the location accuracy of the device. Value in meters. @@ -82,27 +86,27 @@ def location_accuracy(self): return 0 @property - def location_name(self) -> str: + def location_name(self) -> str | None: """Return a location name for the current location of the device.""" return None @property - def latitude(self) -> float: + def latitude(self) -> float | None: """Return latitude value of the device.""" - return NotImplementedError + raise NotImplementedError @property - def longitude(self) -> float: + def longitude(self) -> float | None: """Return longitude value of the device.""" - return NotImplementedError + raise NotImplementedError @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" - if self.location_name: + if self.location_name is not None: return self.location_name - if self.latitude is not None: + if self.latitude is not None and self.longitude is not None: zone_state = zone.async_active_zone( self.hass, self.latitude, self.longitude, self.location_accuracy ) @@ -118,11 +122,11 @@ def state(self): @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attr = {} + attr: dict[str, StateType] = {} attr.update(super().state_attributes) - if self.latitude is not None: + if self.latitude is not None and self.longitude is not None: attr[ATTR_LATITUDE] = self.latitude attr[ATTR_LONGITUDE] = self.longitude attr[ATTR_GPS_ACCURACY] = self.location_accuracy @@ -162,9 +166,9 @@ def is_connected(self) -> bool: @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attr = {} + attr: dict[str, StateType] = {} attr.update(super().state_attributes) if self.ip_address is not None: attr[ATTR_IP] = self.ip_address diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index aa1b349ef12ec..216255b9cb6cb 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -1,37 +1,39 @@ """Device tracker constants.""" from datetime import timedelta import logging +from typing import Final -LOGGER = logging.getLogger(__package__) +LOGGER: Final = logging.getLogger(__package__) -DOMAIN = "device_tracker" +DOMAIN: Final = "device_tracker" +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" -PLATFORM_TYPE_LEGACY = "legacy" -PLATFORM_TYPE_ENTITY = "entity_platform" +PLATFORM_TYPE_LEGACY: Final = "legacy" +PLATFORM_TYPE_ENTITY: Final = "entity_platform" -SOURCE_TYPE_GPS = "gps" -SOURCE_TYPE_ROUTER = "router" -SOURCE_TYPE_BLUETOOTH = "bluetooth" -SOURCE_TYPE_BLUETOOTH_LE = "bluetooth_le" +SOURCE_TYPE_GPS: Final = "gps" +SOURCE_TYPE_ROUTER: Final = "router" +SOURCE_TYPE_BLUETOOTH: Final = "bluetooth" +SOURCE_TYPE_BLUETOOTH_LE: Final = "bluetooth_le" -CONF_SCAN_INTERVAL = "interval_seconds" -SCAN_INTERVAL = timedelta(seconds=12) +CONF_SCAN_INTERVAL: Final = "interval_seconds" +SCAN_INTERVAL: Final = timedelta(seconds=12) -CONF_TRACK_NEW = "track_new_devices" -DEFAULT_TRACK_NEW = True +CONF_TRACK_NEW: Final = "track_new_devices" +DEFAULT_TRACK_NEW: Final = True -CONF_CONSIDER_HOME = "consider_home" -DEFAULT_CONSIDER_HOME = timedelta(seconds=180) +CONF_CONSIDER_HOME: Final = "consider_home" +DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180) -CONF_NEW_DEVICE_DEFAULTS = "new_device_defaults" +CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults" -ATTR_ATTRIBUTES = "attributes" -ATTR_BATTERY = "battery" -ATTR_DEV_ID = "dev_id" -ATTR_GPS = "gps" -ATTR_HOST_NAME = "host_name" -ATTR_LOCATION_NAME = "location_name" -ATTR_MAC = "mac" -ATTR_SOURCE_TYPE = "source_type" -ATTR_CONSIDER_HOME = "consider_home" -ATTR_IP = "ip" +ATTR_ATTRIBUTES: Final = "attributes" +ATTR_BATTERY: Final = "battery" +ATTR_DEV_ID: Final = "dev_id" +ATTR_GPS: Final = "gps" +ATTR_HOST_NAME: Final = "host_name" +ATTR_LOCATION_NAME: Final = "location_name" +ATTR_MAC: Final = "mac" +ATTR_SOURCE_TYPE: Final = "source_type" +ATTR_CONSIDER_HOME: Final = "consider_home" +ATTR_IP: Final = "ip" diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 0260a4bbd3abf..2762a271cab5c 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -11,7 +11,6 @@ CONF_ENTITY_ID, CONF_TYPE, STATE_HOME, - STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry @@ -43,43 +42,31 @@ async def async_get_conditions( continue # Add conditions for each entity that belongs to this integration - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_home", - } - ) - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_not_home", - } - ) + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + + conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] return conditions @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_home": - state = STATE_HOME - else: - state = STATE_NOT_HOME + reverse = config[CONF_TYPE] == "is_not_home" @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + result = condition.state(hass, config[ATTR_ENTITY_ID], STATE_HOME) + if reverse: + result = not result + return result return test_is_state diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 81a16545c74e8..926519c22436b 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,10 +1,15 @@ """Provides device automations for Device Tracker.""" from __future__ import annotations +from typing import Any, Final + import voluptuous as vol -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.zone import DOMAIN as DOMAIN_ZONE, trigger as zone from homeassistant.const import ( CONF_DEVICE_ID, @@ -21,9 +26,9 @@ from . import DOMAIN -TRIGGER_TYPES = {"enters", "leaves"} +TRIGGER_TYPES: Final[set[str]] = {"enters", "leaves"} -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), @@ -32,7 +37,9 @@ ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Device Tracker devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -68,7 +75,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "enters": @@ -82,13 +89,15 @@ async def async_attach_trigger( CONF_ZONE: config[CONF_ZONE], CONF_EVENT: event, } - zone_config = zone.TRIGGER_SCHEMA(zone_config) + zone_config = await zone.async_validate_trigger_config(hass, zone_config) return await zone.async_attach_trigger( hass, zone_config, action, automation_info, platform_type="device" ) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: ConfigType): +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" zones = { ent.entity_id: ent.name diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index e1eb897f1ba58..d81743c530a18 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence +from collections.abc import Callable, Coroutine, Sequence from datetime import timedelta import hashlib from types import ModuleType -from typing import Any, Callable, final +from typing import Any, Final, final import attr import voluptuous as vol @@ -28,7 +28,7 @@ STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery import homeassistant.helpers.config_validation as cv @@ -38,7 +38,7 @@ async_track_utc_time_change, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, GPSType +from homeassistant.helpers.typing import ConfigType, GPSType, StateType from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import dt as dt_util from homeassistant.util.yaml import dump @@ -69,9 +69,9 @@ SOURCE_TYPE_ROUTER, ) -SERVICE_SEE = "see" +SERVICE_SEE: Final = "see" -SOURCE_TYPES = ( +SOURCE_TYPES: Final[tuple[str, ...]] = ( SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, SOURCE_TYPE_BLUETOOTH, @@ -82,7 +82,7 @@ None, vol.Schema({vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean}), ) -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA.extend( { vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_TRACK_NEW): cv.boolean, @@ -92,9 +92,11 @@ vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA, } ) -PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) +PLATFORM_SCHEMA_BASE: Final[vol.Schema] = cv.PLATFORM_SCHEMA_BASE.extend( + PLATFORM_SCHEMA.schema +) -SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema( +SERVICE_SEE_PAYLOAD_SCHEMA: Final[vol.Schema] = vol.Schema( vol.All( cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), { @@ -115,23 +117,23 @@ ) ) -YAML_DEVICES = "known_devices.yaml" -EVENT_NEW_DEVICE = "device_tracker_new_device" +YAML_DEVICES: Final = "known_devices.yaml" +EVENT_NEW_DEVICE: Final = "device_tracker_new_device" def see( hass: HomeAssistant, - mac: str = None, - dev_id: str = None, - host_name: str = None, - location_name: str = None, - gps: GPSType = None, - gps_accuracy=None, - battery: int = None, - attributes: dict = None, -): + mac: str | None = None, + dev_id: str | None = None, + host_name: str | None = None, + location_name: str | None = None, + gps: GPSType | None = None, + gps_accuracy: int | None = None, + battery: int | None = None, + attributes: dict | None = None, +) -> None: """Call service to notify you see device.""" - data = { + data: dict[str, Any] = { key: value for key, value in ( (ATTR_MAC, mac), @@ -144,7 +146,7 @@ def see( ) if value is not None } - if attributes: + if attributes is not None: data[ATTR_ATTRIBUTES] = attributes hass.services.call(DOMAIN, SERVICE_SEE, data) @@ -163,7 +165,9 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No if setup_tasks: await asyncio.wait(setup_tasks) - async def async_platform_discovered(p_type, info): + async def async_platform_discovered( + p_type: str, info: dict[str, Any] | None + ) -> None: """Load a platform.""" platform = await async_create_platform_type(hass, config, p_type, {}) @@ -179,7 +183,7 @@ async def async_platform_discovered(p_type, info): hass, tracker.async_update_stale, second=range(0, 60, 5) ) - async def async_see_service(call): + async def async_see_service(call: ServiceCall) -> None: """Service to see a device.""" # Temp workaround for iOS, introduced in 0.65 data = dict(call.data) @@ -199,7 +203,7 @@ async def async_see_service(call): class DeviceTrackerPlatform: """Class to hold platform information.""" - LEGACY_SETUP = ( + LEGACY_SETUP: Final[tuple[str, ...]] = ( "async_get_scanner", "get_scanner", "async_setup_scanner", @@ -211,17 +215,22 @@ class DeviceTrackerPlatform: config: dict = attr.ib() @property - def type(self): + def type(self) -> str | None: """Return platform type.""" - for methods, platform_type in ((self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY),): - for meth in methods: - if hasattr(self.platform, meth): - return platform_type - + methods, platform_type = self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY + for method in methods: + if hasattr(self.platform, method): + return platform_type return None - async def async_setup_legacy(self, hass, tracker, discovery_info=None): + async def async_setup_legacy( + self, + hass: HomeAssistant, + tracker: DeviceTracker, + discovery_info: dict[str, Any] | None = None, + ) -> None: """Set up a legacy platform.""" + assert self.type == PLATFORM_TYPE_LEGACY full_name = f"{DOMAIN}.{self.name}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): @@ -229,20 +238,22 @@ async def async_setup_legacy(self, hass, tracker, discovery_info=None): scanner = None setup = None if hasattr(self.platform, "async_get_scanner"): - scanner = await self.platform.async_get_scanner( + scanner = await self.platform.async_get_scanner( # type: ignore[attr-defined] hass, {DOMAIN: self.config} ) elif hasattr(self.platform, "get_scanner"): scanner = await hass.async_add_executor_job( - self.platform.get_scanner, hass, {DOMAIN: self.config} + self.platform.get_scanner, # type: ignore[attr-defined] + hass, + {DOMAIN: self.config}, ) elif hasattr(self.platform, "async_setup_scanner"): - setup = await self.platform.async_setup_scanner( + setup = await self.platform.async_setup_scanner( # type: ignore[attr-defined] hass, self.config, tracker.async_see, discovery_info ) elif hasattr(self.platform, "setup_scanner"): setup = await hass.async_add_executor_job( - self.platform.setup_scanner, + self.platform.setup_scanner, # type: ignore[attr-defined] hass, self.config, tracker.see, @@ -251,12 +262,12 @@ async def async_setup_legacy(self, hass, tracker, discovery_info=None): else: raise HomeAssistantError("Invalid legacy device_tracker platform.") - if scanner: + if scanner is not None: async_setup_scanner_platform( hass, self.config, scanner, tracker.async_see, self.type ) - if not setup and not scanner: + if setup is None and scanner is None: LOGGER.error( "Error setting up platform %s %s", self.type, self.name ) @@ -270,9 +281,11 @@ async def async_setup_legacy(self, hass, tracker, discovery_info=None): ) -async def async_extract_config(hass, config): +async def async_extract_config( + hass: HomeAssistant, config: ConfigType +) -> list[DeviceTrackerPlatform]: """Extract device tracker config and split between legacy and modern.""" - legacy = [] + legacy: list[DeviceTrackerPlatform] = [] for platform in await asyncio.gather( *( @@ -294,7 +307,7 @@ async def async_extract_config(hass, config): async def async_create_platform_type( - hass, config, p_type, p_config + hass: HomeAssistant, config: ConfigType, p_type: str, p_config: dict ) -> DeviceTrackerPlatform | None: """Determine type of platform.""" platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) @@ -309,10 +322,10 @@ async def async_create_platform_type( def async_setup_scanner_platform( hass: HomeAssistant, config: ConfigType, - scanner: Any, - async_see_device: Callable, + scanner: DeviceScanner, + async_see_device: Callable[..., Coroutine[None, None, None]], platform: str, -): +) -> None: """Set up the connect scanner-based platform to device tracker. This method must be run in the event loop. @@ -324,7 +337,7 @@ def async_setup_scanner_platform( # Initial scan of each mac we also tell about host name for config seen: Any = set() - async def async_device_tracker_scan(now: dt_util.dt.datetime): + async def async_device_tracker_scan(now: dt_util.dt.datetime | None) -> None: """Handle interval matches.""" if update_lock.locked(): LOGGER.warning( @@ -350,7 +363,7 @@ async def async_device_tracker_scan(now: dt_util.dt.datetime): except NotImplementedError: extra_attributes = {} - kwargs = { + kwargs: dict[str, Any] = { "mac": mac, "host_name": host_name, "source_type": SOURCE_TYPE_ROUTER, @@ -361,7 +374,7 @@ async def async_device_tracker_scan(now: dt_util.dt.datetime): } zone_home = hass.states.get(hass.components.zone.ENTITY_ID_HOME) - if zone_home: + if zone_home is not None: kwargs["gps"] = [ zone_home.attributes[ATTR_LATITUDE], zone_home.attributes[ATTR_LONGITUDE], @@ -374,7 +387,7 @@ async def async_device_tracker_scan(now: dt_util.dt.datetime): hass.async_create_task(async_device_tracker_scan(None)) -async def get_tracker(hass, config): +async def get_tracker(hass: HomeAssistant, config: ConfigType) -> DeviceTracker: """Create a tracker.""" yaml_path = hass.config.path(YAML_DEVICES) @@ -383,8 +396,7 @@ async def get_tracker(hass, config): consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) - track_new = conf.get(CONF_TRACK_NEW) - if track_new is None: + if (track_new := conf.get(CONF_TRACK_NEW)) is None: track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) devices = await async_load_config(yaml_path, hass, consider_home) @@ -400,12 +412,12 @@ def __init__( hass: HomeAssistant, consider_home: timedelta, track_new: bool, - defaults: dict, - devices: Sequence, + defaults: dict[str, Any], + devices: Sequence[Device], ) -> None: """Initialize a device tracker.""" self.hass = hass - self.devices = {dev.dev_id: dev for dev in devices} + self.devices: dict[str, Device] = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} self.consider_home = consider_home self.track_new = ( @@ -424,21 +436,21 @@ def __init__( def see( self, - mac: str = None, - dev_id: str = None, - host_name: str = None, - location_name: str = None, - gps: GPSType = None, - gps_accuracy: int = None, - battery: int = None, - attributes: dict = None, + mac: str | None = None, + dev_id: str | None = None, + host_name: str | None = None, + location_name: str | None = None, + gps: GPSType | None = None, + gps_accuracy: int | None = None, + battery: int | None = None, + attributes: dict | None = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, - icon: str = None, - consider_home: timedelta = None, - ): + picture: str | None = None, + icon: str | None = None, + consider_home: timedelta | None = None, + ) -> None: """Notify the device tracker that you see a device.""" - self.hass.add_job( + self.hass.create_task( self.async_see( mac, dev_id, @@ -457,19 +469,19 @@ def see( async def async_see( self, - mac: str = None, - dev_id: str = None, - host_name: str = None, - location_name: str = None, - gps: GPSType = None, - gps_accuracy: int = None, - battery: int = None, - attributes: dict = None, + mac: str | None = None, + dev_id: str | None = None, + host_name: str | None = None, + location_name: str | None = None, + gps: GPSType | None = None, + gps_accuracy: int | None = None, + battery: int | None = None, + attributes: dict | None = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, - icon: str = None, - consider_home: timedelta = None, - ): + picture: str | None = None, + icon: str | None = None, + consider_home: timedelta | None = None, + ) -> None: """Notify the device tracker that you see a device. This method is a coroutine. @@ -479,14 +491,13 @@ async def async_see( raise HomeAssistantError("Neither mac or device id passed in") if mac is not None: mac = str(mac).upper() - device = self.mac_to_dev.get(mac) - if not device: + if (device := self.mac_to_dev.get(mac)) is None: dev_id = util.slugify(host_name or "") or util.slugify(mac) else: dev_id = cv.slug(str(dev_id).lower()) device = self.devices.get(dev_id) - if device: + if device is not None: await device.async_seen( host_name, location_name, @@ -501,6 +512,9 @@ async def async_see( device.async_write_ha_state() return + # If it's None then device is not None and we can't get here. + assert dev_id is not None + # Guard from calling see on entity registry entities. entity_id = f"{DOMAIN}.{dev_id}" if registry.async_is_registered(entity_id): @@ -553,7 +567,7 @@ async def async_see( ) ) - async def async_update_config(self, path, dev_id, device): + async def async_update_config(self, path: str, dev_id: str, device: Device) -> None: """Add device to YAML configuration file. This method is a coroutine. @@ -564,7 +578,7 @@ async def async_update_config(self, path, dev_id, device): ) @callback - def async_update_stale(self, now: dt_util.dt.datetime): + def async_update_stale(self, now: dt_util.dt.datetime) -> None: """Update stale devices. This method must be run in the event loop. @@ -573,18 +587,18 @@ def async_update_stale(self, now: dt_util.dt.datetime): if (device.track and device.last_update_home) and device.stale(now): self.hass.async_create_task(device.async_update_ha_state(True)) - async def async_setup_tracked_device(self): + async def async_setup_tracked_device(self) -> None: """Set up all not exists tracked devices. This method is a coroutine. """ - async def async_init_single_device(dev): + async def async_init_single_device(dev: Device) -> None: """Init a single device_tracker entity.""" await dev.async_added_to_hass() dev.async_write_ha_state() - tasks = [] + tasks: list[asyncio.Task] = [] for device in self.devices.values(): if device.track and not device.last_seen: tasks.append( @@ -598,19 +612,17 @@ async def async_init_single_device(dev): class Device(RestoreEntity): """Base class for a tracked device.""" - host_name: str = None - location_name: str = None - gps: GPSType = None + host_name: str | None = None + location_name: str | None = None + gps: GPSType | None = None gps_accuracy: int = 0 - last_seen: dt_util.dt.datetime = None - consider_home: dt_util.dt.timedelta = None - battery: int = None - attributes: dict = None - icon: str = None + last_seen: dt_util.dt.datetime | None = None + battery: int | None = None + attributes: dict | None = None # Track if the last update of this device was HOME. - last_update_home = False - _state = STATE_NOT_HOME + last_update_home: bool = False + _state: str = STATE_NOT_HOME def __init__( self, @@ -618,11 +630,11 @@ def __init__( consider_home: timedelta, track: bool, dev_id: str, - mac: str, - name: str = None, - picture: str = None, - gravatar: str = None, - icon: str = None, + mac: str | None, + name: str | None = None, + picture: str | None = None, + gravatar: str | None = None, + icon: str | None = None, ) -> None: """Initialize a device.""" self.hass = hass @@ -643,64 +655,70 @@ def __init__( self.config_name = name # Configured picture + self.config_picture: str | None if gravatar is not None: self.config_picture = get_gravatar_for_email(gravatar) else: self.config_picture = picture - self.icon = icon + self._icon = icon - self.source_type = None + self.source_type: str | None = None - self._attributes = {} + self._attributes: dict[str, Any] = {} @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self.config_name or self.host_name or self.dev_id or DEVICE_DEFAULT_NAME @property - def state(self): + def state(self) -> str: """Return the state of the device.""" return self._state @property - def entity_picture(self): + def entity_picture(self) -> str | None: """Return the picture of the device.""" return self.config_picture @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attributes = {ATTR_SOURCE_TYPE: self.source_type} + attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type} - if self.gps: + if self.gps is not None: attributes[ATTR_LATITUDE] = self.gps[0] attributes[ATTR_LONGITUDE] = self.gps[1] attributes[ATTR_GPS_ACCURACY] = self.gps_accuracy - if self.battery: + if self.battery is not None: attributes[ATTR_BATTERY] = self.battery return attributes @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device state attributes.""" return self._attributes + @property + def icon(self) -> str | None: + """Return device icon.""" + return self._icon + async def async_seen( self, - host_name: str = None, - location_name: str = None, - gps: GPSType = None, - gps_accuracy=0, - battery: int = None, - attributes: dict = None, + host_name: str | None = None, + location_name: str | None = None, + gps: GPSType | None = None, + gps_accuracy: int | None = None, + battery: int | None = None, + attributes: dict[str, Any] | None = None, source_type: str = SOURCE_TYPE_GPS, - consider_home: timedelta = None, - ): + consider_home: timedelta | None = None, + ) -> None: """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() @@ -708,9 +726,9 @@ async def async_seen( self.location_name = location_name self.consider_home = consider_home or self.consider_home - if battery: + if battery is not None: self.battery = battery - if attributes: + if attributes is not None: self._attributes.update(attributes) self.gps = None @@ -726,7 +744,7 @@ async def async_seen( await self.async_update() - def stale(self, now: dt_util.dt.datetime = None): + def stale(self, now: dt_util.dt.datetime | None = None) -> bool: """Return if device state is stale. Async friendly. @@ -736,13 +754,13 @@ def stale(self, now: dt_util.dt.datetime = None): or (now or dt_util.utcnow()) - self.last_seen > self.consider_home ) - def mark_stale(self): + def mark_stale(self) -> None: """Mark the device state as stale.""" self._state = STATE_NOT_HOME self.gps = None self.last_update_home = False - async def async_update(self): + async def async_update(self) -> None: """Update state of entity. This method is a coroutine. @@ -767,11 +785,10 @@ async def async_update(self): self._state = STATE_HOME self.last_update_home = True - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add an entity.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if not state: + if not (state := await self.async_get_last_state()): return self._state = state.state self.last_update_home = state.state == STATE_HOME @@ -795,34 +812,45 @@ async def async_added_to_hass(self): class DeviceScanner: """Device scanner object.""" - hass: HomeAssistant = None + hass: HomeAssistant | None = None def scan_devices(self) -> list[str]: """Scan for devices.""" raise NotImplementedError() - async def async_scan_devices(self) -> Any: + async def async_scan_devices(self) -> list[str]: """Scan for devices.""" + assert ( + self.hass is not None + ), "hass should be set by async_setup_scanner_platform" return await self.hass.async_add_executor_job(self.scan_devices) - def get_device_name(self, device: str) -> str: + def get_device_name(self, device: str) -> str | None: """Get the name of a device.""" raise NotImplementedError() - async def async_get_device_name(self, device: str) -> Any: + async def async_get_device_name(self, device: str) -> str | None: """Get the name of a device.""" + assert ( + self.hass is not None + ), "hass should be set by async_setup_scanner_platform" return await self.hass.async_add_executor_job(self.get_device_name, device) def get_extra_attributes(self, device: str) -> dict: """Get the extra attributes of a device.""" raise NotImplementedError() - async def async_get_extra_attributes(self, device: str) -> Any: + async def async_get_extra_attributes(self, device: str) -> dict: """Get the extra attributes of a device.""" + assert ( + self.hass is not None + ), "hass should be set by async_setup_scanner_platform" return await self.hass.async_add_executor_job(self.get_extra_attributes, device) -async def async_load_config(path: str, hass: HomeAssistant, consider_home: timedelta): +async def async_load_config( + path: str, hass: HomeAssistant, consider_home: timedelta +) -> list[Device]: """Load devices from YAML configuration file. This method is a coroutine. @@ -842,7 +870,7 @@ async def async_load_config(path: str, hass: HomeAssistant, consider_home: timed ), } ) - result = [] + result: list[Device] = [] try: devices = await hass.async_add_executor_job(load_yaml_config_file, path) except HomeAssistantError as err: @@ -865,10 +893,10 @@ async def async_load_config(path: str, hass: HomeAssistant, consider_home: timed return result -def update_config(path: str, dev_id: str, device: Device): +def update_config(path: str, dev_id: str, device: Device) -> None: """Add device to YAML configuration file.""" - with open(path, "a") as out: - device = { + with open(path, "a", encoding="utf8") as out: + device_config = { device.dev_id: { ATTR_NAME: device.name, ATTR_MAC: device.mac, @@ -878,10 +906,10 @@ def update_config(path: str, dev_id: str, device: Device): } } out.write("\n") - out.write(dump(device)) + out.write(dump(device_config)) -def get_gravatar_for_email(email: str): +def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. Async friendly. diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 9e27a04fabf17..c6c2d212e2d04 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -37,15 +37,14 @@ see: gps_accuracy: name: GPS accuracy description: Accuracy of GPS coordinates. - example: "80" selector: number: min: 1 max: 100 + unit_of_measurement: "%" battery: name: Battery level description: Battery level of device. - example: "100" selector: number: min: 0 diff --git a/homeassistant/components/device_tracker/translations/es-419.json b/homeassistant/components/device_tracker/translations/es-419.json index 8a8b7197dcb0e..26b8877d4cee7 100644 --- a/homeassistant/components/device_tracker/translations/es-419.json +++ b/homeassistant/components/device_tracker/translations/es-419.json @@ -3,6 +3,10 @@ "condition_type": { "is_home": "{entity_name} est\u00e1 en casa", "is_not_home": "{entity_name} no est\u00e1 en casa" + }, + "trigger_type": { + "enters": "{entity_name} ingresa a una zona", + "leaves": "{entity_name} abandona una zona" } }, "state": { diff --git a/homeassistant/components/device_tracker/translations/he.json b/homeassistant/components/device_tracker/translations/he.json index 5db22ed4071f1..e20a3291008ca 100644 --- a/homeassistant/components/device_tracker/translations/he.json +++ b/homeassistant/components/device_tracker/translations/he.json @@ -1,9 +1,19 @@ { + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u05d1\u05d1\u05d9\u05ea", + "is_not_home": "{entity_name} \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + }, + "trigger_type": { + "enters": "{entity_name} \u05e0\u05db\u05e0\u05e1 \u05dc\u05d0\u05d6\u05d5\u05e8", + "leaves": "{entity_name} \u05d9\u05e6\u05d0 \u05de\u05d0\u05d6\u05d5\u05e8" + } + }, "state": { "_": { "home": "\u05d1\u05d1\u05d9\u05ea", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + "not_home": "\u05d1\u05d7\u05d5\u05e5" } }, - "title": "\u05de\u05e2\u05e7\u05d1 \u05de\u05db\u05e9\u05d9\u05e8" + "title": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" } \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ja.json b/homeassistant/components/device_tracker/translations/ja.json index 6679d6cca0644..53302c9eb2986 100644 --- a/homeassistant/components/device_tracker/translations/ja.json +++ b/homeassistant/components/device_tracker/translations/ja.json @@ -1,8 +1,19 @@ { + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u306f\u3001\u5728\u5b85\u3067\u3059", + "is_not_home": "{entity_name} \u306f\u3001\u4e0d\u5728\u3067\u3059" + }, + "trigger_type": { + "enters": "{entity_name} \u304c\u30be\u30fc\u30f3\u306b\u5165\u308b", + "leaves": "{entity_name} \u304c\u30be\u30fc\u30f3\u304b\u3089\u96e2\u308c\u308b" + } + }, "state": { "_": { "home": "\u5728\u5b85", - "not_home": "\u5916\u51fa" + "not_home": "\u96e2\u5e2d(away)" } - } + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u30c8\u30e9\u30c3\u30ab\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/tr.json b/homeassistant/components/device_tracker/translations/tr.json index 87042b6500e83..c520ae1057ba0 100644 --- a/homeassistant/components/device_tracker/translations/tr.json +++ b/homeassistant/components/device_tracker/translations/tr.json @@ -1,5 +1,9 @@ { "device_automation": { + "condition_type": { + "is_home": "{entity_name} evde", + "is_not_home": "{entity_name} evde de\u011fil" + }, "trigger_type": { "enters": "{entity_name} bir b\u00f6lgeye girdi", "leaves": "{entity_name} bir b\u00f6lgeden ayr\u0131l\u0131yor" diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index ded30d75de989..46b8f9dcaeaf9 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -1,6 +1,10 @@ """The devolo_home_control integration.""" +from __future__ import annotations + import asyncio from functools import partial +from types import MappingProxyType +from typing import Any from devolo_home_control_api.exceptions.gateway import GatewayOfflineError from devolo_home_control_api.homecontrol import HomeControl @@ -9,8 +13,8 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( CONF_MYDEVOLO, @@ -30,14 +34,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid) if not credentials_valid: - return False + raise ConfigEntryAuthFailed if await hass.async_add_executor_job(mydevolo.maintenance): raise ConfigEntryNotReady gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids) - if GATEWAY_SERIAL_PATTERN.match(entry.unique_id): + if entry.unique_id and GATEWAY_SERIAL_PATTERN.match(entry.unique_id): uuid = await hass.async_add_executor_job(mydevolo.uuid) hass.config_entries.async_update_entry(entry, unique_id=uuid) @@ -60,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - def shutdown(event): + def shutdown(event: Event) -> None: for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: gateway.websocket_disconnect( f"websocket disconnect requested by {EVENT_HOMEASSISTANT_STOP}" @@ -78,17 +82,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await asyncio.gather( - *[ + *( hass.async_add_executor_job(gateway.websocket_disconnect) for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"] - ] + ) ) hass.data[DOMAIN][entry.entry_id]["listener"]() hass.data[DOMAIN].pop(entry.entry_id) return unload -def configure_mydevolo(conf: dict) -> Mydevolo: +def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo: """Configure mydevolo.""" mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index e99c96832ae23..fad6b91f15502 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -1,35 +1,36 @@ """Platform for binary sensor integration.""" +from __future__ import annotations + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_DOOR, - DEVICE_CLASS_HEAT, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_PROBLEM, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_device import DevoloDeviceEntity DEVICE_CLASS_MAPPING = { - "Water alarm": DEVICE_CLASS_MOISTURE, - "Home Security": DEVICE_CLASS_MOTION, - "Smoke Alarm": DEVICE_CLASS_SMOKE, - "Heat Alarm": DEVICE_CLASS_HEAT, - "door": DEVICE_CLASS_DOOR, - "overload": DEVICE_CLASS_SAFETY, + "Water alarm": BinarySensorDeviceClass.MOISTURE, + "Home Security": BinarySensorDeviceClass.MOTION, + "Smoke Alarm": BinarySensorDeviceClass.SMOKE, + "Heat Alarm": BinarySensorDeviceClass.HEAT, + "door": BinarySensorDeviceClass.DOOR, + "overload": BinarySensorDeviceClass.SAFETY, } async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all binary sensor and multi level sensor devices and setup them via config entry.""" - entities = [] + entities: list[BinarySensorEntity] = [] for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.binary_sensor_devices: @@ -61,7 +62,9 @@ async def async_setup_entry( class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): """Representation of a binary sensor within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a devolo binary sensor.""" self._binary_sensor_property = device_instance.binary_sensor_property.get( element_uid @@ -73,38 +76,43 @@ def __init__(self, homecontrol, device_instance, element_uid): element_uid=element_uid, ) - self._device_class = DEVICE_CLASS_MAPPING.get( + self._attr_device_class = DEVICE_CLASS_MAPPING.get( self._binary_sensor_property.sub_type or self._binary_sensor_property.sensor_type ) - if self._device_class is None: + if self._attr_device_class is None: if device_instance.binary_sensor_property.get(element_uid).sub_type != "": - self._name += f" {device_instance.binary_sensor_property.get(element_uid).sub_type}" + self._attr_name += f" {device_instance.binary_sensor_property.get(element_uid).sub_type}" else: - self._name += f" {device_instance.binary_sensor_property.get(element_uid).sensor_type}" + self._attr_name += f" {device_instance.binary_sensor_property.get(element_uid).sensor_type}" self._value = self._binary_sensor_property.state + if self._attr_device_class == BinarySensorDeviceClass.SAFETY: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + if element_uid.startswith("devolo.WarningBinaryFI:"): - self._device_class = DEVICE_CLASS_PROBLEM - self._enabled_default = False + self._attr_device_class = BinarySensorDeviceClass.PROBLEM + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_entity_registry_enabled_default = False @property - def is_on(self): + def is_on(self) -> bool: """Return the state.""" - return self._value - - @property - def device_class(self): - """Return device class.""" - return self._device_class + return bool(self._value) class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity): """Representation of a remote control within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid, key): + def __init__( + self, + homecontrol: HomeControl, + device_instance: Zwave, + element_uid: str, + key: int, + ) -> None: """Initialize a devolo remote control.""" self._remote_control_property = device_instance.remote_control_property.get( element_uid @@ -117,24 +125,19 @@ def __init__(self, homecontrol, device_instance, element_uid, key): ) self._key = key - self._state = False - - @property - def is_on(self): - """Return the state.""" - return self._state + self._attr_is_on = False - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the binary sensor state.""" if ( message[0] == self._remote_control_property.element_uid and message[1] == self._key ): - self._state = True + self._attr_is_on = True elif ( message[0] == self._remote_control_property.element_uid and message[1] == 0 ): - self._state = False + self._attr_is_on = False else: self._generic_message(message) self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 018c9cf36eccd..f6efc3094d3a0 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -1,6 +1,11 @@ """Platform for climate integration.""" from __future__ import annotations +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.climate import ( ATTR_TEMPERATURE, HVAC_MODE_HEAT, @@ -11,13 +16,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all cover devices and setup them via config entry.""" entities = [] @@ -25,11 +31,11 @@ async def async_setup_entry( for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.multi_level_switch_devices: for multi_level_switch in device.multi_level_switch_property: - if device.device_model_uid in [ + if device.device_model_uid in ( "devolo.model.Thermostat:Valve", "devolo.model.Room:Thermostat", "devolo.model.Eurotronic:Spirit:Device", - ]: + ): entities.append( DevoloClimateDeviceEntity( homecontrol=gateway, @@ -44,6 +50,25 @@ async def async_setup_entry( class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntity): """Representation of a climate/thermostat device within devolo Home Control.""" + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a climate entity within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + + self._attr_hvac_mode = HVAC_MODE_HEAT + self._attr_hvac_modes = [HVAC_MODE_HEAT] + self._attr_min_temp = self._multi_level_switch_property.min + self._attr_max_temp = self._multi_level_switch_property.max + self._attr_precision = PRECISION_TENTHS + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_target_temperature_step = PRECISION_HALVES + self._attr_temperature_unit = TEMP_CELSIUS + @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -64,49 +89,9 @@ def target_temperature(self) -> float | None: """Return the target temperature.""" return self._value - @property - def target_temperature_step(self) -> float: - """Return the precision of the target temperature.""" - return PRECISION_HALVES - - @property - def hvac_mode(self) -> str: - """Return the supported HVAC mode.""" - return HVAC_MODE_HEAT - - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT] - - @property - def min_temp(self) -> float: - """Return the minimum set temperature value.""" - return self._multi_level_switch_property.min - - @property - def max_temp(self) -> float: - """Return the maximum set temperature value.""" - return self._multi_level_switch_property.max - - @property - def precision(self) -> float: - """Return the precision of the set temperature.""" - return PRECISION_TENTHS - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - @property - def temperature_unit(self) -> str: - """Return the supported unit of temperature.""" - return TEMP_CELSIUS - def set_hvac_mode(self, hvac_mode: str) -> None: """Do nothing as devolo devices do not support changing the hvac mode.""" - def set_temperature(self, **kwargs): + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" self._multi_level_switch_property.set(kwargs[ATTR_TEMPERATURE]) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index 4f605baf98dbf..e0e49197f4592 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -1,14 +1,20 @@ """Config flow to configure the devolo home control integration.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.data_entry_flow import FlowResult from . import configure_mydevolo from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, DOMAIN, SUPPORTED_MODEL_TYPES -from .exceptions import CredentialsInvalid +from .exceptions import CredentialsInvalid, UuidChanged class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -16,19 +22,21 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize devolo Home Control flow.""" self.data_schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } + self._reauth_entry: ConfigEntry | None = None + self._url = DEFAULT_MYDEVOLO - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if self.show_advanced_options: - self.data_schema[ - vol.Required(CONF_MYDEVOLO, default=DEFAULT_MYDEVOLO) - ] = str + self.data_schema[vol.Required(CONF_MYDEVOLO, default=self._url)] = str if user_input is None: return self._show_form(step_id="user") try: @@ -36,15 +44,19 @@ async def async_step_user(self, user_input=None): except CredentialsInvalid: return self._show_form(step_id="user", errors={"base": "invalid_auth"}) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" # Check if it is a gateway - if discovery_info.get("properties", {}).get("MT") in SUPPORTED_MODEL_TYPES: + if discovery_info.properties.get("MT") in SUPPORTED_MODEL_TYPES: await self._async_handle_discovery_without_unique_id() return await self.async_step_zeroconf_confirm() return self.async_abort(reason="Not a devolo Home Control gateway.") - async def async_step_zeroconf_confirm(self, user_input=None): + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by zeroconf.""" if user_input is None: return self._show_form(step_id="zeroconf_confirm") @@ -55,8 +67,38 @@ async def async_step_zeroconf_confirm(self, user_input=None): step_id="zeroconf_confirm", errors={"base": "invalid_auth"} ) - async def _connect_mydevolo(self, user_input): + async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + """Handle reauthentication.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._url = user_input[CONF_MYDEVOLO] + self.data_schema = { + vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD): str, + } + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by reauthentication.""" + if user_input is None: + return self._show_form(step_id="reauth_confirm") + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + return self._show_form( + step_id="reauth_confirm", errors={"base": "invalid_auth"} + ) + except UuidChanged: + return self._show_form( + step_id="reauth_confirm", errors={"base": "reauth_failed"} + ) + + async def _connect_mydevolo(self, user_input: dict[str, Any]) -> FlowResult: """Connect to mydevolo.""" + user_input[CONF_MYDEVOLO] = user_input.get(CONF_MYDEVOLO, self._url) mydevolo = configure_mydevolo(conf=user_input) credentials_valid = await self.hass.async_add_executor_job( mydevolo.credentials_valid @@ -64,20 +106,35 @@ async def _connect_mydevolo(self, user_input): if not credentials_valid: raise CredentialsInvalid uuid = await self.hass.async_add_executor_job(mydevolo.uuid) - await self.async_set_unique_id(uuid) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title="devolo Home Control", - data={ - CONF_PASSWORD: mydevolo.password, - CONF_USERNAME: mydevolo.user, - CONF_MYDEVOLO: mydevolo.url, - }, + + if not self._reauth_entry: + await self.async_set_unique_id(uuid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="devolo Home Control", + data={ + CONF_PASSWORD: mydevolo.password, + CONF_USERNAME: mydevolo.user, + CONF_MYDEVOLO: mydevolo.url, + }, + ) + + if self._reauth_entry.unique_id != uuid: + # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change. + raise UuidChanged + + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input, unique_id=uuid + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) ) + return self.async_abort(reason="reauth_successful") @callback - def _show_form(self, step_id, errors=None): + def _show_form( + self, step_id: str, errors: dict[str, str] | None = None + ) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index b15c0acf62292..e2ac3a42416ec 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -1,9 +1,18 @@ """Constants for the devolo_home_control integration.""" import re +from homeassistant.const import Platform + DOMAIN = "devolo_home_control" DEFAULT_MYDEVOLO = "https://www.mydevolo.com" -PLATFORMS = ["binary_sensor", "climate", "cover", "light", "sensor", "switch"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] CONF_MYDEVOLO = "mydevolo_url" GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") SUPPORTED_MODEL_TYPES = ["2600", "2601"] diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index d552c53bbfc59..0f82847660c64 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -1,20 +1,28 @@ """Platform for cover integration.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.cover import ( - DEVICE_CLASS_BLIND, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, + CoverDeviceClass, CoverEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all cover devices and setup them via config entry.""" entities = [] @@ -37,34 +45,39 @@ async def async_setup_entry( class DevoloCoverDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, CoverEntity): """Representation of a cover device within devolo Home Control.""" + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a climate entity within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + + self._attr_device_class = CoverDeviceClass.BLIND + self._attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + ) + @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position. 0 is closed. 100 is open.""" return self._value @property - def device_class(self): - """Return the class of the device.""" - return DEVICE_CLASS_BLIND - - @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the blind is closed or not.""" return not bool(self._value) - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the blind.""" self._multi_level_switch_property.set(100) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the blind.""" self._multi_level_switch_property.set(0) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Set the blind to the given position.""" self._multi_level_switch_property.set(kwargs["position"]) diff --git a/homeassistant/components/devolo_home_control/devolo_device.py b/homeassistant/components/devolo_home_control/devolo_device.py index 6aef842ffff18..f4f2432aa6ec6 100644 --- a/homeassistant/components/devolo_home_control/devolo_device.py +++ b/homeassistant/components/devolo_home_control/devolo_device.py @@ -1,7 +1,13 @@ """Base class for a device entity integrated in devolo Home Control.""" +from __future__ import annotations + import logging +from urllib.parse import urlparse + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN from .subscriber import Subscriber @@ -12,31 +18,39 @@ class DevoloDeviceEntity(Entity): """Abstract representation of a device within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a devolo device entity.""" self._device_instance = device_instance - self._unique_id = element_uid self._homecontrol = homecontrol - self._name = device_instance.settings_property["general_device_settings"].name - self._area = device_instance.settings_property["general_device_settings"].zone - self._device_class = None - self._value = None - self._unit = None - self._enabled_default = True - - # This is not doing I/O. It fetches an internal state of the API - self._available = device_instance.is_online() - # Get the brand and model information - self._brand = device_instance.brand - self._model = device_instance.name + self._attr_available = ( + device_instance.is_online() + ) # This is not doing I/O. It fetches an internal state of the API + self._attr_name: str = device_instance.settings_property[ + "general_device_settings" + ].name + self._attr_should_poll = False + self._attr_unique_id = element_uid + self._attr_device_info = DeviceInfo( + configuration_url=f"https://{urlparse(device_instance.href).netloc}", + identifiers={(DOMAIN, self._device_instance.uid)}, + manufacturer=device_instance.brand, + model=device_instance.name, + name=self._attr_name, + suggested_area=device_instance.settings_property[ + "general_device_settings" + ].zone, + ) - self.subscriber = None + self.subscriber: Subscriber | None = None self.sync_callback = self._sync + self._value: int async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.subscriber = Subscriber(self._name, callback=self.sync_callback) + self.subscriber = Subscriber(self._attr_name, callback=self.sync_callback) self._homecontrol.publisher.register( self._device_instance.uid, self.subscriber, self.sync_callback ) @@ -47,56 +61,20 @@ async def async_will_remove_from_hass(self) -> None: self._device_instance.uid, self.subscriber ) - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self._device_instance.uid)}, - "name": self._name, - "manufacturer": self._brand, - "model": self._model, - "suggested_area": self._area, - } - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the display name of this entity.""" - return self._name - - @property - def available(self) -> bool: - """Return the online state.""" - return self._available - - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the state.""" - if message[0] == self._unique_id: + if message[0] == self._attr_unique_id: self._value = message[1] else: self._generic_message(message) self.schedule_update_ha_state() - def _generic_message(self, message): + def _generic_message(self, message: tuple) -> None: """Handle generic messages.""" if len(message) == 3 and message[2] == "battery_level": self._value = message[1] elif len(message) == 3 and message[2] == "status": # Maybe the API wants to tell us, that the device went on- or offline. - self._available = self._device_instance.is_online() + self._attr_available = self._device_instance.is_online() else: _LOGGER.debug("No valid message received: %s", message) diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py index 482edd51f1ec1..eafd1e63b1fef 100644 --- a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py +++ b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py @@ -1,11 +1,16 @@ """Base class for multi level switches in devolo Home Control.""" +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from .devolo_device import DevoloDeviceEntity class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a multi level switch within devolo Home Control.""" super().__init__( homecontrol=homecontrol, diff --git a/homeassistant/components/devolo_home_control/exceptions.py b/homeassistant/components/devolo_home_control/exceptions.py index 378efa41cc515..a89058e6c16e1 100644 --- a/homeassistant/components/devolo_home_control/exceptions.py +++ b/homeassistant/components/devolo_home_control/exceptions.py @@ -4,3 +4,7 @@ class CredentialsInvalid(HomeAssistantError): """Given credentials are invalid.""" + + +class UuidChanged(HomeAssistantError): + """UUID of the user changed.""" diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 7fd59bd7d11ec..28da95c8902d0 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -1,4 +1,11 @@ """Platform for light integration.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, @@ -6,13 +13,14 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all light devices and setup them via config entry.""" entities = [] @@ -35,7 +43,9 @@ async def async_setup_entry( class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): """Representation of a light within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a devolo multi level switch.""" super().__init__( homecontrol=homecontrol, @@ -43,26 +53,22 @@ def __init__(self, homecontrol, device_instance, element_uid): element_uid=element_uid, ) + self._attr_supported_features = SUPPORT_BRIGHTNESS self._binary_switch_property = device_instance.binary_switch_property.get( element_uid.replace("Dimmer", "BinarySwitch") ) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness value of the light.""" return round(self._value / 100 * 255) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the light.""" return bool(self._value) - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS - - def turn_on(self, **kwargs) -> None: + def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" if kwargs.get(ATTR_BRIGHTNESS) is not None: self._multi_level_switch_property.set( @@ -76,7 +82,7 @@ def turn_on(self, **kwargs) -> None: # If there is no binary switch attached to the device, turn it on to 100 %. self._multi_level_switch_property.set(100) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" if self._binary_switch_property is not None: self._binary_switch_property.set(False) diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 5886c1d0fe258..9621a49157a90 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -2,7 +2,7 @@ "domain": "devolo_home_control", "name": "devolo Home Control", "documentation": "https://www.home-assistant.io/integrations/devolo_home_control", - "requirements": ["devolo-home-control-api==0.17.3"], + "requirements": ["devolo-home-control-api==0.17.4"], "after_dependencies": ["zeroconf"], "config_flow": true, "codeowners": ["@2Fake", "@Shutgun"], diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index e309130537547..ab6ef87fab2b0 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -1,37 +1,49 @@ """Platform for sensor integration.""" +from __future__ import annotations + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_device import DevoloDeviceEntity DEVICE_CLASS_MAPPING = { - "battery": DEVICE_CLASS_BATTERY, - "temperature": DEVICE_CLASS_TEMPERATURE, - "light": DEVICE_CLASS_ILLUMINANCE, - "humidity": DEVICE_CLASS_HUMIDITY, - "current": DEVICE_CLASS_POWER, - "total": DEVICE_CLASS_ENERGY, - "voltage": DEVICE_CLASS_VOLTAGE, + "battery": SensorDeviceClass.BATTERY, + "temperature": SensorDeviceClass.TEMPERATURE, + "light": SensorDeviceClass.ILLUMINANCE, + "humidity": SensorDeviceClass.HUMIDITY, + "current": SensorDeviceClass.POWER, + "total": SensorDeviceClass.ENERGY, + "voltage": SensorDeviceClass.VOLTAGE, +} + +STATE_CLASS_MAPPING = { + "battery": SensorStateClass.MEASUREMENT, + "temperature": SensorStateClass.MEASUREMENT, + "light": SensorStateClass.MEASUREMENT, + "humidity": SensorStateClass.MEASUREMENT, + "current": SensorStateClass.MEASUREMENT, + "total": SensorStateClass.TOTAL_INCREASING, + "voltage": SensorStateClass.MEASUREMENT, } async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all sensor devices and setup them via config entry.""" - entities = [] + entities: list[SensorEntity] = [] for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.multi_level_sensor_devices: @@ -46,7 +58,7 @@ async def async_setup_entry( for device in gateway.devices.values(): if hasattr(device, "consumption_property"): for consumption in device.consumption_property: - for consumption_type in ["current", "total"]: + for consumption_type in ("current", "total"): entities.append( DevoloConsumptionEntity( homecontrol=gateway, @@ -71,30 +83,20 @@ class DevoloMultiLevelDeviceEntity(DevoloDeviceEntity, SensorEntity): """Abstract representation of a multi level sensor within devolo Home Control.""" @property - def device_class(self) -> str: - """Return device class.""" - return self._device_class - - @property - def state(self): + def native_value(self) -> int: """Return the state of the sensor.""" return self._value - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit - class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): """Representation of a generic multi level sensor within devolo Home Control.""" def __init__( self, - homecontrol, - device_instance, - element_uid, - ): + homecontrol: HomeControl, + device_instance: Zwave, + element_uid: str, + ) -> None: """Initialize a devolo multi level sensor.""" self._multi_level_sensor_property = device_instance.multi_level_sensor_property[ element_uid @@ -106,24 +108,29 @@ def __init__( element_uid=element_uid, ) - self._device_class = DEVICE_CLASS_MAPPING.get( + self._attr_device_class = DEVICE_CLASS_MAPPING.get( + self._multi_level_sensor_property.sensor_type + ) + self._attr_state_class = STATE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) + self._attr_native_unit_of_measurement = self._multi_level_sensor_property.unit self._value = self._multi_level_sensor_property.value - self._unit = self._multi_level_sensor_property.unit - if self._device_class is None: - self._name += f" {self._multi_level_sensor_property.sensor_type}" + if self._attr_device_class is None: + self._attr_name += f" {self._multi_level_sensor_property.sensor_type}" if element_uid.startswith("devolo.VoltageMultiLevelSensor:"): - self._enabled_default = False + self._attr_entity_registry_enabled_default = False class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): """Representation of a battery entity within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a battery sensor.""" super().__init__( @@ -132,16 +139,24 @@ def __init__(self, homecontrol, device_instance, element_uid): element_uid=element_uid, ) - self._device_class = DEVICE_CLASS_MAPPING.get("battery") + self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") + self._attr_state_class = STATE_CLASS_MAPPING.get("battery") + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_native_unit_of_measurement = PERCENTAGE self._value = device_instance.battery_level - self._unit = PERCENTAGE class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): """Representation of a consumption entity within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid, consumption): + def __init__( + self, + homecontrol: HomeControl, + device_instance: Zwave, + element_uid: str, + consumption: str, + ) -> None: """Initialize a devolo consumption sensor.""" super().__init__( @@ -151,27 +166,31 @@ def __init__(self, homecontrol, device_instance, element_uid, consumption): ) self._sensor_type = consumption - self._device_class = DEVICE_CLASS_MAPPING.get(consumption) + self._attr_device_class = DEVICE_CLASS_MAPPING.get(consumption) + self._attr_state_class = STATE_CLASS_MAPPING.get(consumption) + self._attr_native_unit_of_measurement = getattr( + device_instance.consumption_property[element_uid], f"{consumption}_unit" + ) + + if consumption == "total": + self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._value = getattr( device_instance.consumption_property[element_uid], consumption ) - self._unit = getattr( - device_instance.consumption_property[element_uid], f"{consumption}_unit" - ) - self._name += f" {consumption}" + self._attr_name += f" {consumption}" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of the entity.""" - return f"{self._unique_id}_{self._sensor_type}" + return f"{self._attr_unique_id}_{self._sensor_type}" - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the consumption sensor state.""" - if message[0] == self._unique_id: + if message[0] == self._attr_unique_id: self._value = getattr( - self._device_instance.consumption_property[self._unique_id], + self._device_instance.consumption_property[self._attr_unique_id], self._sensor_type, ) else: diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index cbc911fcd18e6..ba1bc20bfd284 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_failed": "Please use the same mydevolo user as before." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/subscriber.py b/homeassistant/components/devolo_home_control/subscriber.py index d291e4b174fed..13ffabeaba21b 100644 --- a/homeassistant/components/devolo_home_control/subscriber.py +++ b/homeassistant/components/devolo_home_control/subscriber.py @@ -1,5 +1,5 @@ """Subscriber for devolo home control API publisher.""" - +from collections.abc import Callable import logging _LOGGER = logging.getLogger(__name__) @@ -8,12 +8,12 @@ class Subscriber: """Subscriber class for the publisher in mprm websocket class.""" - def __init__(self, name, callback): + def __init__(self, name: str, callback: Callable) -> None: """Initiate the subscriber.""" self.name = name self.callback = callback - def update(self, message): + def update(self, message: str) -> None: """Trigger hass to update the device.""" _LOGGER.debug('%s got message "%s"', self.name, message) self.callback(message) diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 2a96198826b05..4896d66b8050d 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -1,14 +1,22 @@ """Platform for switch integration.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_device import DevoloDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all devices and setup the switch devices via config entry.""" entities = [] @@ -33,7 +41,9 @@ async def async_setup_entry( class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): """Representation of a switch.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize an devolo Switch.""" super().__init__( homecontrol=homecontrol, @@ -41,45 +51,24 @@ def __init__(self, homecontrol, device_instance, element_uid): element_uid=element_uid, ) self._binary_switch_property = self._device_instance.binary_switch_property.get( - self._unique_id + self._attr_unique_id ) - self._is_on = self._binary_switch_property.state - - if hasattr(self._device_instance, "consumption_property"): - self._consumption = self._device_instance.consumption_property.get( - self._unique_id.replace("BinarySwitch", "Meter") - ).current - else: - self._consumption = None - - @property - def is_on(self): - """Return the state.""" - return self._is_on - - @property - def current_power_w(self): - """Return the current consumption.""" - return self._consumption + self._attr_is_on = self._binary_switch_property.state - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Switch on the device.""" - self._is_on = True self._binary_switch_property.set(state=True) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Switch off the device.""" - self._is_on = False self._binary_switch_property.set(state=False) - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the binary switch state and consumption.""" if message[0].startswith("devolo.BinarySwitch"): - self._is_on = self._device_instance.binary_switch_property[message[0]].state - elif message[0].startswith("devolo.Meter"): - self._consumption = self._device_instance.consumption_property[ + self._attr_is_on = self._device_instance.binary_switch_property[ message[0] - ].current + ].state else: self._generic_message(message) self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/translations/bg.json b/homeassistant/components/devolo_home_control/translations/bg.json new file mode 100644 index 0000000000000..d5f922c14ffce --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/translations/ca.json b/homeassistant/components/devolo_home_control/translations/ca.json index 57ca0b7c2090e..73417c5c56449 100644 --- a/homeassistant/components/devolo_home_control/translations/ca.json +++ b/homeassistant/components/devolo_home_control/translations/ca.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "reauth_failed": "Utilitza el mateix usuari de mydevolo que abans." }, "step": { "user": { "data": { - "home_control_url": "URL de Home Control", "mydevolo_url": "URL de mydevolo", "password": "Contrasenya", "username": "Correu electr\u00f2nic / ID de devolo" diff --git a/homeassistant/components/devolo_home_control/translations/cs.json b/homeassistant/components/devolo_home_control/translations/cs.json index f41c2dc218fe6..5416934696897 100644 --- a/homeassistant/components/devolo_home_control/translations/cs.json +++ b/homeassistant/components/devolo_home_control/translations/cs.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" @@ -9,7 +10,6 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Heslo", "username": "E-mail / Devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/de.json b/homeassistant/components/devolo_home_control/translations/de.json index 251d058b42e19..4208d80eaec91 100644 --- a/homeassistant/components/devolo_home_control/translations/de.json +++ b/homeassistant/components/devolo_home_control/translations/de.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Konto wurde bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { - "invalid_auth": "Ung\u00fcltige Authentifizierung" + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "reauth_failed": "Bitte verwende denselben mydevolo-Benutzer wie zuvor." }, "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Passwort", "username": "E-Mail / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/en.json b/homeassistant/components/devolo_home_control/translations/en.json index e358e47ef0b1b..e5ea6a49cd805 100644 --- a/homeassistant/components/devolo_home_control/translations/en.json +++ b/homeassistant/components/devolo_home_control/translations/en.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { - "invalid_auth": "Invalid authentication" + "invalid_auth": "Invalid authentication", + "reauth_failed": "Please use the same mydevolo user as before." }, "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Password", "username": "Email / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/es-419.json b/homeassistant/components/devolo_home_control/translations/es-419.json new file mode 100644 index 0000000000000..b9e484949df05 --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "reauth_failed": "Utilice el mismo usuario mydevolo que antes." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/translations/es.json b/homeassistant/components/devolo_home_control/translations/es.json index 713f5a53d7393..b4a7a873aaa47 100644 --- a/homeassistant/components/devolo_home_control/translations/es.json +++ b/homeassistant/components/devolo_home_control/translations/es.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "La cuenta ya ha sido configurada" + "already_configured": "La cuenta ya ha sido configurada", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "reauth_failed": "Por favor, utiliza el mismo usuario de mydevolo que antes." }, "step": { "user": { "data": { - "home_control_url": "URL de Home Control", "mydevolo_url": "URL de mydevolo", "password": "Contrase\u00f1a", "username": "Correo electr\u00f3nico / ID de devolo" diff --git a/homeassistant/components/devolo_home_control/translations/et.json b/homeassistant/components/devolo_home_control/translations/et.json index f781e4b404225..8997f4952dfcc 100644 --- a/homeassistant/components/devolo_home_control/translations/et.json +++ b/homeassistant/components/devolo_home_control/translations/et.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Konto on juba seadistatud" + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { - "invalid_auth": "Tuvastamise viga" + "invalid_auth": "Tuvastamise viga", + "reauth_failed": "Palun kasuta sama mydevolo kasutajat nagu varem." }, "step": { "user": { "data": { - "home_control_url": "Home Control'i URL", "mydevolo_url": "mydevolo URL", "password": "Salas\u00f5na", "username": "E-post / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/fi.json b/homeassistant/components/devolo_home_control/translations/fi.json index 51dc72c408aa3..2bf76d9168d50 100644 --- a/homeassistant/components/devolo_home_control/translations/fi.json +++ b/homeassistant/components/devolo_home_control/translations/fi.json @@ -3,7 +3,6 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Salasana" } diff --git a/homeassistant/components/devolo_home_control/translations/fr.json b/homeassistant/components/devolo_home_control/translations/fr.json index 13354e9da768d..020b469092db3 100644 --- a/homeassistant/components/devolo_home_control/translations/fr.json +++ b/homeassistant/components/devolo_home_control/translations/fr.json @@ -1,25 +1,26 @@ { "config": { "abort": { - "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "invalid_auth": "Authentification invalide" + "invalid_auth": "Authentification invalide", + "reauth_failed": "Veuillez utiliser le m\u00eame utilisateur mydevolo que pr\u00e9c\u00e9demment." }, "step": { "user": { "data": { - "home_control_url": "URL Home Control", "mydevolo_url": "URL mydevolo", "password": "Mot de passe", - "username": "Adresse e-mail / devolo ID" + "username": "Email / devolo ID" } }, "zeroconf_confirm": { "data": { "mydevolo_url": "mydevolo URL", "password": "Mot de passe", - "username": "[%key:common::config_flow::d ata::email%] / devolo ID" + "username": "Email / devolo ID" } } } diff --git a/homeassistant/components/devolo_home_control/translations/he.json b/homeassistant/components/devolo_home_control/translations/he.json index ac90b3264eab3..2ac09df14fdd5 100644 --- a/homeassistant/components/devolo_home_control/translations/he.json +++ b/homeassistant/components/devolo_home_control/translations/he.json @@ -1,10 +1,24 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, "step": { "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + "username": "\u05d3\u05d5\u05d0\"\u05dc / \u05de\u05d6\u05d4\u05d4 devolo" + } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 mydevolo", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05d3\u05d5\u05d0\"\u05dc / \u05de\u05d6\u05d4\u05d4 devolo" } } } diff --git a/homeassistant/components/devolo_home_control/translations/hu.json b/homeassistant/components/devolo_home_control/translations/hu.json index 45b07f0adcb50..391eeb6072724 100644 --- a/homeassistant/components/devolo_home_control/translations/hu.json +++ b/homeassistant/components/devolo_home_control/translations/hu.json @@ -1,19 +1,27 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "reauth_failed": "K\u00e9rj\u00fck, ugyanazt a mydevolo felhaszn\u00e1l\u00f3t haszn\u00e1lja, mint kor\u00e1bban." }, "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Jelsz\u00f3", "username": "E-mail / devolo ID" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "Jelsz\u00f3", + "username": "E-mail / devolo azonos\u00edt\u00f3" + } } } } diff --git a/homeassistant/components/devolo_home_control/translations/id.json b/homeassistant/components/devolo_home_control/translations/id.json index 8b7ce0171d557..a4db1b3d6af55 100644 --- a/homeassistant/components/devolo_home_control/translations/id.json +++ b/homeassistant/components/devolo_home_control/translations/id.json @@ -1,19 +1,27 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { - "invalid_auth": "Autentikasi tidak valid" + "invalid_auth": "Autentikasi tidak valid", + "reauth_failed": "Gunakan pengguna mydevolo yang sama seperti sebelumnya." }, "step": { "user": { "data": { - "home_control_url": "URL Home Control", "mydevolo_url": "URL mydevolo", "password": "Kata Sandi", "username": "Email/ID devolo" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "URL mydevolo", + "password": "Kata Sandi", + "username": "Email/devolo ID" + } } } } diff --git a/homeassistant/components/devolo_home_control/translations/it.json b/homeassistant/components/devolo_home_control/translations/it.json index a0cba314ea668..e31377a47de36 100644 --- a/homeassistant/components/devolo_home_control/translations/it.json +++ b/homeassistant/components/devolo_home_control/translations/it.json @@ -1,25 +1,26 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { - "invalid_auth": "Autenticazione non valida" + "invalid_auth": "Autenticazione non valida", + "reauth_failed": "Utilizza lo stesso utente mydevolo di prima." }, "step": { "user": { "data": { - "home_control_url": "URL di Home Control", "mydevolo_url": "URL di mydevolo", "password": "Password", - "username": "E-mail / devolo ID" + "username": "Email / devolo ID" } }, "zeroconf_confirm": { "data": { "mydevolo_url": "URL mydevolo", "password": "Password", - "username": "E-mail / ID devolo" + "username": "Email / ID devolo" } } } diff --git a/homeassistant/components/devolo_home_control/translations/ja.json b/homeassistant/components/devolo_home_control/translations/ja.json new file mode 100644 index 0000000000000..c52a21772b25d --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "reauth_failed": "\u4ee5\u524d\u306e\u3068\u540c\u3058mydevolo\u30e6\u30fc\u30b6\u30fc\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb / devolo ID" + } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb / devolo ID" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/translations/ko.json b/homeassistant/components/devolo_home_control/translations/ko.json index f21122bff70d8..f3832dec7c16b 100644 --- a/homeassistant/components/devolo_home_control/translations/ko.json +++ b/homeassistant/components/devolo_home_control/translations/ko.json @@ -9,7 +9,13 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL \uc8fc\uc18c", + "mydevolo_url": "mydevolo URL \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c / devolo ID" + } + }, + "zeroconf_confirm": { + "data": { "mydevolo_url": "mydevolo URL \uc8fc\uc18c", "password": "\ube44\ubc00\ubc88\ud638", "username": "\uc774\uba54\uc77c / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/lb.json b/homeassistant/components/devolo_home_control/translations/lb.json index 3943dbd1d5b07..56f8362fca771 100644 --- a/homeassistant/components/devolo_home_control/translations/lb.json +++ b/homeassistant/components/devolo_home_control/translations/lb.json @@ -9,7 +9,6 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Passwuert", "username": "E-Mail / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/nl.json b/homeassistant/components/devolo_home_control/translations/nl.json index 0ae5696a23a41..c85c685597d46 100644 --- a/homeassistant/components/devolo_home_control/translations/nl.json +++ b/homeassistant/components/devolo_home_control/translations/nl.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Account is al geconfigureerd" + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { - "invalid_auth": "Ongeldige authenticatie" + "invalid_auth": "Ongeldige authenticatie", + "reauth_failed": "Gelieve dezelfde mydevolo-gebruiker te gebruiken als voorheen." }, "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Wachtwoord", "username": "E-mail adres / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/no.json b/homeassistant/components/devolo_home_control/translations/no.json index 3076e4679e01e..1f1ee69ae47aa 100644 --- a/homeassistant/components/devolo_home_control/translations/no.json +++ b/homeassistant/components/devolo_home_control/translations/no.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Kontoen er allerede konfigurert" + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { - "invalid_auth": "Ugyldig godkjenning" + "invalid_auth": "Ugyldig godkjenning", + "reauth_failed": "Bruk samme mydevolo-bruker som f\u00f8r." }, "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "Passord", "username": "E-post / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/pl.json b/homeassistant/components/devolo_home_control/translations/pl.json index e07f41deb6d70..0c0f18b1d6a7c 100644 --- a/homeassistant/components/devolo_home_control/translations/pl.json +++ b/homeassistant/components/devolo_home_control/translations/pl.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { - "invalid_auth": "Niepoprawne uwierzytelnienie" + "invalid_auth": "Niepoprawne uwierzytelnienie", + "reauth_failed": "U\u017cyj tego samego u\u017cytkownika mydevolo co poprzednio." }, "step": { "user": { "data": { - "home_control_url": "URL Home Control", "mydevolo_url": "URL mydevolo", "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika/identyfikator devolo" diff --git a/homeassistant/components/devolo_home_control/translations/pt.json b/homeassistant/components/devolo_home_control/translations/pt.json index ca6b9a6542c65..2215d148b7b71 100644 --- a/homeassistant/components/devolo_home_control/translations/pt.json +++ b/homeassistant/components/devolo_home_control/translations/pt.json @@ -9,7 +9,6 @@ "step": { "user": { "data": { - "home_control_url": "Home Control [VOID]", "mydevolo_url": "mydevolo [VOID]", "password": "Palavra-passe", "username": "Email / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/ru.json b/homeassistant/components/devolo_home_control/translations/ru.json index 66293556e7c5e..ab0463bc81195 100644 --- a/homeassistant/components/devolo_home_control/translations/ru.json +++ b/homeassistant/components/devolo_home_control/translations/ru.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "reauth_failed": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0442\u043e\u0433\u043e \u0436\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f mydevolo, \u0447\u0442\u043e \u0438 \u0440\u0430\u043d\u044c\u0448\u0435." }, "step": { "user": { "data": { - "home_control_url": "Home Control URL-\u0430\u0434\u0440\u0435\u0441", "mydevolo_url": "mydevolo URL-\u0430\u0434\u0440\u0435\u0441", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/sv.json b/homeassistant/components/devolo_home_control/translations/sv.json index 48cd7428a78a9..4479e25b2502e 100644 --- a/homeassistant/components/devolo_home_control/translations/sv.json +++ b/homeassistant/components/devolo_home_control/translations/sv.json @@ -3,7 +3,6 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL", "mydevolo_url": "mydevolo URL", "password": "L\u00f6senord", "username": "E-postadress / devolo-ID" diff --git a/homeassistant/components/devolo_home_control/translations/tr.json b/homeassistant/components/devolo_home_control/translations/tr.json index 4c6b158f6946b..0eebd9ec5ea47 100644 --- a/homeassistant/components/devolo_home_control/translations/tr.json +++ b/homeassistant/components/devolo_home_control/translations/tr.json @@ -1,17 +1,27 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "reauth_failed": "L\u00fctfen \u00f6ncekiyle ayn\u0131 mydevolo kullan\u0131c\u0131s\u0131n\u0131 kullan\u0131n." }, "step": { "user": { "data": { + "mydevolo_url": "mydevolo URL", "password": "Parola", "username": "E-posta / devolo ID" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "Parola", + "username": "E-posta / devolo kimli\u011fi" + } } } } diff --git a/homeassistant/components/devolo_home_control/translations/uk.json b/homeassistant/components/devolo_home_control/translations/uk.json index d230d1918f5e0..d9546a36eb10d 100644 --- a/homeassistant/components/devolo_home_control/translations/uk.json +++ b/homeassistant/components/devolo_home_control/translations/uk.json @@ -9,7 +9,6 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL-\u0430\u0434\u0440\u0435\u0441\u0430", "mydevolo_url": "mydevolo URL-\u0430\u0434\u0440\u0435\u0441\u0430", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0410\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438 / devolo ID" diff --git a/homeassistant/components/devolo_home_control/translations/zh-Hant.json b/homeassistant/components/devolo_home_control/translations/zh-Hant.json index b855480da9eec..3bdd499bdbd2a 100644 --- a/homeassistant/components/devolo_home_control/translations/zh-Hant.json +++ b/homeassistant/components/devolo_home_control/translations/zh-Hant.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "reauth_failed": "\u8acb\u4f7f\u7528\u8207\u5148\u524d\u76f8\u540c\u7684 mydevolo \u4f7f\u7528\u8005\u3002" }, "step": { "user": { "data": { - "home_control_url": "Home Control \u7db2\u5740", "mydevolo_url": "mydevolo \u7db2\u5740", "password": "\u5bc6\u78bc", "username": "\u96fb\u5b50\u90f5\u4ef6 / devolo ID" diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py new file mode 100644 index 0000000000000..f427e5acbfc96 --- /dev/null +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -0,0 +1,122 @@ +"""The devolo Home Network integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import async_timeout +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DeviceNotFound, DeviceUnavailable + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + LONG_UPDATE_INTERVAL, + NEIGHBORING_WIFI_NETWORKS, + PLATFORMS, + SHORT_UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up devolo Home Network from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + async_client = get_async_client(hass) + + try: + device = Device( + ip=entry.data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance + ) + await device.async_connect(session_instance=async_client) + except DeviceNotFound as err: + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}" + ) from err + + async def async_update_connected_plc_devices() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(10): + return await device.plcnet.async_get_network_overview() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def async_update_wifi_connected_station() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(10): + return await device.device.async_get_wifi_connected_station() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def async_update_wifi_neighbor_access_points() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(30): + return await device.device.async_get_wifi_neighbor_access_points() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def disconnect(event: Event) -> None: + """Disconnect from device.""" + await device.async_disconnect() + + coordinators: dict[str, DataUpdateCoordinator] = {} + if device.plcnet: + coordinators[CONNECTED_PLC_DEVICES] = DataUpdateCoordinator( + hass, + _LOGGER, + name=CONNECTED_PLC_DEVICES, + update_method=async_update_connected_plc_devices, + update_interval=LONG_UPDATE_INTERVAL, + ) + if device.device and "wifi1" in device.device.features: + coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=CONNECTED_WIFI_CLIENTS, + update_method=async_update_wifi_connected_station, + update_interval=SHORT_UPDATE_INTERVAL, + ) + coordinators[NEIGHBORING_WIFI_NETWORKS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=NEIGHBORING_WIFI_NETWORKS, + update_method=async_update_wifi_neighbor_access_points, + update_interval=LONG_UPDATE_INTERVAL, + ) + + hass.data[DOMAIN][entry.entry_id] = {"device": device, "coordinators": coordinators} + + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py new file mode 100644 index 0000000000000..765d16177d933 --- /dev/null +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for devolo Home Network integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DeviceNotFound +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + zeroconf_instance = await zeroconf.async_get_instance(hass) + async_client = get_async_client(hass) + + device = Device(data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance) + + await device.async_connect(session_instance=async_client) + await device.async_disconnect() + + return { + SERIAL_NUMBER: str(device.serial_number), + TITLE: device.hostname.split(".")[0], + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for devolo Home Network.""" + + VERSION = 1 + + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + try: + info = await validate_input(self.hass, user_input) + except DeviceNotFound: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[SERIAL_NUMBER], raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info[TITLE], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zerooconf discovery.""" + if discovery_info.properties["MT"] in ["2600", "2601"]: + return self.async_abort(reason="home_control") + + await self.async_set_unique_id(discovery_info.properties["SN"]) + self._abort_if_unique_id_configured() + + self.context[CONF_HOST] = discovery_info.host + self.context["title_placeholders"] = { + PRODUCT: discovery_info.properties["Product"], + CONF_NAME: discovery_info.hostname.split(".")[0], + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + title = self.context["title_placeholders"][CONF_NAME] + if user_input is not None: + data = { + CONF_IP_ADDRESS: self.context[CONF_HOST], + } + return self.async_create_entry(title=title, data=data) + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"host_name": title}, + ) diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py new file mode 100644 index 0000000000000..bd7170bfde570 --- /dev/null +++ b/homeassistant/components/devolo_home_network/const.py @@ -0,0 +1,19 @@ +"""Constants for the devolo Home Network integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "devolo_home_network" +PLATFORMS = [Platform.SENSOR] + +PRODUCT = "product" +SERIAL_NUMBER = "serial_number" +TITLE = "title" + +LONG_UPDATE_INTERVAL = timedelta(minutes=5) +SHORT_UPDATE_INTERVAL = timedelta(seconds=15) + +CONNECTED_PLC_DEVICES = "connected_plc_devices" +CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" +NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py new file mode 100644 index 0000000000000..dbfe0e4035a12 --- /dev/null +++ b/homeassistant/components/devolo_home_network/entity.py @@ -0,0 +1,37 @@ +"""Generic platform.""" +from __future__ import annotations + +from devolo_plc_api.device import Device + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class DevoloEntity(CoordinatorEntity): + """Representation of a devolo home network device.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, device: Device, device_name: str + ) -> None: + """Initialize a devolo home network device.""" + super().__init__(coordinator) + + self._device = device + self._device_name = device_name + + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{self._device.ip}", + identifiers={(DOMAIN, str(self._device.serial_number))}, + manufacturer="devolo", + model=self._device.product, + name=self._device_name, + sw_version=self._device.firmware_version, + ) + self._attr_unique_id = ( + f"{self._device.serial_number}_{self.entity_description.key}" + ) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json new file mode 100644 index 0000000000000..ef3f1f0c82a6e --- /dev/null +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "devolo_home_network", + "name": "devolo Home Network", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", + "requirements": ["devolo-plc-api==0.6.3"], + "zeroconf": ["_dvl-deviceapi._tcp.local."], + "codeowners": ["@2Fake", "@Shutgun"], + "quality_scale": "platinum", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py new file mode 100644 index 0000000000000..08d61cd6eff4e --- /dev/null +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -0,0 +1,130 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from devolo_plc_api.device import Device + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + NEIGHBORING_WIFI_NETWORKS, +) +from .entity import DevoloEntity + + +@dataclass +class DevoloSensorRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[dict[str, Any]], int] + + +@dataclass +class DevoloSensorEntityDescription( + SensorEntityDescription, DevoloSensorRequiredKeysMixin +): + """Describes devolo sensor entity.""" + + +SENSOR_TYPES: dict[str, DevoloSensorEntityDescription] = { + CONNECTED_PLC_DEVICES: DevoloSensorEntityDescription( + key=CONNECTED_PLC_DEVICES, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:lan", + name="Connected PLC devices", + value_func=lambda data: len( + {device["mac_address_from"] for device in data["network"]["data_rates"]} + ), + ), + CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription( + key=CONNECTED_WIFI_CLIENTS, + entity_registry_enabled_default=True, + icon="mdi:wifi", + name="Connected Wifi clients", + state_class=SensorStateClass.MEASUREMENT, + value_func=lambda data: len(data["connected_stations"]), + ), + NEIGHBORING_WIFI_NETWORKS: DevoloSensorEntityDescription( + key=NEIGHBORING_WIFI_NETWORKS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:wifi-marker", + name="Neighboring Wifi networks", + value_func=lambda data: len(data["neighbor_aps"]), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id][ + "coordinators" + ] + + entities: list[DevoloSensorEntity] = [] + if device.plcnet: + entities.append( + DevoloSensorEntity( + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[CONNECTED_PLC_DEVICES], + device, + entry.title, + ) + ) + if device.device and "wifi1" in device.device.features: + entities.append( + DevoloSensorEntity( + coordinators[CONNECTED_WIFI_CLIENTS], + SENSOR_TYPES[CONNECTED_WIFI_CLIENTS], + device, + entry.title, + ) + ) + entities.append( + DevoloSensorEntity( + coordinators[NEIGHBORING_WIFI_NETWORKS], + SENSOR_TYPES[NEIGHBORING_WIFI_NETWORKS], + device, + entry.title, + ) + ) + async_add_entities(entities) + + +class DevoloSensorEntity(DevoloEntity, SensorEntity): + """Representation of a devolo sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: DevoloSensorEntityDescription, + device: Device, + device_name: str, + ) -> None: + """Initialize entity.""" + self.entity_description: DevoloSensorEntityDescription = description + super().__init__(coordinator, device, device_name) + + @property + def native_value(self) -> int: + """State of the sensor.""" + return self.entity_description.value_func(self.coordinator.data) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json new file mode 100644 index 0000000000000..685e139d2b8a9 --- /dev/null +++ b/homeassistant/components/devolo_home_network/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{product} ({name})", + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo home network device" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "home_control": "The devolo Home Control Central Unit does not work with this integration." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/af.json b/homeassistant/components/devolo_home_network/translations/af.json new file mode 100644 index 0000000000000..47aa8910aaa49 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/af.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van \u00e1ll\u00edtva" + }, + "error": { + "cannot_connect": "Sikertelen kapcsol\u00f3d\u00e1s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "ip_address": "IP c\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/bg.json b/homeassistant/components/devolo_home_network/translations/bg.json new file mode 100644 index 0000000000000..c1dc13fe2d7ac --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0437\u0430\u043f\u043e\u0447\u043d\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435\u0442\u043e?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/ca.json b/homeassistant/components/devolo_home_network/translations/ca.json new file mode 100644 index 0000000000000..c175a1a1246f8 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/ca.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "home_control": "La unitat central de control dom\u00e8stic de devolo no funciona amb aquesta integraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Adre\u00e7a IP" + }, + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + }, + "zeroconf_confirm": { + "description": "Vols afegir a Home Assistant el dispositiu de xarxa dom\u00e8stica devolo amb nom d'amfitri\u00f3 `{host_name}`?", + "title": "Dispositiu de xarxa dom\u00e8stica devolo descobert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/de.json b/homeassistant/components/devolo_home_network/translations/de.json new file mode 100644 index 0000000000000..c018c757d1653 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/de.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "home_control": "Die devolo Home Control-Zentraleinheit funktioniert nicht mit dieser Integration." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP-Adresse" + }, + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + }, + "zeroconf_confirm": { + "description": "M\u00f6chtest du das devolo-Heimnetzwerkger\u00e4t mit dem Hostnamen `{host_name}` zum Home Assistant hinzuf\u00fcgen?", + "title": "Gefundenes devolo Heimnetzwerkger\u00e4t" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/en.json b/homeassistant/components/devolo_home_network/translations/en.json new file mode 100644 index 0000000000000..39c0b6d331f5e --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "home_control": "The devolo Home Control Central Unit does not work with this integration." + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP Address" + }, + "description": "Do you want to start set up?" + }, + "zeroconf_confirm": { + "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo home network device" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/et.json b/homeassistant/components/devolo_home_network/translations/et.json new file mode 100644 index 0000000000000..dff9df53c72be --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/et.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "home_control": "Devolo Home Controli kesk\u00fcksus ei t\u00f6\u00f6ta selle sidumisega." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{product} ( {name} )", + "step": { + "user": { + "data": { + "ip_address": "IP aadress" + }, + "description": "Kas alutada seadistamist?" + }, + "zeroconf_confirm": { + "description": "Kas soovitd lisada devolo koduv\u00f5rgu seadme hostinimega `{host_name}` Home Assistanti?", + "title": "Avastati devolo koduv\u00f5rgu seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/fr.json b/homeassistant/components/devolo_home_network/translations/fr.json new file mode 100644 index 0000000000000..49dc24e0db14e --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/fr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "home_control": "L'unit\u00e9 centrale devolo Home Control ne fonctionne pas avec cette int\u00e9gration." + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "flow_title": "{product} ( {name} )", + "step": { + "user": { + "data": { + "ip_address": "Adresse IP" + }, + "description": "Voulez-vous commencer la configuration ?" + }, + "zeroconf_confirm": { + "description": "Voulez-vous ajouter le p\u00e9riph\u00e9rique r\u00e9seau domestique devolo avec le nom d'h\u00f4te ` {host_name} ` \u00e0 Home Assistant\u00a0?", + "title": "Appareil r\u00e9seau domestique devolo d\u00e9couvert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/he.json b/homeassistant/components/devolo_home_network/translations/he.json new file mode 100644 index 0000000000000..6bb4c9a7ed37b --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP" + }, + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/hu.json b/homeassistant/components/devolo_home_network/translations/hu.json new file mode 100644 index 0000000000000..dfae08312dfb5 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/hu.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "home_control": "A devolo Home Control k\u00f6zponti egys\u00e9g nem m\u0171k\u00f6dik ezzel az integr\u00e1ci\u00f3val." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP c\u00edm" + }, + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" + }, + "zeroconf_confirm": { + "description": "Szeretn\u00e9 hozz\u00e1adni a `{host_name}`nev\u0171 a devolo otthoni h\u00e1l\u00f3zati eszk\u00f6zt Home Assistanthoz?", + "title": "Felfedezett devolo otthoni h\u00e1l\u00f3zati eszk\u00f6z" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/id.json b/homeassistant/components/devolo_home_network/translations/id.json new file mode 100644 index 0000000000000..0950f6a271182 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/id.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "home_control": "Unit Central devolo Home Control tidak berfungsi dengan integrasi ini." + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Alamat IP" + }, + "description": "Ingin memulai penyiapan?" + }, + "zeroconf_confirm": { + "description": "Ingin menambahkan perangkat jaringan rumah devolo dengan nama host `{host_name}` ke Home Assistant?", + "title": "Menemukan perangkat jaringan rumah devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/it.json b/homeassistant/components/devolo_home_network/translations/it.json new file mode 100644 index 0000000000000..118ad0e79c64d --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "home_control": "L'unit\u00e0 centrale devolo Home Control non funziona con questa integrazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Indirizzo IP" + }, + "description": "Vuoi iniziare la configurazione?" + }, + "zeroconf_confirm": { + "description": "Vuoi aggiungere il dispositivo di rete domestica devolo con il nome host `{host_name}` a Home Assistant?", + "title": "Rilevato dispositivo di rete domestica devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/ja.json b/homeassistant/components/devolo_home_network/translations/ja.json new file mode 100644 index 0000000000000..ee08879f713af --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "home_control": "devolo Home Control Central Unit\u306f\u3001\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u52d5\u4f5c\u3057\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "zeroconf_confirm": { + "description": "\u30db\u30b9\u30c8\u540d\u304c `{host_name}` \u306e devolo\u793e\u306e\u30db\u30fc\u30e0\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30c7\u30d0\u30a4\u30b9\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "devolo\u793e\u306e\u30db\u30fc\u30e0\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u6a5f\u5668\u3092\u767a\u898b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/nl.json b/homeassistant/components/devolo_home_network/translations/nl.json new file mode 100644 index 0000000000000..e8730f44b5e3e --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "home_control": "De devolo Home Control Centrale Unit werkt niet met deze integratie." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP-adres" + }, + "description": "Wilt u beginnen met instellen?" + }, + "zeroconf_confirm": { + "description": "Wilt u het devolo-thuisnetwerkapparaat met de hostnaam ` {host_name} ` aan Home Assistant toevoegen?", + "title": "Ontdekt devolo thuisnetwerk apparaat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/no.json b/homeassistant/components/devolo_home_network/translations/no.json new file mode 100644 index 0000000000000..405434abc4a5d --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/no.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "home_control": "Devolo Home Control Central Unit fungerer ikke med denne integrasjonen." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "flow_title": "{product} ( {name} )", + "step": { + "user": { + "data": { + "ip_address": "IP adresse" + }, + "description": "Vil du starte oppsettet?" + }, + "zeroconf_confirm": { + "description": "Vil du legge til devolo hjemmenettverksenheten med vertsnavnet ` {host_name} ` til Home Assistant?", + "title": "Oppdaget devolo hjemmenettverksenhet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/pl.json b/homeassistant/components/devolo_home_network/translations/pl.json new file mode 100644 index 0000000000000..4abe26671002d --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/pl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "home_control": "Ta jednostka devolo Home Control Central nie wsp\u00f3\u0142pracuje z t\u0105 integracj\u0105." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Adres IP" + }, + "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" + }, + "zeroconf_confirm": { + "description": "Czy chcesz doda\u0107 do Home Assistanta urz\u0105dzenie sieciowe devolo o nazwie \"{host_name}\"?", + "title": "Wykryto urz\u0105dzenie sieciowe devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/pt-BR.json b/homeassistant/components/devolo_home_network/translations/pt-BR.json new file mode 100644 index 0000000000000..edffd23f3afb1 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/pt-BR.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado", + "home_control": "A Unidade Central de Home Control Devolo n\u00e3o funciona com esta integra\u00e7\u00e3o." + }, + "error": { + "cannot_connect": "Falha ao conectar", + "unknown": "Erro inesperado" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Endere\u00e7o IP" + }, + "description": "Deseja iniciar a configura\u00e7\u00e3o?" + }, + "zeroconf_confirm": { + "description": "Deseja adicionar o dispositivo de rede dom\u00e9stica Devolo com o nome \"{host_name}\" ao Home Assistant?", + "title": "Dispositivo de rede dom\u00e9stica Devolo descoberto" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/ru.json b/homeassistant/components/devolo_home_network/translations/ru.json new file mode 100644 index 0000000000000..4cc909b8816a4 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/ru.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "home_control": "\u0426\u0435\u043d\u0442\u0440\u0430\u043b\u044c\u043d\u044b\u0439 \u0431\u043b\u043e\u043a \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f devolo Home Control \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0441 \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + }, + "zeroconf_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e devolo `{host_name}` \u0432 Home Assistant?", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/sl.json b/homeassistant/components/devolo_home_network/translations/sl.json new file mode 100644 index 0000000000000..7f0bff8bc21bf --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "ip_address": "IP naslov" + }, + "description": "Ali \u017eelite za\u010deti z nastavitvijo?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/th.json b/homeassistant/components/devolo_home_network/translations/th.json new file mode 100644 index 0000000000000..2fd6d1c083a3d --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0e01\u0e32\u0e23\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27", + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e04\u0e34\u0e14" + }, + "step": { + "user": { + "data": { + "ip_address": "IP \u0e41\u0e2d\u0e14\u0e40\u0e14\u0e23\u0e2a" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/tr.json b/homeassistant/components/devolo_home_network/translations/tr.json new file mode 100644 index 0000000000000..841ae1773cad6 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/tr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "home_control": "devolo Ev Kontrol Merkezi Birimi bu entegrasyonla \u00e7al\u0131\u015fmaz." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP Adresi" + }, + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "zeroconf_confirm": { + "description": "{host_name} ` ana bilgisayar ad\u0131na sahip devolo ev a\u011f\u0131 cihaz\u0131n\u0131 Home Assistant'a eklemek ister misiniz?", + "title": "Ke\u015ffedilen devolo ev a\u011f\u0131 cihaz\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/zh-Hant.json b/homeassistant/components/devolo_home_network/translations/zh-Hant.json new file mode 100644 index 0000000000000..17eb11eb070ae --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "home_control": "Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e\u8207\u6b64\u6574\u5408\u4e0d\u76f8\u5bb9\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP \u4f4d\u5740" + }, + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + }, + "zeroconf_confirm": { + "description": "\u662f\u5426\u8981\u5c07\u4e3b\u6a5f\u540d\u7a31\u70ba `{host_name}` \u7684 Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e\u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u81ea\u52d5\u63a2\u7d22\u5230 Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py index 1c02a86ca4286..8db69b389279c 100644 --- a/homeassistant/components/dexcom/__init__.py +++ b/homeassistant/components/dexcom/__init__.py @@ -25,7 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=180) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Dexcom from a config entry.""" try: dexcom = await hass.async_add_executor_job( @@ -71,7 +71,7 @@ async def async_update_data(): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 37e6c5e97561e..063d14549db01 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -61,7 +61,7 @@ def async_get_options_flow(config_entry): class DexcomOptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Dexcom.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/dexcom/const.py b/homeassistant/components/dexcom/const.py index 40b7e32df6c73..cb75f3bd500cf 100644 --- a/homeassistant/components/dexcom/const.py +++ b/homeassistant/components/dexcom/const.py @@ -1,8 +1,8 @@ """Constants for the Dexcom integration.""" +from homeassistant.const import Platform DOMAIN = "dexcom" -PLATFORMS = ["sensor"] - +PLATFORMS = [Platform.SENSOR] GLUCOSE_VALUE_ICON = "mdi:diabetes" GLUCOSE_TREND_ICON = [ diff --git a/homeassistant/components/dexcom/manifest.json b/homeassistant/components/dexcom/manifest.json index 1321f38a0d72d..6133a67bcf16e 100644 --- a/homeassistant/components/dexcom/manifest.json +++ b/homeassistant/components/dexcom/manifest.json @@ -3,7 +3,7 @@ "name": "Dexcom", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dexcom", - "requirements": ["pydexcom==0.2.0"], + "requirements": ["pydexcom==0.2.2"], "codeowners": ["@gagebenne"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 730a1824e1aad..316f36e3630b7 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -42,12 +42,12 @@ def icon(self): return GLUCOSE_VALUE_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the device.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return getattr(self.coordinator.data, self._attribute_unit_of_measurement) @@ -82,7 +82,7 @@ def icon(self): return GLUCOSE_TREND_ICON[0] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return self.coordinator.data.trend_description diff --git a/homeassistant/components/dexcom/translations/bg.json b/homeassistant/components/dexcom/translations/bg.json new file mode 100644 index 0000000000000..ec574a06c2fcb --- /dev/null +++ b/homeassistant/components/dexcom/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "server": "\u0421\u044a\u0440\u0432\u044a\u0440", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/ca.json b/homeassistant/components/dexcom/translations/ca.json index e188718a71d56..7b97a209e4916 100644 --- a/homeassistant/components/dexcom/translations/ca.json +++ b/homeassistant/components/dexcom/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/dexcom/translations/de.json b/homeassistant/components/dexcom/translations/de.json index 20e5ee22751f2..be04c779390bd 100644 --- a/homeassistant/components/dexcom/translations/de.json +++ b/homeassistant/components/dexcom/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/dexcom/translations/es-419.json b/homeassistant/components/dexcom/translations/es-419.json new file mode 100644 index 0000000000000..a2d55e2b462eb --- /dev/null +++ b/homeassistant/components/dexcom/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "server": "Servidor" + }, + "description": "Ingrese las credenciales de Dexcom Share", + "title": "Configurar la integraci\u00f3n de Dexcom" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "Unidad de medida" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/he.json b/homeassistant/components/dexcom/translations/he.json new file mode 100644 index 0000000000000..454b7e1ae510e --- /dev/null +++ b/homeassistant/components/dexcom/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/hu.json b/homeassistant/components/dexcom/translations/hu.json index 45f38b22a84a0..039eb56f8f06a 100644 --- a/homeassistant/components/dexcom/translations/hu.json +++ b/homeassistant/components/dexcom/translations/hu.json @@ -14,7 +14,9 @@ "password": "Jelsz\u00f3", "server": "Szerver", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Adja meg a Dexcom Share hiteles\u00edt\u0151 adatait", + "title": "Dexcom integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa" } } }, diff --git a/homeassistant/components/dexcom/translations/it.json b/homeassistant/components/dexcom/translations/it.json index b196f9f87edf7..21904e36378f4 100644 --- a/homeassistant/components/dexcom/translations/it.json +++ b/homeassistant/components/dexcom/translations/it.json @@ -16,7 +16,7 @@ "username": "Nome utente" }, "description": "Inserisci le credenziali di Dexcom Share", - "title": "Configurare l'integrazione di Dexcom" + "title": "Configura l'integrazione di Dexcom" } } }, diff --git a/homeassistant/components/dexcom/translations/ja.json b/homeassistant/components/dexcom/translations/ja.json new file mode 100644 index 0000000000000..21cc971beec18 --- /dev/null +++ b/homeassistant/components/dexcom/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "server": "\u30b5\u30fc\u30d0\u30fc", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Dexcom Share\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3059\u308b", + "title": "Dexcom\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "\u6e2c\u5b9a\u5358\u4f4d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/tr.json b/homeassistant/components/dexcom/translations/tr.json index ec93dc078afb9..8ff66f07ccf00 100644 --- a/homeassistant/components/dexcom/translations/tr.json +++ b/homeassistant/components/dexcom/translations/tr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Hesap zaten konfig\u00fcre edilmi\u015fi durumda" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -12,8 +12,11 @@ "user": { "data": { "password": "Parola", + "server": "Sunucu", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Dexcom Share kimlik bilgilerini girin", + "title": "Dexcom entegrasyonunu kurun" } } }, diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 5d0b31c878850..282049436067f 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -1,12 +1,13 @@ """The dhcp integration.""" -from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import fnmatch from ipaddress import ip_address as make_ip_address import logging import os import threading +from typing import Any, Final from aiodiscover import DiscoverHosts from aiodiscover.discovery import ( @@ -14,14 +15,10 @@ IP_ADDRESS as DISCOVERY_IP_ADDRESS, MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) -from scapy.arch.common import compile_filter from scapy.config import conf from scapy.error import Scapy_Exception -from scapy.layers.dhcp import DHCP -from scapy.layers.inet import IP -from scapy.layers.l2 import Ether -from scapy.sendrecv import AsyncSniffer +from homeassistant import config_entries from homeassistant.components.device_tracker.const import ( ATTR_HOST_NAME, ATTR_IP, @@ -36,12 +33,17 @@ STATE_HOME, ) from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.data_entry_flow import BaseServiceInfo +from homeassistant.helpers import discovery_flow from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.frame import report +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_dhcp +from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.network import is_invalid, is_link_local, is_loopback from .const import DOMAIN @@ -49,16 +51,55 @@ FILTER = "udp and (port 67 or 68)" REQUESTED_ADDR = "requested_addr" MESSAGE_TYPE = "message-type" -HOSTNAME = "hostname" -MAC_ADDRESS = "macaddress" -IP_ADDRESS = "ip" +HOSTNAME: Final = "hostname" +MAC_ADDRESS: Final = "macaddress" +IP_ADDRESS: Final = "ip" DHCP_REQUEST = 3 SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +@dataclass +class DhcpServiceInfo(BaseServiceInfo): + """Prepared info from dhcp entries.""" + + ip: str + hostname: str + macaddress: str + + def __getitem__(self, name: str) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; " + "this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + return getattr(self, name) + + def get(self, name: str, default: Any = None) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + report( + f"accessed discovery_info.get('{name}') instead of discovery_info.{name}; " + "this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + if hasattr(self, name): + return getattr(self, name) + return default + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" async def _initialize(_): @@ -93,6 +134,17 @@ def __init__(self, hass, address_data, integration_matchers): self._address_data = address_data def process_client(self, ip_address, hostname, mac_address): + """Process a client.""" + return run_callback_threadsafe( + self.hass.loop, + self.async_process_client, + ip_address, + hostname, + mac_address, + ).result() + + @callback + def async_process_client(self, ip_address, hostname, mac_address): """Process a client.""" made_ip_address = make_ip_address(ip_address) @@ -105,7 +157,6 @@ def process_client(self, ip_address, hostname, mac_address): return data = self._address_data.get(ip_address) - if ( data and data[MAC_ADDRESS] == mac_address @@ -115,12 +166,9 @@ def process_client(self, ip_address, hostname, mac_address): # to process it return - self._address_data[ip_address] = {MAC_ADDRESS: mac_address, HOSTNAME: hostname} - - self.process_updated_address_data(ip_address, self._address_data[ip_address]) + data = {MAC_ADDRESS: mac_address, HOSTNAME: hostname} + self._address_data[ip_address] = data - def process_updated_address_data(self, ip_address, data): - """Process the address data update.""" lowercase_hostname = data[HOSTNAME].lower() uppercase_mac = data[MAC_ADDRESS].upper() @@ -143,23 +191,17 @@ def process_updated_address_data(self, ip_address, data): continue _LOGGER.debug("Matched %s against %s", data, entry) - - self.create_task( - self.hass.config_entries.flow.async_init( - entry["domain"], - context={"source": DOMAIN}, - data={ - IP_ADDRESS: ip_address, - HOSTNAME: lowercase_hostname, - MAC_ADDRESS: data[MAC_ADDRESS], - }, - ) + discovery_flow.async_create_flow( + self.hass, + entry["domain"], + {"source": config_entries.SOURCE_DHCP}, + DhcpServiceInfo( + ip=ip_address, + hostname=lowercase_hostname, + macaddress=data[MAC_ADDRESS], + ), ) - @abstractmethod - def create_task(self, task): - """Pass a task to async_add_task based on which context we are in.""" - class NetworkWatcher(WatcherBase): """Class to query ptr records routers.""" @@ -193,21 +235,17 @@ def async_start_discover(self, *_): """Start a new discovery task if one is not running.""" if self._discover_task and not self._discover_task.done(): return - self._discover_task = self.create_task(self.async_discover()) + self._discover_task = self.hass.async_create_task(self.async_discover()) async def async_discover(self): """Process discovery.""" for host in await self._discover_hosts.async_discover(): - self.process_client( + self.async_process_client( host[DISCOVERY_IP_ADDRESS], host[DISCOVERY_HOSTNAME], _format_mac(host[DISCOVERY_MAC_ADDRESS]), ) - def create_task(self, task): - """Pass a task to async_create_task since we are in async context.""" - return self.hass.async_create_task(task) - class DeviceTrackerWatcher(WatcherBase): """Class to watch dhcp data from routers.""" @@ -248,17 +286,13 @@ def _async_process_device_state(self, state: State): return ip_address = attributes.get(ATTR_IP) - hostname = attributes.get(ATTR_HOST_NAME) + hostname = attributes.get(ATTR_HOST_NAME, "") mac_address = attributes.get(ATTR_MAC) - if ip_address is None or hostname is None or mac_address is None: + if ip_address is None or mac_address is None: return - self.process_client(ip_address, hostname, _format_mac(mac_address)) - - def create_task(self, task): - """Pass a task to async_create_task since we are in async context.""" - return self.hass.async_create_task(task) + self.async_process_client(ip_address, hostname, _format_mac(mac_address)) class DHCPWatcher(WatcherBase): @@ -281,11 +315,53 @@ def _stop(self): async def async_start(self): """Start watching for dhcp packets.""" + await self.hass.async_add_executor_job(self._start) + + def _start(self): + """Start watching for dhcp packets.""" + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 + arch, + ) + from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel + from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel + from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel + + # + # Importing scapy.sendrecv will cause a scapy resync which will + # import scapy.arch.read_routes which will import scapy.sendrecv + # + # We avoid this circular import by importing arch above to ensure + # the module is loaded and avoid the problem + # + from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel + AsyncSniffer, + ) + + def _handle_dhcp_packet(packet): + """Process a dhcp packet.""" + if DHCP not in packet: + return + + options = packet[DHCP].options + request_type = _decode_dhcp_option(options, MESSAGE_TYPE) + if request_type != DHCP_REQUEST: + # Not a DHCP request + return + + ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src + hostname = _decode_dhcp_option(options, HOSTNAME) or "" + mac_address = _format_mac(packet[Ether].src) + + if ip_address is not None and mac_address is not None: + self.process_client(ip_address, hostname, mac_address) + # disable scapy promiscuous mode as we do not need it conf.sniff_promisc = 0 try: - await self.hass.async_add_executor_job(_verify_l2socket_setup, FILTER) + _verify_l2socket_setup(FILTER) except (Scapy_Exception, OSError) as ex: if os.geteuid() == 0: _LOGGER.error("Cannot watch for dhcp packets: %s", ex) @@ -296,7 +372,7 @@ async def async_start(self): return try: - await self.hass.async_add_executor_job(_verify_working_pcap, FILTER) + _verify_working_pcap(FILTER) except (Scapy_Exception, ImportError) as ex: _LOGGER.error( "Cannot watch for dhcp packets without a functional packet filter: %s", @@ -307,7 +383,7 @@ async def async_start(self): self._sniffer = AsyncSniffer( filter=FILTER, started_callback=self._started.set, - prn=self.handle_dhcp_packet, + prn=_handle_dhcp_packet, store=0, ) @@ -315,31 +391,6 @@ async def async_start(self): if self._sniffer.thread: self._sniffer.thread.name = self.__class__.__name__ - def handle_dhcp_packet(self, packet): - """Process a dhcp packet.""" - if DHCP not in packet: - return - - options = packet[DHCP].options - - request_type = _decode_dhcp_option(options, MESSAGE_TYPE) - if request_type != DHCP_REQUEST: - # DHCP request - return - - ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src - hostname = _decode_dhcp_option(options, HOSTNAME) - mac_address = _format_mac(packet[Ether].src) - - if ip_address is None or hostname is None or mac_address is None: - return - - self.process_client(ip_address, hostname, mac_address) - - def create_task(self, task): - """Pass a task to hass.add_job since we are in a thread.""" - return self.hass.add_job(task) - def _decode_dhcp_option(dhcp_options, key): """Extract and decode data from a packet option.""" @@ -381,4 +432,10 @@ def _verify_working_pcap(cap_filter): If we cannot create a filter we will be listening for all traffic which is too intensive. """ + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy.arch.common import ( # pylint: disable=import-outside-toplevel + compile_filter, + ) + compile_filter(cap_filter) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 5808226500679..312b83c331144 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -2,7 +2,7 @@ "domain": "dhcp", "name": "DHCP Discovery", "documentation": "https://www.home-assistant.io/integrations/dhcp", - "requirements": ["scapy==2.4.5", "aiodiscover==1.4.0"], + "requirements": ["scapy==2.4.5", "aiodiscover==1.4.5"], "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/dht/manifest.json b/homeassistant/components/dht/manifest.json index 9067b930f0a11..3eb3cfd202cfd 100644 --- a/homeassistant/components/dht/manifest.json +++ b/homeassistant/components/dht/manifest.json @@ -2,7 +2,12 @@ "domain": "dht", "name": "DHT Sensor", "documentation": "https://www.home-assistant.io/integrations/dht", - "requirements": ["adafruit-circuitpython-dht==3.6.0"], - "codeowners": ["@thegardenmonkey"], + "requirements": [ + "adafruit-circuitpython-dht==3.7.0", + "RPi.GPIO==0.7.1a4" + ], + "codeowners": [ + "@thegardenmonkey" + ], "iot_class": "local_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 7278083296098..27bf7baefc239 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -1,5 +1,6 @@ """Support for Adafruit DHT temperature and humidity sensor.""" -from contextlib import suppress +from __future__ import annotations + from datetime import timedelta import logging @@ -7,17 +8,22 @@ import board import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PIN, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -32,10 +38,24 @@ SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None], - SENSOR_HUMIDITY: ["Humidity", PERCENTAGE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] def validate_pin_input(value): @@ -52,7 +72,7 @@ def validate_pin_input(value): vol.Required(CONF_SENSOR): cv.string, vol.Required(CONF_PIN): vol.All(cv.string, validate_pin_input), vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TEMPERATURE_OFFSET, default=0): vol.All( @@ -67,7 +87,13 @@ def validate_pin_input(value): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DHT sensor.""" - SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit + _LOGGER.warning( + "The DHT Sensor integration is deprecated and will be removed " + "in Home Assistant Core 2022.4; this integration is removed under " + "Architectural Decision Record 0019, more information can be found here: " + "https://github.com/home-assistant/architecture/blob/master/adr/0019-GPIO.md" + ) + available_sensors = { "AM2302": adafruit_dht.DHT22, "DHT11": adafruit_dht.DHT11, @@ -84,22 +110,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return False data = DHTClient(sensor, pin, name) - dev = [] - - with suppress(KeyError): - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - DHTSensor( - data, - variable, - SENSOR_TYPES[variable][1], - name, - temperature_offset, - humidity_offset, - ) - ) - add_entities(dev, True) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + DHTSensor(data, name, temperature_offset, humidity_offset, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + + add_entities(entities, True) class DHTSensor(SensorEntity): @@ -108,37 +127,18 @@ class DHTSensor(SensorEntity): def __init__( self, dht_client, - sensor_type, - temp_unit, name, temperature_offset, humidity_offset, + description: SensorEntityDescription, ): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.dht_client = dht_client - self.temp_unit = temp_unit - self.type = sensor_type self.temperature_offset = temperature_offset self.humidity_offset = humidity_offset - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{name} {description.name}" def update(self): """Get the latest data from the DHT and updates the states.""" @@ -147,7 +147,8 @@ def update(self): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMPERATURE and sensor_type in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug( "Temperature %.1f \u00b0C + offset %.1f", @@ -155,14 +156,12 @@ def update(self): temperature_offset, ) if -20 <= temperature < 80: - self._state = round(temperature + temperature_offset, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: + self._attr_native_value = round(temperature + temperature_offset, 1) + elif sensor_type == SENSOR_HUMIDITY and sensor_type in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) if 0 <= humidity <= 100: - self._state = round(humidity + humidity_offset, 1) + self._attr_native_value = round(humidity + humidity_offset, 1) class DHTClient: diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index 6003f17c9e9a7..9473fd537addf 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -106,8 +106,7 @@ async def async_handle_message(hass, message): "Dialogflow V1 API will be removed on October 23, 2019. Please change your DialogFlow settings to use the V2 api" ) req = message.get("result") - action_incomplete = req.get("actionIncomplete", True) - if action_incomplete: + if req.get("actionIncomplete", True): return elif _api_version is V2: diff --git a/homeassistant/components/dialogflow/translations/bg.json b/homeassistant/components/dialogflow/translations/bg.json index cc8faa1f0fd8e..d27bddfcd05db 100644 --- a/homeassistant/components/dialogflow/translations/bg.json +++ b/homeassistant/components/dialogflow/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, "create_entry": { "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 [\u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 Dialogflow]({dialogflow_url}). \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST\n - Content Type: application/json\n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." }, diff --git a/homeassistant/components/dialogflow/translations/he.json b/homeassistant/components/dialogflow/translations/he.json new file mode 100644 index 0000000000000..ebee9aee97649 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "webhook_not_internet_accessible": "\u05de\u05d5\u05e4\u05e2 \u05d4-Home Assistant \u05e9\u05dc\u05da \u05e6\u05e8\u05d9\u05da \u05dc\u05d4\u05d9\u05d5\u05ea \u05e0\u05d2\u05d9\u05e9 \u05de\u05d4\u05d0\u05d9\u05e0\u05d8\u05e8\u05e0\u05d8 \u05db\u05d3\u05d9 \u05dc\u05e7\u05d1\u05dc \u05d4\u05d5\u05d3\u05e2\u05d5\u05ea webhook." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/translations/hu.json b/homeassistant/components/dialogflow/translations/hu.json index 17f38b0262f6e..23a6001d77c1c 100644 --- a/homeassistant/components/dialogflow/translations/hu.json +++ b/homeassistant/components/dialogflow/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Dialogflow webhook integr\u00e1ci\u00f3j\u00e1t]({dialogflow_url}). \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Dialogflowt?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Dialogflowt?", "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/dialogflow/translations/it.json b/homeassistant/components/dialogflow/translations/it.json index 8df625d2cc373..b7b04c7886390 100644 --- a/homeassistant/components/dialogflow/translations/it.json +++ b/homeassistant/components/dialogflow/translations/it.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "L'istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi webhook." }, "create_entry": { - "default": "Per inviare eventi a Home Assistant, dovrai configurare [l'integrazione webhook di Dialogflow]({dialogflow_url})\n\n Compila le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli." + "default": "Per inviare eventi a Home Assistant, dovrai configurare [l'integrazione webhook di Dialogflow]({dialogflow_url})\n\n Compila le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Metodo: POST \n - Tipo di contenuto: application/json \n\n Vedi [la documentazione]({docs_url}) per ulteriori dettagli." }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/translations/ja.json b/homeassistant/components/dialogflow/translations/ja.json new file mode 100644 index 0000000000000..0cb6f57eae2f3 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001[Dialogflow\u306ewebhook\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3]({dialogflow_url})\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "Dialogflow\u3092\u8a2d\u5b9a\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "Dialogflow Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/translations/tr.json b/homeassistant/components/dialogflow/translations/tr.json index 84adcdf8225c4..520424e434fe2 100644 --- a/homeassistant/components/dialogflow/translations/tr.json +++ b/homeassistant/components/dialogflow/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in [Dialogflow'un webhook entegrasyonunu]( {dialogflow_url} ) ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST\n - \u0130\u00e7erik T\u00fcr\u00fc: uygulama/json \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Dialogflow'u kurmak istedi\u011finizden emin misiniz?", + "title": "Dialogflow Webhook'u ayarlay\u0131n" + } } } } \ No newline at end of file diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 9a9f82c36d2d5..f2222a03d73fd 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -4,8 +4,8 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOVING, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import ATTR_ATTRIBUTION @@ -36,16 +36,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Digital Ocean droplet sensor.""" - digital = hass.data.get(DATA_DIGITAL_OCEAN) - if not digital: + if not (digital := hass.data.get(DATA_DIGITAL_OCEAN)): return False droplets = config[CONF_DROPLETS] dev = [] for droplet in droplets: - droplet_id = digital.get_droplet_id(droplet) - if droplet_id is None: + if (droplet_id := digital.get_droplet_id(droplet)) is None: _LOGGER.error("Droplet %s is not available", droplet) return False dev.append(DigitalOceanBinarySensor(digital, droplet_id)) @@ -56,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DigitalOceanBinarySensor(BinarySensorEntity): """Representation of a Digital Ocean droplet sensor.""" - def __init__(self, do, droplet_id): + def __init__(self, do, droplet_id): # pylint: disable=invalid-name """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id @@ -76,7 +74,7 @@ def is_on(self): @property def device_class(self): """Return the class of this sensor.""" - return DEVICE_CLASS_MOVING + return BinarySensorDeviceClass.MOVING @property def extra_state_attributes(self): diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 0678b9ab1a157..3ba60c4c45739 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -33,16 +33,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Digital Ocean droplet switch.""" - digital = hass.data.get(DATA_DIGITAL_OCEAN) - if not digital: + if not (digital := hass.data.get(DATA_DIGITAL_OCEAN)): return False droplets = config[CONF_DROPLETS] dev = [] for droplet in droplets: - droplet_id = digital.get_droplet_id(droplet) - if droplet_id is None: + if (droplet_id := digital.get_droplet_id(droplet)) is None: _LOGGER.error("Droplet %s is not available", droplet) return False dev.append(DigitalOceanSwitch(digital, droplet_id)) @@ -53,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DigitalOceanSwitch(SwitchEntity): """Representation of a Digital Ocean droplet switch.""" - def __init__(self, do, droplet_id): + def __init__(self, do, droplet_id): # pylint: disable=invalid-name """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index b79a55394d558..1068ec4ccc460 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -6,25 +6,17 @@ from directv import DIRECTV, DIRECTVError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, - ATTR_VIA_DEVICE, - DOMAIN, -) +from .const import DOMAIN -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = ["media_player", "remote"] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] SCAN_INTERVAL = timedelta(seconds=30) @@ -52,32 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class DIRECTVEntity(Entity): - """Defines a base DirecTV entity.""" - - def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: - """Initialize the DirecTV entity.""" - self._address = address - self._device_id = address if address != "0" else dtv.device.info.receiver_id - self._is_client = address != "0" - self._name = name - self.dtv = dtv - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this DirecTV receiver.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)}, - ATTR_NAME: self.name, - ATTR_MANUFACTURER: self.dtv.device.info.brand, - ATTR_MODEL: None, - ATTR_SOFTWARE_VERSION: self.dtv.device.info.version, - ATTR_VIA_DEVICE: (DOMAIN, self.dtv.device.info.receiver_id), - } diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 325dbb195e9e6..34a09a04811da 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -8,13 +8,12 @@ from directv import DIRECTV, DIRECTVError import voluptuous as vol -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_RECEIVER_ID, DOMAIN @@ -45,7 +44,9 @@ def __init__(self): """Set up the instance.""" self.discovery_info = {} - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if user_input is None: return self._show_setup_form() @@ -65,13 +66,15 @@ async def async_step_user(self, user_input: ConfigType | None = None) -> FlowRes return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle SSDP discovery.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location).hostname receiver_id = None - if discovery_info.get(ATTR_UPNP_SERIAL): - receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID- + if discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): + receiver_id = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL][ + 4: + ] # strips off RID- self.context.update({"title_placeholders": {"name": host}}) @@ -97,7 +100,7 @@ async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult return await self.async_step_ssdp_confirm() async def async_step_ssdp_confirm( - self, user_input: ConfigType = None + self, user_input: dict[str, Any] = None ) -> FlowResult: """Handle a confirmation flow initiated by SSDP.""" if user_input is None: diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py index 9ad01a0179b57..e90fd6879c775 100644 --- a/homeassistant/components/directv/const.py +++ b/homeassistant/components/directv/const.py @@ -1,17 +1,11 @@ """Constants for the DirecTV integration.""" - DOMAIN = "directv" # Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_RATING = "media_rating" ATTR_MEDIA_RECORDED = "media_recorded" ATTR_MEDIA_START_TIME = "media_start_time" -ATTR_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" -ATTR_VIA_DEVICE = "via_device" CONF_RECEIVER_ID = "receiver_id" diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py new file mode 100644 index 0000000000000..08b24a50a755d --- /dev/null +++ b/homeassistant/components/directv/entity.py @@ -0,0 +1,30 @@ +"""Base DirecTV Entity.""" +from __future__ import annotations + +from directv import DIRECTV + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class DIRECTVEntity(Entity): + """Defines a base DirecTV entity.""" + + def __init__(self, *, dtv: DIRECTV, address: str = "0") -> None: + """Initialize the DirecTV entity.""" + self._address = address + self._device_id = address if address != "0" else dtv.device.info.receiver_id + self._is_client = address != "0" + self.dtv = dtv + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this DirecTV receiver.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=self.dtv.device.info.brand, + name=self.name, + sw_version=self.dtv.device.info.version, + via_device=(DOMAIN, self.dtv.device.info.receiver_id), + ) diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 6d69ba2fd5aae..3fba13121f1b6 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -3,7 +3,7 @@ "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", "requirements": ["directv==0.4.0"], - "codeowners": ["@ctalkington"], + "codeowners": [], "quality_scale": "gold", "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 5d7f7d1185bb9..b965eb4e7ea67 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -6,7 +6,7 @@ from directv import DIRECTV from homeassistant.components.media_player import ( - DEVICE_CLASS_RECEIVER, + MediaPlayerDeviceClass, MediaPlayerEntity, ) from homeassistant.components.media_player.const import ( @@ -29,7 +29,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import DIRECTVEntity from .const import ( ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, @@ -37,6 +36,7 @@ ATTR_MEDIA_START_TIME, DOMAIN, ) +from .entity import DIRECTVEntity _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Set up the DirecTV config entry.""" dtv = hass.data[DOMAIN][entry.entry_id] entities = [] @@ -91,12 +91,14 @@ def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: """Initialize DirecTV media player.""" super().__init__( dtv=dtv, - name=name, address=address, ) - self._assumed_state = None - self._available = False + self._attr_unique_id = self._device_id + self._attr_name = name + self._attr_device_class = MediaPlayerDeviceClass.RECEIVER + self._attr_available = False + self._is_recorded = None self._is_standby = True self._last_position = None @@ -108,12 +110,12 @@ def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: async def async_update(self): """Retrieve latest state.""" self._state = await self.dtv.state(self._address) - self._available = self._state.available + self._attr_available = self._state.available self._is_standby = self._state.standby self._program = self._state.program if self._is_standby: - self._assumed_state = False + self._attr_assumed_state = False self._is_recorded = None self._last_position = None self._last_update = None @@ -123,7 +125,7 @@ async def async_update(self): self._is_recorded = self._program.recorded self._last_position = self._program.position self._last_update = self._state.at - self._assumed_state = self._is_recorded + self._attr_assumed_state = self._is_recorded @property def extra_state_attributes(self): @@ -137,24 +139,6 @@ def extra_state_attributes(self): ATTR_MEDIA_START_TIME: self.media_start_time, } - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return the class of this device.""" - return DEVICE_CLASS_RECEIVER - - @property - def unique_id(self): - """Return a unique ID to use for this media player.""" - if self._address == "0": - return self.dtv.device.info.receiver_id - - return self._address - # MediaPlayerEntity properties and methods @property def state(self): @@ -170,16 +154,6 @@ def state(self): return STATE_PLAYING - @property - def available(self): - """Return if able to retrieve information from DVR or not.""" - return self._available - - @property - def assumed_state(self): - """Return if we assume the state or not.""" - return self._assumed_state - @property def media_content_id(self): """Return the content ID of current playing media.""" @@ -316,7 +290,7 @@ async def async_turn_on(self): if self._is_client: raise NotImplementedError() - _LOGGER.debug("Turn on %s", self._name) + _LOGGER.debug("Turn on %s", self.name) await self.dtv.remote("poweron", self._address) async def async_turn_off(self): @@ -324,32 +298,32 @@ async def async_turn_off(self): if self._is_client: raise NotImplementedError() - _LOGGER.debug("Turn off %s", self._name) + _LOGGER.debug("Turn off %s", self.name) await self.dtv.remote("poweroff", self._address) async def async_media_play(self): """Send play command.""" - _LOGGER.debug("Play on %s", self._name) + _LOGGER.debug("Play on %s", self.name) await self.dtv.remote("play", self._address) async def async_media_pause(self): """Send pause command.""" - _LOGGER.debug("Pause on %s", self._name) + _LOGGER.debug("Pause on %s", self.name) await self.dtv.remote("pause", self._address) async def async_media_stop(self): """Send stop command.""" - _LOGGER.debug("Stop on %s", self._name) + _LOGGER.debug("Stop on %s", self.name) await self.dtv.remote("stop", self._address) async def async_media_previous_track(self): """Send rewind command.""" - _LOGGER.debug("Rewind on %s", self._name) + _LOGGER.debug("Rewind on %s", self.name) await self.dtv.remote("rew", self._address) async def async_media_next_track(self): """Send fast forward command.""" - _LOGGER.debug("Fast forward on %s", self._name) + _LOGGER.debug("Fast forward on %s", self.name) await self.dtv.remote("ffwd", self._address) async def async_play_media(self, media_type, media_id, **kwargs): @@ -362,5 +336,5 @@ async def async_play_media(self, media_type, media_id, **kwargs): ) return - _LOGGER.debug("Changing channel on %s to %s", self._name, media_id) + _LOGGER.debug("Changing channel on %s to %s", self.name, media_id) await self.dtv.tune(media_id, self._address) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 424b5ba4ec6c1..c8c84a7f0cc3d 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DIRECTVEntity from .const import DOMAIN +from .entity import DIRECTVEntity _LOGGER = logging.getLogger(__name__) @@ -25,7 +25,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Load DirecTV remote based on a config entry.""" dtv = hass.data[DOMAIN][entry.entry_id] entities = [] @@ -49,41 +49,24 @@ def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: """Initialize DirecTV remote.""" super().__init__( dtv=dtv, - name=name, address=address, ) - self._available = False - self._is_on = True - - @property - def available(self): - """Return if able to retrieve information from device or not.""" - return self._available - - @property - def unique_id(self): - """Return a unique ID.""" - if self._address == "0": - return self.dtv.device.info.receiver_id - - return self._address - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on + self._attr_unique_id = self._device_id + self._attr_name = name + self._attr_available = False + self._attr_is_on = True async def async_update(self) -> None: """Update device state.""" status = await self.dtv.status(self._address) if status in ("active", "standby"): - self._available = True - self._is_on = status == "active" + self._attr_available = True + self._attr_is_on = status == "active" else: - self._available = False - self._is_on = False + self._attr_available = False + self._attr_is_on = False async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index e6c54d0d4aa1d..4384867dfa43c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -3,7 +3,6 @@ "flow_title": "{name}", "step": { "ssdp_confirm": { - "data": {}, "description": "Do you want to set up {name}?" }, "user": { diff --git a/homeassistant/components/directv/translations/bg.json b/homeassistant/components/directv/translations/bg.json new file mode 100644 index 0000000000000..ffb6977606037 --- /dev/null +++ b/homeassistant/components/directv/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/ca.json b/homeassistant/components/directv/translations/ca.json index 57db4ee003007..db5e9386d05a2 100644 --- a/homeassistant/components/directv/translations/ca.json +++ b/homeassistant/components/directv/translations/ca.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Vols configurar {name}?" diff --git a/homeassistant/components/directv/translations/de.json b/homeassistant/components/directv/translations/de.json index 95bd807c048a2..5fb4d2e7b5b92 100644 --- a/homeassistant/components/directv/translations/de.json +++ b/homeassistant/components/directv/translations/de.json @@ -1,20 +1,16 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { - "data": { - "one": "eins", - "other": "andere" - }, - "description": "M\u00f6chten Sie {name} einrichten?" + "description": "M\u00f6chtest du {name} einrichten?" }, "user": { "data": { diff --git a/homeassistant/components/directv/translations/en.json b/homeassistant/components/directv/translations/en.json index 118c693c89152..9e921d9811294 100644 --- a/homeassistant/components/directv/translations/en.json +++ b/homeassistant/components/directv/translations/en.json @@ -10,7 +10,6 @@ "flow_title": "{name}", "step": { "ssdp_confirm": { - "data": {}, "description": "Do you want to set up {name}?" }, "user": { diff --git a/homeassistant/components/directv/translations/et.json b/homeassistant/components/directv/translations/et.json index 67c0f4c1046eb..45d41149438c3 100644 --- a/homeassistant/components/directv/translations/et.json +++ b/homeassistant/components/directv/translations/et.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Kas seadistada {name}?" diff --git a/homeassistant/components/directv/translations/fr.json b/homeassistant/components/directv/translations/fr.json index 4876c455ff080..9f227f2004edd 100644 --- a/homeassistant/components/directv/translations/fr.json +++ b/homeassistant/components/directv/translations/fr.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00c9chec de connexion" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "data": { @@ -18,7 +18,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" } } } diff --git a/homeassistant/components/directv/translations/he.json b/homeassistant/components/directv/translations/he.json new file mode 100644 index 0000000000000..f057c4e4629bb --- /dev/null +++ b/homeassistant/components/directv/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/hu.json b/homeassistant/components/directv/translations/hu.json index 0309eb358814c..9e3aa3efb1361 100644 --- a/homeassistant/components/directv/translations/hu.json +++ b/homeassistant/components/directv/translations/hu.json @@ -7,10 +7,18 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, + "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + }, + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" + }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/directv/translations/id.json b/homeassistant/components/directv/translations/id.json index 74f778d6cee28..fcf7318d906a1 100644 --- a/homeassistant/components/directv/translations/id.json +++ b/homeassistant/components/directv/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Ingin menyiapkan {name}?" diff --git a/homeassistant/components/directv/translations/it.json b/homeassistant/components/directv/translations/it.json index 2b6e25d63ef4a..9fb0932c342db 100644 --- a/homeassistant/components/directv/translations/it.json +++ b/homeassistant/components/directv/translations/it.json @@ -7,9 +7,13 @@ "error": { "cannot_connect": "Impossibile connettersi" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { + "data": { + "one": "Pi\u00f9", + "other": "Altri" + }, "description": "Vuoi impostare {name} ?" }, "user": { diff --git a/homeassistant/components/directv/translations/ja.json b/homeassistant/components/directv/translations/ja.json new file mode 100644 index 0000000000000..413861bce12f1 --- /dev/null +++ b/homeassistant/components/directv/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "ssdp_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/nl.json b/homeassistant/components/directv/translations/nl.json index 957095712342a..3c0ca048e8bd4 100644 --- a/homeassistant/components/directv/translations/nl.json +++ b/homeassistant/components/directv/translations/nl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Kan geen verbinding maken" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "data": { diff --git a/homeassistant/components/directv/translations/no.json b/homeassistant/components/directv/translations/no.json index e93d3dadf49fa..a8c9492d6ba6b 100644 --- a/homeassistant/components/directv/translations/no.json +++ b/homeassistant/components/directv/translations/no.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Vil du sette opp {name} ?" diff --git a/homeassistant/components/directv/translations/pl.json b/homeassistant/components/directv/translations/pl.json index db0dc7ea0a42d..b7ee7c9251dca 100644 --- a/homeassistant/components/directv/translations/pl.json +++ b/homeassistant/components/directv/translations/pl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "data": { diff --git a/homeassistant/components/directv/translations/ru.json b/homeassistant/components/directv/translations/ru.json index a3a340e5ce59a..50995f26590ca 100644 --- a/homeassistant/components/directv/translations/ru.json +++ b/homeassistant/components/directv/translations/ru.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" diff --git a/homeassistant/components/directv/translations/tr.json b/homeassistant/components/directv/translations/tr.json index daca8f1ef6246..27a9dc80cb58c 100644 --- a/homeassistant/components/directv/translations/tr.json +++ b/homeassistant/components/directv/translations/tr.json @@ -7,13 +7,18 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "ssdp_confirm": { + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, "description": "{name} kurmak istiyor musunuz?" }, "user": { "data": { - "host": "Ana Bilgisayar" + "host": "Sunucu" } } } diff --git a/homeassistant/components/directv/translations/zh-Hant.json b/homeassistant/components/directv/translations/zh-Hant.json index d38bbb90528c0..c9f3fda773e4d 100644 --- a/homeassistant/components/directv/translations/zh-Hant.json +++ b/homeassistant/components/directv/translations/zh-Hant.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "DirecTV\uff1a{name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 81beec0e60ebd..a751f437cc182 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -1,4 +1,6 @@ """Show the amount of records in a user's Discogs collection.""" +from __future__ import annotations + from datetime import timedelta import logging import random @@ -6,7 +8,11 @@ import discogs_client import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, @@ -34,30 +40,33 @@ SENSOR_WANTLIST_TYPE = "wantlist" SENSOR_RANDOM_RECORD_TYPE = "random_record" -SENSORS = { - SENSOR_COLLECTION_TYPE: { - "name": "Collection", - "icon": ICON_RECORD, - "unit_of_measurement": UNIT_RECORDS, - }, - SENSOR_WANTLIST_TYPE: { - "name": "Wantlist", - "icon": ICON_RECORD, - "unit_of_measurement": UNIT_RECORDS, - }, - SENSOR_RANDOM_RECORD_TYPE: { - "name": "Random Record", - "icon": ICON_PLAYER, - "unit_of_measurement": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_COLLECTION_TYPE, + name="Collection", + icon=ICON_RECORD, + native_unit_of_measurement=UNIT_RECORDS, + ), + SensorEntityDescription( + key=SENSOR_WANTLIST_TYPE, + name="Wantlist", + icon=ICON_RECORD, + native_unit_of_measurement=UNIT_RECORDS, + ), + SensorEntityDescription( + key=SENSOR_RANDOM_RECORD_TYPE, + name="Random Record", + icon=ICON_PLAYER, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_TOKEN): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -81,51 +90,37 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("API token is not valid") return - sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(DiscogsSensor(discogs_data, name, sensor_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + DiscogsSensor(discogs_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(sensors, True) + add_entities(entities, True) class DiscogsSensor(SensorEntity): """Create a new Discogs sensor for a specific type.""" - def __init__(self, discogs_data, name, sensor_type): + def __init__(self, discogs_data, name, description: SensorEntityDescription): """Initialize the Discogs sensor.""" + self.entity_description = description self._discogs_data = discogs_data - self._name = name - self._type = sensor_type - self._state = None - self._attrs = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSORS[self._type]['name']}" + self._attrs: dict = {} - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return SENSORS[self._type]["icon"] - - @property - def unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return SENSORS[self._type]["unit_of_measurement"] + self._attr_name = f"{name} {description.name}" @property def extra_state_attributes(self): """Return the device state attributes of the sensor.""" - if self._state is None or self._attrs is None: + if self._attr_native_value is None or self._attrs is None: return None - if self._type == SENSOR_RANDOM_RECORD_TYPE and self._state is not None: + if ( + self.entity_description.key == SENSOR_RANDOM_RECORD_TYPE + and self._attr_native_value is not None + ): return { "cat_no": self._attrs["labels"][0]["catno"], "cover_image": self._attrs["cover_image"], @@ -156,9 +151,9 @@ def get_random_record(self): def update(self): """Set state to the amount of records in user's collection.""" - if self._type == SENSOR_COLLECTION_TYPE: - self._state = self._discogs_data["collection_count"] - elif self._type == SENSOR_WANTLIST_TYPE: - self._state = self._discogs_data["wantlist_count"] + if self.entity_description.key == SENSOR_COLLECTION_TYPE: + self._attr_native_value = self._discogs_data["collection_count"] + elif self.entity_description.key == SENSOR_WANTLIST_TYPE: + self._attr_native_value = self._discogs_data["wantlist_count"] else: - self._state = self.get_random_record() + self._attr_native_value = self.get_random_record() diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index c475c502f602b..0da186e792451 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -2,7 +2,7 @@ "domain": "discord", "name": "Discord", "documentation": "https://www.home-assistant.io/integrations/discord", - "requirements": ["discord.py==1.7.2"], + "requirements": ["discord.py==1.7.3"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index dfc89a4cb7e23..c3f7de94af026 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -59,9 +59,21 @@ async def async_send_message(self, message, **kwargs): data = kwargs.get(ATTR_DATA) or {} + embed = None if ATTR_EMBED in data: embedding = data[ATTR_EMBED] - fields = embedding.get(ATTR_EMBED_FIELDS) + fields = embedding.get(ATTR_EMBED_FIELDS) or [] + + if embedding: + embed = discord.Embed(**embedding) + for field in fields: + embed.add_field(**field) + if ATTR_EMBED_FOOTER in embedding: + embed.set_footer(**embedding[ATTR_EMBED_FOOTER]) + if ATTR_EMBED_AUTHOR in embedding: + embed.set_author(**embedding[ATTR_EMBED_AUTHOR]) + if ATTR_EMBED_THUMBNAIL in embedding: + embed.set_thumbnail(**embedding[ATTR_EMBED_THUMBNAIL]) if ATTR_IMAGES in data: images = [] @@ -76,44 +88,21 @@ async def async_send_message(self, message, **kwargs): else: _LOGGER.warning("Image not found: %s", image) - # pylint: disable=unused-variable - @discord_bot.event - async def on_ready(): - """Send the messages when the bot is ready.""" - try: - for channelid in kwargs[ATTR_TARGET]: - channelid = int(channelid) - channel = discord_bot.get_channel( + await discord_bot.login(self.token) + + try: + for channelid in kwargs[ATTR_TARGET]: + channelid = int(channelid) + try: + channel = await discord_bot.fetch_channel( channelid - ) or discord_bot.get_user(channelid) - - if channel is None: - _LOGGER.warning("Channel not found for ID: %s", channelid) - continue - # Must create new instances of File for each channel. - files = None - if images: - files = [] - for image in images: - files.append(discord.File(image)) - if embedding: - embed = discord.Embed(**embedding) - if fields: - for field_num, field_name in enumerate(fields): - embed.add_field(**fields[field_num]) - if ATTR_EMBED_FOOTER in embedding: - embed.set_footer(**embedding[ATTR_EMBED_FOOTER]) - if ATTR_EMBED_AUTHOR in embedding: - embed.set_author(**embedding[ATTR_EMBED_AUTHOR]) - if ATTR_EMBED_THUMBNAIL in embedding: - embed.set_thumbnail(**embedding[ATTR_EMBED_THUMBNAIL]) - await channel.send(message, files=files, embed=embed) - else: - await channel.send(message, files=files) - except (discord.errors.HTTPException, discord.errors.NotFound) as error: - _LOGGER.warning("Communication error: %s", error) - await discord_bot.logout() - await discord_bot.close() - - # Using reconnect=False prevents multiple ready events to be fired. - await discord_bot.start(self.token, reconnect=False) + ) or await discord_bot.fetch_user(channelid) + except discord.NotFound: + _LOGGER.warning("Channel not found for ID: %s", channelid) + continue + # Must create new instances of File for each channel. + files = [discord.File(image) for image in images] if images else None + await channel.send(message, files=files, embed=embed) + except (discord.HTTPException, discord.NotFound) as error: + _LOGGER.warning("Communication error: %s", error) + await discord_bot.close() diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 883958226d8d0..595771cd67394 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -1,7 +1,10 @@ """Starts a service to scan in intervals for new devices.""" +from __future__ import annotations + from datetime import timedelta import json import logging +from typing import NamedTuple from netdisco.discovery import NetworkDiscovery import voluptuous as vol @@ -13,6 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_discover, async_load_platform from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.loader import async_get_zeroconf import homeassistant.util.dt as dt_util DOMAIN = "discovery" @@ -34,7 +38,6 @@ SERVICE_TELLDUSLIVE = "tellstick" SERVICE_YEELIGHT = "yeelight" SERVICE_WEMO = "belkin_wemo" -SERVICE_WINK = "wink" SERVICE_XIAOMI_GW = "xiaomi_gw" # These have custom protocols @@ -43,21 +46,27 @@ "logitech_mediaserver": "squeezebox", } + +class ServiceDetails(NamedTuple): + """Store service details.""" + + component: str + platform: str | None + + # These have no config flows SERVICE_HANDLERS = { - SERVICE_NETGEAR: ("device_tracker", None), - SERVICE_ENIGMA2: ("media_player", "enigma2"), - SERVICE_SABNZBD: ("sabnzbd", None), - "yamaha": ("media_player", "yamaha"), - "frontier_silicon": ("media_player", "frontier_silicon"), - "openhome": ("media_player", "openhome"), - "bose_soundtouch": ("media_player", "soundtouch"), - "bluesound": ("media_player", "bluesound"), - "lg_smart_device": ("media_player", "lg_soundbar"), - "nanoleaf_aurora": ("light", "nanoleaf"), + SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), + SERVICE_SABNZBD: ServiceDetails("sabnzbd", None), + "yamaha": ServiceDetails("media_player", "yamaha"), + "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), + "openhome": ServiceDetails("media_player", "openhome"), + "bose_soundtouch": ServiceDetails("media_player", "soundtouch"), + "bluesound": ServiceDetails("media_player", "bluesound"), + "lg_smart_device": ServiceDetails("media_player", "lg_soundbar"), } -OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} +OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} MIGRATED_SERVICE_HANDLERS = [ SERVICE_APPLE_TV, @@ -65,6 +74,7 @@ "deconz", SERVICE_DAIKIN, "denonavr", + SERVICE_DLNA_DMR, "esphome", "google_cast", SERVICE_HASS_IOS_APP, @@ -76,16 +86,17 @@ "kodi", SERVICE_KONNECTED, SERVICE_MOBILE_APP, + SERVICE_NETGEAR, SERVICE_OCTOPRINT, "philips_hue", SERVICE_SAMSUNG_PRINTER, "sonos", "songpal", SERVICE_WEMO, - SERVICE_WINK, SERVICE_XIAOMI_GW, "volumio", SERVICE_YEELIGHT, + "nanoleaf_aurora", ] DEFAULT_ENABLED = ( @@ -139,6 +150,10 @@ async def async_setup(hass, config): ) zeroconf_instance = await zeroconf.async_get_instance(hass) + # Do not scan for types that have already been converted + # as it will generate excess network traffic for questions + # the zeroconf instance already knows the answers + zeroconf_types = list(await async_get_zeroconf(hass)) async def new_service_found(service, info): """Handle a new service if one is found.""" @@ -164,30 +179,30 @@ async def new_service_found(service, info): ) return - comp_plat = SERVICE_HANDLERS.get(service) + service_details = SERVICE_HANDLERS.get(service) - if not comp_plat and service in enabled_platforms: - comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + if not service_details and service in enabled_platforms: + service_details = OPTIONAL_SERVICE_HANDLERS[service] # We do not know how to handle this service. - if not comp_plat: + if not service_details: logger.debug("Unknown service discovered: %s %s", service, info) return logger.info("Found new service: %s %s", service, info) - component, platform = comp_plat - - if platform is None: - await async_discover(hass, service, info, component, config) + if service_details.platform is None: + await async_discover(hass, service, info, service_details.component, config) else: - await async_load_platform(hass, component, platform, info, config) + await async_load_platform( + hass, service_details.component, service_details.platform, info, config + ) async def scan_devices(now): """Scan for devices.""" try: results = await hass.async_add_executor_job( - _discover, netdisco, zeroconf_instance + _discover, netdisco, zeroconf_instance, zeroconf_types ) for result in results: @@ -209,11 +224,13 @@ def schedule_first(event): return True -def _discover(netdisco, zeroconf_instance): +def _discover(netdisco, zeroconf_instance, zeroconf_types): """Discover devices.""" results = [] try: - netdisco.scan(zeroconf_instance=zeroconf_instance) + netdisco.scan( + zeroconf_instance=zeroconf_instance, suppress_mdns_types=zeroconf_types + ) for disc in netdisco.discover(): for service in netdisco.get_info(disc): diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json index a2d2df1730a6d..1b7d51c1716e5 100644 --- a/homeassistant/components/discovery/manifest.json +++ b/homeassistant/components/discovery/manifest.json @@ -2,7 +2,7 @@ "domain": "discovery", "name": "Discovery", "documentation": "https://www.home-assistant.io/integrations/discovery", - "requirements": ["netdisco==2.8.3"], + "requirements": ["netdisco==3.0.0"], "after_dependencies": ["zeroconf"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py index f38456ec6ee3f..d34d855035568 100644 --- a/homeassistant/components/dlna_dmr/__init__.py +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -1 +1,30 @@ """The dlna_dmr component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import LOGGER + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up a DLNA DMR device from a config entry.""" + LOGGER.debug("Setting up config entry: %s", entry.unique_id) + + # Forward setup to the appropriate platform + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + # Forward to the same platform as async_setup_entry did + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py new file mode 100644 index 0000000000000..f8736d849ed7b --- /dev/null +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -0,0 +1,401 @@ +"""Config flow for DLNA DMR.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from pprint import pformat +from typing import Any, Mapping, Optional, cast +from urllib.parse import urlparse + +from async_upnp_client.client import UpnpError +from async_upnp_client.profiles.dlna import DmrDevice +from async_upnp_client.profiles.profile import find_device_of_type +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_TYPE, CONF_URL +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import IntegrationError +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DEFAULT_NAME, + DOMAIN, +) +from .data import get_domain_data + +LOGGER = logging.getLogger(__name__) + +FlowInput = Optional[Mapping[str, Any]] + + +class ConnectError(IntegrationError): + """Error occurred when trying to connect to a device.""" + + +class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a DLNA DMR config flow. + + The Unique Device Name (UDN) of the DMR device is used as the unique_id for + config entries and for entities. This UDN may differ from the root UDN if + the DMR is an embedded device. + """ + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._location: str | None = None + self._udn: str | None = None + self._device_type: str | None = None + self._name: str | None = None + self._options: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Define the config flow to handle options.""" + return DlnaDmrOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: FlowInput = None) -> FlowResult: + """Handle a flow initialized by the user. + + Let user choose from a list of found and unconfigured devices or to + enter an URL manually. + """ + LOGGER.debug("async_step_user: user_input: %s", user_input) + + if user_input is not None: + if not (host := user_input.get(CONF_HOST)): + # No device chosen, user might want to directly enter an URL + return await self.async_step_manual() + # User has chosen a device, ask for confirmation + discovery = self._discoveries[host] + await self._async_set_info_from_discovery(discovery) + return self._create_entry() + + if not (discoveries := await self._async_get_discoveries()): + # Nothing found, maybe the user knows an URL to try + return await self.async_step_manual() + + self._discoveries = { + discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + or cast(str, urlparse(discovery.ssdp_location).hostname): discovery + for discovery in discoveries + } + + data_schema = vol.Schema( + {vol.Optional(CONF_HOST): vol.In(self._discoveries.keys())} + ) + return self.async_show_form(step_id="user", data_schema=data_schema) + + async def async_step_manual(self, user_input: FlowInput = None) -> FlowResult: + """Manual URL entry by the user.""" + LOGGER.debug("async_step_manual: user_input: %s", user_input) + + # Device setup manually, assume we don't get SSDP broadcast notifications + self._options[CONF_POLL_AVAILABILITY] = True + + errors = {} + if user_input is not None: + self._location = user_input[CONF_URL] + try: + await self._async_connect() + except ConnectError as err: + errors["base"] = err.args[0] + else: + return self._create_entry() + + data_schema = vol.Schema({CONF_URL: str}) + return self.async_show_form( + step_id="manual", data_schema=data_schema, errors=errors + ) + + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Handle a flow initialized by SSDP discovery.""" + LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) + + await self._async_set_info_from_discovery(discovery_info) + + if _is_ignored_device(discovery_info): + return self.async_abort(reason="alternative_integration") + + # Abort if the device doesn't support all services required for a DmrDevice. + # Use the discovery_info instead of DmrDevice.is_profile_device to avoid + # contacting the device again. + discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + if not discovery_service_list: + return self.async_abort(reason="not_dmr") + discovery_service_ids = { + service.get("serviceId") + for service in discovery_service_list.get("service") or [] + } + if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids): + return self.async_abort(reason="not_dmr") + + # Abort if another config entry has the same location, in case the + # device doesn't have a static and unique UDN (breaking the UPnP spec). + self._async_abort_entries_match({CONF_URL: self._location}) + + self.context["title_placeholders"] = {"name": self._name} + + return await self.async_step_confirm() + + async def async_step_unignore(self, user_input: Mapping[str, Any]) -> FlowResult: + """Rediscover previously ignored devices by their unique_id.""" + LOGGER.debug("async_step_unignore: user_input: %s", user_input) + self._udn = user_input["unique_id"] + assert self._udn + await self.async_set_unique_id(self._udn) + + # Find a discovery matching the unignored unique_id for a DMR device + for dev_type in DmrDevice.DEVICE_TYPES: + discovery = await ssdp.async_get_discovery_info_by_udn_st( + self.hass, self._udn, dev_type + ) + if discovery: + break + else: + return self.async_abort(reason="discovery_error") + + await self._async_set_info_from_discovery(discovery, abort_if_configured=False) + + self.context["title_placeholders"] = {"name": self._name} + + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input: FlowInput = None) -> FlowResult: + """Allow the user to confirm adding the device.""" + LOGGER.debug("async_step_confirm: %s", user_input) + + if user_input is not None: + return self._create_entry() + + self._set_confirm_only() + return self.async_show_form(step_id="confirm") + + async def _async_connect(self) -> None: + """Connect to a device to confirm it works and gather extra information. + + Updates this flow's unique ID to the device UDN if not already done. + Raises ConnectError if something goes wrong. + """ + LOGGER.debug("_async_connect: location: %s", self._location) + assert self._location, "self._location has not been set before connect" + + domain_data = get_domain_data(self.hass) + try: + device = await domain_data.upnp_factory.async_create_device(self._location) + except UpnpError as err: + raise ConnectError("cannot_connect") from err + + if not DmrDevice.is_profile_device(device): + raise ConnectError("not_dmr") + + device = find_device_of_type(device, DmrDevice.DEVICE_TYPES) + + if not self._udn: + self._udn = device.udn + await self.async_set_unique_id(self._udn) + + # Abort if already configured, but update the last-known location + self._abort_if_unique_id_configured( + updates={CONF_URL: self._location}, reload_on_update=False + ) + + if not self._device_type: + self._device_type = device.device_type + + if not self._name: + self._name = device.name + + def _create_entry(self) -> FlowResult: + """Create a config entry, assuming all required information is now known.""" + LOGGER.debug( + "_async_create_entry: location: %s, UDN: %s", self._location, self._udn + ) + assert self._location + assert self._udn + assert self._device_type + + title = self._name or urlparse(self._location).hostname or DEFAULT_NAME + data = { + CONF_URL: self._location, + CONF_DEVICE_ID: self._udn, + CONF_TYPE: self._device_type, + } + return self.async_create_entry(title=title, data=data, options=self._options) + + async def _async_set_info_from_discovery( + self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True + ) -> None: + """Set information required for a config entry from the SSDP discovery.""" + LOGGER.debug( + "_async_set_info_from_discovery: location: %s, UDN: %s", + discovery_info.ssdp_location, + discovery_info.ssdp_udn, + ) + + if not self._location: + self._location = discovery_info.ssdp_location + assert isinstance(self._location, str) + + self._udn = discovery_info.ssdp_udn + await self.async_set_unique_id(self._udn) + + if abort_if_configured: + # Abort if already configured, but update the last-known location + self._abort_if_unique_id_configured( + updates={CONF_URL: self._location}, reload_on_update=False + ) + + self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st + self._name = ( + discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + or urlparse(self._location).hostname + or DEFAULT_NAME + ) + + async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + """Get list of unconfigured DLNA devices discovered by SSDP.""" + LOGGER.debug("_get_discoveries") + + # Get all compatible devices from ssdp's cache + discoveries: list[ssdp.SsdpServiceInfo] = [] + for udn_st in DmrDevice.DEVICE_TYPES: + st_discoveries = await ssdp.async_get_discovery_info_by_st( + self.hass, udn_st + ) + discoveries.extend(st_discoveries) + + # Filter out devices already configured + current_unique_ids = { + entry.unique_id + for entry in self._async_current_entries(include_ignore=False) + } + discoveries = [ + disc for disc in discoveries if disc.ssdp_udn not in current_unique_ids + ] + + return discoveries + + +class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a DLNA DMR options flow. + + Configures the single instance and updates the existing config entry. + """ + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + # Don't modify existing (read-only) options -- copy and update instead + options = dict(self.config_entry.options) + + if user_input is not None: + LOGGER.debug("user_input: %s", user_input) + listen_port = user_input.get(CONF_LISTEN_PORT) or None + callback_url_override = user_input.get(CONF_CALLBACK_URL_OVERRIDE) or None + + try: + # Cannot use cv.url validation in the schema itself so apply + # extra validation here + if callback_url_override: + cv.url(callback_url_override) + except vol.Invalid: + errors["base"] = "invalid_url" + + options[CONF_LISTEN_PORT] = listen_port + options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override + options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY] + + # Save if there's no errors, else fall through and show the form again + if not errors: + return self.async_create_entry(title="", data=options) + + fields = {} + + def _add_with_suggestion(key: str, validator: Callable) -> None: + """Add a field to with a suggested, not default, value.""" + if (suggested_value := options.get(key)) is None: + fields[vol.Optional(key)] = validator + else: + fields[ + vol.Optional(key, description={"suggested_value": suggested_value}) + ] = validator + + # listen_port can be blank or 0 for "bind any free port" + _add_with_suggestion(CONF_LISTEN_PORT, cv.port) + _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str) + fields[ + vol.Required( + CONF_POLL_AVAILABILITY, + default=options.get(CONF_POLL_AVAILABILITY, False), + ) + ] = bool + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(fields), + errors=errors, + ) + + +def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: + """Return True if this device should be ignored for discovery. + + These devices are supported better by other integrations, so don't bug + the user about them. The user can add them if desired by via the user config + flow, which will list all discovered but unconfigured devices. + """ + # Did the discovery trigger more than just this flow? + if len(discovery_info.x_homeassistant_matching_domains) > 1: + LOGGER.debug( + "Ignoring device supported by multiple integrations: %s", + discovery_info.x_homeassistant_matching_domains, + ) + return True + + # Is the root device not a DMR? + if ( + discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE) + not in DmrDevice.DEVICE_TYPES + ): + return True + + # Special cases for devices with other discovery methods (e.g. mDNS), or + # that advertise multiple unrelated (sent in separate discovery packets) + # UPnP devices. + manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER, "").lower() + model = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").lower() + + if manufacturer.startswith("xbmc") or model == "kodi": + # kodi + return True + if "philips" in manufacturer and "tv" in model: + # philips_js + # These TVs don't have a stable UDN, so also get discovered as a new + # device every time they are turned on. + return True + if manufacturer.startswith("samsung") and "tv" in model: + # samsungtv + return True + if manufacturer.startswith("lg") and "tv" in model: + # webostv + return True + + return False diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py new file mode 100644 index 0000000000000..20a978f9fda2e --- /dev/null +++ b/homeassistant/components/dlna_dmr/const.py @@ -0,0 +1,173 @@ +"""Constants for the DLNA DMR component.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Final + +from async_upnp_client.profiles.dlna import PlayMode as _PlayMode + +from homeassistant.components.media_player import const as _mp_const + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "dlna_dmr" + +CONF_LISTEN_PORT: Final = "listen_port" +CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" +CONF_POLL_AVAILABILITY: Final = "poll_availability" + +DEFAULT_NAME: Final = "DLNA Digital Media Renderer" + +CONNECT_TIMEOUT: Final = 10 + +# Map UPnP class to media_player media_content_type +MEDIA_TYPE_MAP: Mapping[str, str] = { + "object": _mp_const.MEDIA_TYPE_URL, + "object.item": _mp_const.MEDIA_TYPE_URL, + "object.item.imageItem": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.imageItem.photo": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.audioItem": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.musicTrack": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBroadcast": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBook": _mp_const.MEDIA_TYPE_PODCAST, + "object.item.videoItem": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.videoItem.movie": _mp_const.MEDIA_TYPE_MOVIE, + "object.item.videoItem.videoBroadcast": _mp_const.MEDIA_TYPE_TVSHOW, + "object.item.videoItem.musicVideoClip": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.playlistItem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.item.textItem": _mp_const.MEDIA_TYPE_URL, + "object.item.bookmarkItem": _mp_const.MEDIA_TYPE_URL, + "object.item.epgItem": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.audioProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.videoProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.container": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.person": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.person.musicArtist": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.playlistContainer": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.album": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.musicAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.photoAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.genre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.musicGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.movieGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.channelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.audioChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.videoChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.epgContainer": _mp_const.MEDIA_TYPE_TVSHOW, + "object.container.storageSystem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageVolume": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST, +} + +# Map media_player media_content_type to UPnP class. Not everything will map +# directly, in which case it's not specified and other defaults will be used. +MEDIA_UPNP_CLASS_MAP: Mapping[str, str] = { + _mp_const.MEDIA_TYPE_ALBUM: "object.container.album.musicAlbum", + _mp_const.MEDIA_TYPE_ARTIST: "object.container.person.musicArtist", + _mp_const.MEDIA_TYPE_CHANNEL: "object.item.videoItem.videoBroadcast", + _mp_const.MEDIA_TYPE_CHANNELS: "object.container.channelGroup", + _mp_const.MEDIA_TYPE_COMPOSER: "object.container.person.musicArtist", + _mp_const.MEDIA_TYPE_CONTRIBUTING_ARTIST: "object.container.person.musicArtist", + _mp_const.MEDIA_TYPE_EPISODE: "object.item.epgItem.videoProgram", + _mp_const.MEDIA_TYPE_GENRE: "object.container.genre", + _mp_const.MEDIA_TYPE_IMAGE: "object.item.imageItem", + _mp_const.MEDIA_TYPE_MOVIE: "object.item.videoItem.movie", + _mp_const.MEDIA_TYPE_MUSIC: "object.item.audioItem.musicTrack", + _mp_const.MEDIA_TYPE_PLAYLIST: "object.item.playlistItem", + _mp_const.MEDIA_TYPE_PODCAST: "object.item.audioItem.audioBook", + _mp_const.MEDIA_TYPE_SEASON: "object.item.epgItem.videoProgram", + _mp_const.MEDIA_TYPE_TRACK: "object.item.audioItem.musicTrack", + _mp_const.MEDIA_TYPE_TVSHOW: "object.item.videoItem.videoBroadcast", + _mp_const.MEDIA_TYPE_URL: "object.item.bookmarkItem", + _mp_const.MEDIA_TYPE_VIDEO: "object.item.videoItem", +} + +# Translation of MediaMetadata keys to DIDL-Lite keys. +# See https://developers.google.com/cast/docs/reference/messages#MediaData via +# https://www.home-assistant.io/integrations/media_player/ for HA keys. +# See http://www.upnp.org/specs/av/UPnP-av-ContentDirectory-v4-Service.pdf for +# DIDL-Lite keys. +MEDIA_METADATA_DIDL: Mapping[str, str] = { + "subtitle": "longDescription", + "releaseDate": "date", + "studio": "publisher", + "season": "episodeSeason", + "episode": "episodeNumber", + "albumName": "album", + "trackNumber": "originalTrackNumber", +} + +# For (un)setting repeat mode, map a combination of shuffle & repeat to a list +# of play modes in order of suitability. Fall back to _PlayMode.NORMAL in any +# case. NOTE: This list is slightly different to that in SHUFFLE_PLAY_MODES, +# due to fallback behaviour when turning on repeat modes. +REPEAT_PLAY_MODES: Mapping[tuple[bool, str], list[_PlayMode]] = { + (False, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.REPEAT_ONE, + _PlayMode.REPEAT_ALL, + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.REPEAT_ALL, + _PlayMode.REPEAT_ONE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.SHUFFLE, + _PlayMode.RANDOM, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.REPEAT_ONE, + _PlayMode.RANDOM, + _PlayMode.SHUFFLE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.RANDOM, + _PlayMode.REPEAT_ALL, + _PlayMode.SHUFFLE, + _PlayMode.NORMAL, + ], +} + +# For (un)setting shuffle mode, map a combination of shuffle & repeat to a list +# of play modes in order of suitability. Fall back to _PlayMode.NORMAL in any +# case. +SHUFFLE_PLAY_MODES: Mapping[tuple[bool, str], list[_PlayMode]] = { + (False, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.REPEAT_ONE, + _PlayMode.REPEAT_ALL, + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.REPEAT_ALL, + _PlayMode.REPEAT_ONE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.SHUFFLE, + _PlayMode.RANDOM, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.RANDOM, + _PlayMode.SHUFFLE, + _PlayMode.REPEAT_ONE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.RANDOM, + _PlayMode.SHUFFLE, + _PlayMode.REPEAT_ALL, + _PlayMode.NORMAL, + ], +} diff --git a/homeassistant/components/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py new file mode 100644 index 0000000000000..07046ba4acc4c --- /dev/null +++ b/homeassistant/components/dlna_dmr/data.py @@ -0,0 +1,122 @@ +"""Data used by this integration.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from typing import NamedTuple, cast + +from async_upnp_client import UpnpEventHandler, UpnpFactory, UpnpRequester +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN, LOGGER + + +class EventListenAddr(NamedTuple): + """Unique identifier for an event listener.""" + + host: str | None # Specific local IP(v6) address for listening on + port: int # Listening port, 0 means use an ephemeral port + callback_url: str | None + + +class DlnaDmrData: + """Storage class for domain global data.""" + + lock: asyncio.Lock + requester: UpnpRequester + upnp_factory: UpnpFactory + event_notifiers: dict[EventListenAddr, AiohttpNotifyServer] + event_notifier_refs: defaultdict[EventListenAddr, int] + stop_listener_remove: CALLBACK_TYPE | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global data.""" + self.lock = asyncio.Lock() + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + self.requester = AiohttpSessionRequester(session, with_sleep=True) + self.upnp_factory = UpnpFactory(self.requester, non_strict=True) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + + async def async_cleanup_event_notifiers(self, event: Event) -> None: + """Clean up resources when Home Assistant is stopped.""" + LOGGER.debug("Cleaning resources in DlnaDmrData") + async with self.lock: + tasks = (server.stop_server() for server in self.event_notifiers.values()) + asyncio.gather(*tasks) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + + async def async_get_event_notifier( + self, listen_addr: EventListenAddr, hass: HomeAssistant + ) -> UpnpEventHandler: + """Return existing event notifier for the listen_addr, or create one. + + Only one event notify server is kept for each listen_addr. Must call + async_release_event_notifier when done to cleanup resources. + """ + LOGGER.debug("Getting event handler for %s", listen_addr) + + async with self.lock: + # Stop all servers when HA shuts down, to release resources on devices + if not self.stop_listener_remove: + self.stop_listener_remove = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_cleanup_event_notifiers + ) + + # Always increment the reference counter, for existing or new event handlers + self.event_notifier_refs[listen_addr] += 1 + + # Return an existing event handler if we can + if listen_addr in self.event_notifiers: + return self.event_notifiers[listen_addr].event_handler + + # Start event handler + server = AiohttpNotifyServer( + requester=self.requester, + listen_port=listen_addr.port, + listen_host=listen_addr.host, + callback_url=listen_addr.callback_url, + loop=hass.loop, + ) + await server.start_server() + LOGGER.debug("Started event handler at %s", server.callback_url) + + self.event_notifiers[listen_addr] = server + + return server.event_handler + + async def async_release_event_notifier(self, listen_addr: EventListenAddr) -> None: + """Indicate that the event notifier for listen_addr is not used anymore. + + This is called once by each caller of async_get_event_notifier, and will + stop the listening server when all users are done. + """ + async with self.lock: + assert self.event_notifier_refs[listen_addr] > 0 + self.event_notifier_refs[listen_addr] -= 1 + + # Shutdown the server when it has no more users + if self.event_notifier_refs[listen_addr] == 0: + server = self.event_notifiers.pop(listen_addr) + await server.stop_server() + + # Remove the cleanup listener when there's nothing left to cleanup + if not self.event_notifiers: + assert self.stop_listener_remove is not None + self.stop_listener_remove() + self.stop_listener_remove = None + + +def get_domain_data(hass: HomeAssistant) -> DlnaDmrData: + """Obtain this integration's domain data, creating it if needed.""" + if DOMAIN in hass.data: + return cast(DlnaDmrData, hass.data[DOMAIN]) + + data = DlnaDmrData(hass) + hass.data[DOMAIN] = data + return data diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 997c0585c6dd9..8ddd63ef7531a 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -1,8 +1,24 @@ { "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.17.0"], - "codeowners": [], + "requirements": ["async-upnp-client==0.23.2"], + "dependencies": ["ssdp"], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "st": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "st": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "st": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ], + "codeowners": ["@StevenLooman", "@chishm"], "iot_class": "local_push" } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index b2999a5ae56ef..1b64f592460bc 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -2,32 +2,43 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from collections.abc import Sequence +import contextlib +from datetime import datetime, timedelta import functools -import logging +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast -import aiohttp -from async_upnp_client import UpnpFactory -from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester -from async_upnp_client.profiles.dlna import DeviceState, DmrDevice -import voluptuous as vol +from async_upnp_client import UpnpService, UpnpStateVariable +from async_upnp_client.const import NotificationSubType +from async_upnp_client.exceptions import UpnpError, UpnpResponseError +from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState +from async_upnp_client.utils import async_get_local_ip -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( + ATTR_MEDIA_EXTRA, + REPEAT_MODE_ALL, + REPEAT_MODE_OFF, + REPEAT_MODE_ONE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_REPEAT_SET, SUPPORT_SEEK, + SUPPORT_SELECT_SOUND_MODE, + SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) from homeassistant.const import ( - CONF_NAME, + CONF_DEVICE_ID, + CONF_TYPE, CONF_URL, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, @@ -35,370 +46,796 @@ STATE_PLAYING, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.util import get_local_ip -import homeassistant.util.dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -DLNA_DMR_DATA = "dlna_dmr" - -DEFAULT_NAME = "DLNA Digital Media Renderer" -DEFAULT_LISTEN_PORT = 8301 - -CONF_LISTEN_IP = "listen_ip" -CONF_LISTEN_PORT = "listen_port" -CONF_CALLBACK_URL_OVERRIDE = "callback_url_override" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_LISTEN_IP): cv.string, - vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, - } +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN, + LOGGER as _LOGGER, + MEDIA_METADATA_DIDL, + MEDIA_TYPE_MAP, + MEDIA_UPNP_CLASS_MAP, + REPEAT_PLAY_MODES, + SHUFFLE_PLAY_MODES, ) +from .data import EventListenAddr, get_domain_data +PARALLEL_UPDATES = 0 -def catch_request_errors(): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" +Func = TypeVar("Func", bound=Callable[..., Any]) - def call_wrapper(func): - """Call wrapper for decorator.""" - @functools.wraps(func) - async def wrapper(self, *args, **kwargs): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - try: - return await func(self, *args, **kwargs) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error during call %s", func.__name__) +def catch_request_errors(func: Func) -> Func: + """Catch UpnpError errors.""" - return wrapper + @functools.wraps(func) + async def wrapper(self: "DlnaDmrEntity", *args: Any, **kwargs: Any) -> Any: + """Catch UpnpError errors and check availability before and after request.""" + if not self.available: + _LOGGER.warning( + "Device disappeared when trying to call service %s", func.__name__ + ) + return + try: + return await func(self, *args, **kwargs) + except UpnpError as err: + self.check_available = True + _LOGGER.error("Error during call %s: %r", func.__name__, err) - return call_wrapper + return cast(Func, wrapper) -async def async_start_event_handler( +async def async_setup_entry( hass: HomeAssistant, - server_host: str, - server_port: int, - requester, - callback_url_override: str | None = None, -): - """Register notify view.""" - hass_data = hass.data[DLNA_DMR_DATA] - if "event_handler" in hass_data: - return hass_data["event_handler"] - - # start event handler - server = AiohttpNotifyServer( - requester, - listen_port=server_port, - listen_host=server_host, - callback_url=callback_url_override, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DlnaDmrEntity from a config entry.""" + _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) + + # Create our own device-wrapping entity + entity = DlnaDmrEntity( + udn=entry.data[CONF_DEVICE_ID], + device_type=entry.data[CONF_TYPE], + name=entry.title, + event_port=entry.options.get(CONF_LISTEN_PORT) or 0, + event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), + poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), + location=entry.data[CONF_URL], ) - await server.start_server() - _LOGGER.info("UPNP/DLNA event handler listening, url: %s", server.callback_url) - hass_data["notify_server"] = server - hass_data["event_handler"] = server.event_handler - - # register for graceful shutdown - async def async_stop_server(event): - """Stop server.""" - _LOGGER.debug("Stopping UPNP/DLNA event handler") - await server.stop_server() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) - - return hass_data["event_handler"] - - -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up DLNA DMR platform.""" - if config.get(CONF_URL) is not None: - url = config[CONF_URL] - name = config.get(CONF_NAME) - elif discovery_info is not None: - url = discovery_info["ssdp_description"] - name = discovery_info.get("name") - - if DLNA_DMR_DATA not in hass.data: - hass.data[DLNA_DMR_DATA] = {} - - if "lock" not in hass.data[DLNA_DMR_DATA]: - hass.data[DLNA_DMR_DATA]["lock"] = asyncio.Lock() - - # build upnp/aiohttp requester - session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True) - - # ensure event handler has been started - async with hass.data[DLNA_DMR_DATA]["lock"]: - server_host = config.get(CONF_LISTEN_IP) - if server_host is None: - server_host = get_local_ip() - server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) - callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) - event_handler = await async_start_event_handler( - hass, server_host, server_port, requester, callback_url_override + + async_add_entities([entity]) + + +class DlnaDmrEntity(MediaPlayerEntity): + """Representation of a DLNA DMR device as a HA entity.""" + + udn: str + device_type: str + + _event_addr: EventListenAddr + poll_availability: bool + # Last known URL for the device, used when adding this entity to hass to try + # to connect before SSDP has rediscovered it, or when SSDP discovery fails. + location: str + + _device_lock: asyncio.Lock # Held when connecting or disconnecting the device + _device: DmrDevice | None = None + check_available: bool = False + + # Track BOOTID in SSDP advertisements for device changes + _bootid: int | None = None + + # DMR devices need polling for track position information. async_update will + # determine whether further device polling is required. + _attr_should_poll = True + + def __init__( + self, + udn: str, + device_type: str, + name: str, + event_port: int, + event_callback_url: str | None, + poll_availability: bool, + location: str, + ) -> None: + """Initialize DLNA DMR entity.""" + self.udn = udn + self.device_type = device_type + self._attr_name = name + self._event_addr = EventListenAddr(None, event_port, event_callback_url) + self.poll_availability = poll_availability + self.location = location + self._device_lock = asyncio.Lock() + + async def async_added_to_hass(self) -> None: + """Handle addition.""" + # Update this entity when the associated config entry is modified + if self.registry_entry and self.registry_entry.config_entry_id: + config_entry = self.hass.config_entries.async_get_entry( + self.registry_entry.config_entry_id + ) + assert config_entry is not None + self.async_on_remove( + config_entry.add_update_listener(self.async_config_update_listener) + ) + + # Try to connect to the last known location, but don't worry if not available + if not self._device: + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.debug("Couldn't connect immediately: %r", err) + + # Get SSDP notifications for only this device + self.async_on_remove( + await ssdp.async_register_callback( + self.hass, self.async_ssdp_callback, {"USN": self.usn} + ) ) - # create upnp device - factory = UpnpFactory(requester, non_strict=True) - try: - upnp_device = await factory.async_create_device(url) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - raise PlatformNotReady() from err + # async_upnp_client.SsdpListener only reports byebye once for each *UDN* + # (device name) which often is not the USN (service within the device) + # that we're interested in. So also listen for byebye advertisements for + # the UDN, which is reported in the _udn field of the combined_headers. + self.async_on_remove( + await ssdp.async_register_callback( + self.hass, + self.async_ssdp_callback, + {"_udn": self.udn, "NTS": NotificationSubType.SSDP_BYEBYE}, + ) + ) - # wrap with DmrDevice - dlna_device = DmrDevice(upnp_device, event_handler) + async def async_will_remove_from_hass(self) -> None: + """Handle removal.""" + await self._device_disconnect() + + async def async_ssdp_callback( + self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + ) -> None: + """Handle notification from SSDP of device state change.""" + _LOGGER.debug( + "SSDP %s notification of device %s at %s", + change, + info.ssdp_usn, + info.ssdp_location, + ) - # create our own device - device = DlnaDmrDevice(dlna_device, name) - _LOGGER.debug("Adding device: %s", device) - async_add_entities([device], True) + try: + bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID] + bootid: int | None = int(bootid_str, 10) + except (KeyError, ValueError): + bootid = None + + if change == ssdp.SsdpChange.UPDATE: + # This is an announcement that bootid is about to change + if self._bootid is not None and self._bootid == bootid: + # Store the new value (because our old value matches) so that we + # can ignore subsequent ssdp:alive messages + with contextlib.suppress(KeyError, ValueError): + next_bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_NEXTBOOTID] + self._bootid = int(next_bootid_str, 10) + # Nothing left to do until ssdp:alive comes through + return + if self._bootid is not None and self._bootid != bootid and self._device: + # Device has rebooted, drop existing connection and maybe reconnect + await self._device_disconnect() + self._bootid = bootid -class DlnaDmrDevice(MediaPlayerEntity): - """Representation of a DLNA DMR device.""" + if change == ssdp.SsdpChange.BYEBYE and self._device: + # Device is going away, disconnect + await self._device_disconnect() - def __init__(self, dmr_device, name=None): - """Initialize DLNA DMR device.""" - self._device = dmr_device - self._name = name + if change == ssdp.SsdpChange.ALIVE and not self._device: + if TYPE_CHECKING: + assert info.ssdp_location + location = info.ssdp_location + try: + await self._device_connect(location) + except UpnpError as err: + _LOGGER.warning( + "Failed connecting to recently alive device at %s: %r", + location, + err, + ) + + # Device could have been de/re-connected, state probably changed + self.async_write_ha_state() + + async def async_config_update_listener( + self, hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Handle options update by modifying self in-place.""" + _LOGGER.debug( + "Updating: %s with data=%s and options=%s", + self.name, + entry.data, + entry.options, + ) + self.location = entry.data[CONF_URL] + self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) - self._available = False - self._subscription_renew_time = None + new_port = entry.options.get(CONF_LISTEN_PORT) or 0 + new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) - async def async_added_to_hass(self): - """Handle addition.""" - self._device.on_event = self._on_event + if ( + new_port == self._event_addr.port + and new_callback_url == self._event_addr.callback_url + ): + return - # Register unsubscribe on stop - bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) + # Changes to eventing requires a device reconnect for it to update correctly + await self._device_disconnect() + # Update _event_addr after disconnecting, to stop the right event listener + self._event_addr = self._event_addr._replace( + port=new_port, callback_url=new_callback_url + ) + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.warning("Couldn't (re)connect after config change: %r", err) - @property - def available(self): - """Device is available.""" - return self._available + # Device was de/re-connected, state might have changed + self.async_write_ha_state() - async def _async_on_hass_stop(self, event): - """Event handler on Home Assistant stop.""" - async with self.hass.data[DLNA_DMR_DATA]["lock"]: - await self._device.async_unsubscribe_services() + async def _device_connect(self, location: str) -> None: + """Connect to the device now that it's available.""" + _LOGGER.debug("Connecting to device at %s", location) - async def async_update(self): - """Retrieve the latest data.""" - was_available = self._available + async with self._device_lock: + if self._device: + _LOGGER.debug("Trying to connect when device already connected") + return - try: - await self._device.async_update() - self._available = True - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Device unavailable") + domain_data = get_domain_data(self.hass) + + # Connect to the base UPNP device + upnp_device = await domain_data.upnp_factory.async_create_device(location) + + # Create/get event handler that is reachable by the device, using + # the connection's local IP to listen only on the relevant interface + _, event_ip = await async_get_local_ip(location, self.hass.loop) + self._event_addr = self._event_addr._replace(host=event_ip) + event_handler = await domain_data.async_get_event_notifier( + self._event_addr, self.hass + ) + + # Create profile wrapper + self._device = DmrDevice(upnp_device, event_handler) + + self.location = location + + # Subscribe to event notifications + try: + self._device.on_event = self._on_event + await self._device.async_subscribe_services(auto_resubscribe=True) + except UpnpResponseError as err: + # Device rejected subscription request. This is OK, variables + # will be polled instead. + _LOGGER.debug("Device rejected subscription: %r", err) + except UpnpError as err: + # Don't leave the device half-constructed + self._device.on_event = None + self._device = None + await domain_data.async_release_event_notifier(self._event_addr) + _LOGGER.debug("Error while subscribing during device connect: %r", err) + raise + + if ( + not self.registry_entry + or not self.registry_entry.config_entry_id + or self.registry_entry.device_id + ): return - # do we need to (re-)subscribe? - now = dt_util.utcnow() - should_renew = ( - self._subscription_renew_time and now >= self._subscription_renew_time + # Create linked HA DeviceEntry now the information is known. + dev_reg = device_registry.async_get(self.hass) + device_entry = dev_reg.async_get_or_create( + config_entry_id=self.registry_entry.config_entry_id, + # Connections are based on the root device's UDN, and the DMR + # embedded device's UDN. They may be the same, if the DMR is the + # root device. + connections={ + ( + device_registry.CONNECTION_UPNP, + self._device.profile_device.root_device.udn, + ), + (device_registry.CONNECTION_UPNP, self._device.udn), + }, + identifiers={(DOMAIN, self.unique_id)}, + default_manufacturer=self._device.manufacturer, + default_model=self._device.model_name, + default_name=self._device.name, ) - if should_renew or not was_available and self._available: + + # Update entity registry to link to the device + ent_reg = entity_registry.async_get(self.hass) + ent_reg.async_get_or_create( + self.registry_entry.domain, + self.registry_entry.platform, + self.unique_id, + device_id=device_entry.id, + ) + + async def _device_disconnect(self) -> None: + """Destroy connections to the device now that it's not available. + + Also call when removing this entity from hass to clean up connections. + """ + async with self._device_lock: + if not self._device: + _LOGGER.debug("Disconnecting from device that's not connected") + return + + _LOGGER.debug("Disconnecting from %s", self._device.name) + + self._device.on_event = None + old_device = self._device + self._device = None + await old_device.async_unsubscribe_services() + + domain_data = get_domain_data(self.hass) + await domain_data.async_release_event_notifier(self._event_addr) + + async def async_update(self) -> None: + """Retrieve the latest data.""" + if not self._device: + if not self.poll_availability: + return try: - timeout = await self._device.async_subscribe_services() - self._subscription_renew_time = dt_util.utcnow() + timeout / 2 - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Could not (re)subscribe") + await self._device_connect(self.location) + except UpnpError: + return + + assert self._device is not None - def _on_event(self, service, state_variables): + try: + do_ping = self.poll_availability or self.check_available + await self._device.async_update(do_ping=do_ping) + except UpnpError as err: + _LOGGER.debug("Device unavailable: %r", err) + await self._device_disconnect() + return + finally: + self.check_available = False + + def _on_event( + self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] + ) -> None: """State variable(s) changed, let home-assistant know.""" - self.schedule_update_ha_state() + if not state_variables: + # Indicates a failure to resubscribe, check if device is still available + self.check_available = True + + force_refresh = False + + if service.service_id == "urn:upnp-org:serviceId:AVTransport": + for state_variable in state_variables: + # Force a state refresh when player begins or pauses playback + # to update the position info. + if ( + state_variable.name == "TransportState" + and state_variable.value + in (TransportState.PLAYING, TransportState.PAUSED_PLAYBACK) + ): + force_refresh = True + + self.async_schedule_update_ha_state(force_refresh) @property - def supported_features(self): - """Flag media player features that are supported.""" + def available(self) -> bool: + """Device is available when we have a connection to it.""" + return self._device is not None and self._device.profile_device.available + + @property + def unique_id(self) -> str: + """Report the UDN (Unique Device Name) as this entity's unique ID.""" + return self.udn + + @property + def usn(self) -> str: + """Get the USN based on the UDN (Unique Device Name) and device type.""" + return f"{self.udn}::{self.device_type}" + + @property + def state(self) -> str | None: + """State of the player.""" + if not self._device or not self.available: + return STATE_OFF + if self._device.transport_state is None: + return STATE_ON + if self._device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): + return STATE_PLAYING + if self._device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): + return STATE_PAUSED + if self._device.transport_state == TransportState.VENDOR_DEFINED: + # Unable to map this state to anything reasonable, so it's "Unknown" + return None + + return STATE_IDLE + + @property + def supported_features(self) -> int: + """Flag media player features that are supported at this moment. + + Supported features may change as the device enters different states. + """ + if not self._device: + return 0 + supported_features = 0 if self._device.has_volume_level: supported_features |= SUPPORT_VOLUME_SET if self._device.has_volume_mute: supported_features |= SUPPORT_VOLUME_MUTE - if self._device.has_play: + if self._device.can_play: supported_features |= SUPPORT_PLAY - if self._device.has_pause: + if self._device.can_pause: supported_features |= SUPPORT_PAUSE - if self._device.has_stop: + if self._device.can_stop: supported_features |= SUPPORT_STOP - if self._device.has_previous: + if self._device.can_previous: supported_features |= SUPPORT_PREVIOUS_TRACK - if self._device.has_next: + if self._device.can_next: supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: supported_features |= SUPPORT_PLAY_MEDIA - if self._device.has_seek_rel_time: + if self._device.can_seek_rel_time: supported_features |= SUPPORT_SEEK + play_modes = self._device.valid_play_modes + if play_modes & {PlayMode.RANDOM, PlayMode.SHUFFLE}: + supported_features |= SUPPORT_SHUFFLE_SET + if play_modes & {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL}: + supported_features |= SUPPORT_REPEAT_SET + + if self._device.has_presets: + supported_features |= SUPPORT_SELECT_SOUND_MODE + return supported_features @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._device.has_volume_level: - return self._device.volume_level - return 0 + if not self._device or not self._device.has_volume_level: + return None + return self._device.volume_level - @catch_request_errors() - async def async_set_volume_level(self, volume): + @catch_request_errors + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" + assert self._device is not None await self._device.async_set_volume_level(volume) @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" + if not self._device: + return None return self._device.is_volume_muted - @catch_request_errors() - async def async_mute_volume(self, mute): + @catch_request_errors + async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" + assert self._device is not None desired_mute = bool(mute) await self._device.async_mute_volume(desired_mute) - @catch_request_errors() - async def async_media_pause(self): + @catch_request_errors + async def async_media_pause(self) -> None: """Send pause command.""" - if not self._device.can_pause: - _LOGGER.debug("Cannot do Pause") - return - + assert self._device is not None await self._device.async_pause() - @catch_request_errors() - async def async_media_play(self): + @catch_request_errors + async def async_media_play(self) -> None: """Send play command.""" - if not self._device.can_play: - _LOGGER.debug("Cannot do Play") - return - + assert self._device is not None await self._device.async_play() - @catch_request_errors() - async def async_media_stop(self): + @catch_request_errors + async def async_media_stop(self) -> None: """Send stop command.""" - if not self._device.can_stop: - _LOGGER.debug("Cannot do Stop") - return - + assert self._device is not None await self._device.async_stop() - @catch_request_errors() - async def async_media_seek(self, position): + @catch_request_errors + async def async_media_seek(self, position: int | float) -> None: """Send seek command.""" - if not self._device.can_seek_rel_time: - _LOGGER.debug("Cannot do Seek/rel_time") - return - + assert self._device is not None time = timedelta(seconds=position) await self._device.async_seek_rel_time(time) - @catch_request_errors() - async def async_play_media(self, media_type, media_id, **kwargs): + @catch_request_errors + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) - title = "Home Assistant" + assert self._device is not None + extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} + metadata: dict[str, Any] = extra.get("metadata") or {} + + title = extra.get("title") or metadata.get("title") or "Home Assistant" + if thumb := extra.get("thumb"): + metadata["album_art_uri"] = thumb + + # Translate metadata keys from HA names to DIDL-Lite names + for hass_key, didl_key in MEDIA_METADATA_DIDL.items(): + if hass_key in metadata: + metadata[didl_key] = metadata.pop(hass_key) + + # Create metadata specific to the given media type; different fields are + # available depending on what the upnp_class is. + upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type) + didl_metadata = await self._device.construct_play_media_metadata( + media_url=media_id, + media_title=title, + override_upnp_class=upnp_class, + meta_data=metadata, + ) # Stop current playing media if self._device.can_stop: await self.async_media_stop() # Queue media - await self._device.async_set_transport_uri(media_id, title) - await self._device.async_wait_for_can_play() + await self._device.async_set_transport_uri(media_id, title, didl_metadata) - # If already playing, no need to call Play - if self._device.state == DeviceState.PLAYING: + # If already playing, or don't want to autoplay, no need to call Play + autoplay = extra.get("autoplay", True) + if self._device.transport_state == TransportState.PLAYING or not autoplay: return # Play it + await self._device.async_wait_for_can_play() await self.async_media_play() - @catch_request_errors() - async def async_media_previous_track(self): + @catch_request_errors + async def async_media_previous_track(self) -> None: """Send previous track command.""" - if not self._device.can_previous: - _LOGGER.debug("Cannot do Previous") - return - + assert self._device is not None await self._device.async_previous() - @catch_request_errors() - async def async_media_next_track(self): + @catch_request_errors + async def async_media_next_track(self) -> None: """Send next track command.""" - if not self._device.can_next: - _LOGGER.debug("Cannot do Next") - return - + assert self._device is not None await self._device.async_next() @property - def media_title(self): + def shuffle(self) -> bool | None: + """Boolean if shuffle is enabled.""" + if not self._device: + return None + + if not (play_mode := self._device.play_mode): + return None + + if play_mode == PlayMode.VENDOR_DEFINED: + return None + + return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM) + + @catch_request_errors + async def async_set_shuffle(self, shuffle: bool) -> None: + """Enable/disable shuffle mode.""" + assert self._device is not None + + repeat = self.repeat or REPEAT_MODE_OFF + potential_play_modes = SHUFFLE_PLAY_MODES[(shuffle, repeat)] + + valid_play_modes = self._device.valid_play_modes + + for mode in potential_play_modes: + if mode in valid_play_modes: + await self._device.async_set_play_mode(mode) + return + + _LOGGER.debug( + "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat + ) + + @property + def repeat(self) -> str | None: + """Return current repeat mode.""" + if not self._device: + return None + + if not (play_mode := self._device.play_mode): + return None + + if play_mode == PlayMode.VENDOR_DEFINED: + return None + + if play_mode == PlayMode.REPEAT_ONE: + return REPEAT_MODE_ONE + + if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM): + return REPEAT_MODE_ALL + + return REPEAT_MODE_OFF + + @catch_request_errors + async def async_set_repeat(self, repeat: str) -> None: + """Set repeat mode.""" + assert self._device is not None + + shuffle = self.shuffle or False + potential_play_modes = REPEAT_PLAY_MODES[(shuffle, repeat)] + + valid_play_modes = self._device.valid_play_modes + + for mode in potential_play_modes: + if mode in valid_play_modes: + await self._device.async_set_play_mode(mode) + return + + _LOGGER.debug( + "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat + ) + + @property + def sound_mode(self) -> str | None: + """Name of the current sound mode, not supported by DLNA.""" + return None + + @property + def sound_mode_list(self) -> list[str] | None: + """List of available sound modes.""" + if not self._device: + return None + return self._device.preset_names + + @catch_request_errors + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + assert self._device is not None + await self._device.async_select_preset(sound_mode) + + @property + def media_title(self) -> str | None: """Title of current playing media.""" - return self._device.media_title + if not self._device: + return None + # Use the best available title + return self._device.media_program_title or self._device.media_title @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" + if not self._device: + return None return self._device.media_image_url @property - def state(self): - """State of the player.""" - if not self._available: - return STATE_OFF - - if self._device.state is None: - return STATE_ON - if self._device.state == DeviceState.PLAYING: - return STATE_PLAYING - if self._device.state == DeviceState.PAUSED: - return STATE_PAUSED + def media_content_id(self) -> str | None: + """Content ID of current playing media.""" + if not self._device: + return None + return self._device.current_track_uri - return STATE_IDLE + @property + def media_content_type(self) -> str | None: + """Content type of current playing media.""" + if not self._device or not self._device.media_class: + return None + return MEDIA_TYPE_MAP.get(self._device.media_class) @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" + if not self._device: + return None return self._device.media_duration @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" + if not self._device: + return None return self._device.media_position @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid. Returns value from homeassistant.util.dt.utcnow(). """ + if not self._device: + return None return self._device.media_position_updated_at @property - def name(self) -> str: - """Return the name of the device.""" - if self._name: - return self._name - return self._device.name + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_artist @property - def unique_id(self) -> str: - """Return an unique ID.""" - return self._device.udn + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_name + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_artist + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_track_number + + @property + def media_series_title(self) -> str | None: + """Title of series of current playing media, TV show only.""" + if not self._device: + return None + return self._device.media_series_title + + @property + def media_season(self) -> str | None: + """Season number, starting at 1, of current playing media, TV show only.""" + if not self._device: + return None + # Some DMRs, like Kodi, leave this as 0 and encode the season & episode + # in the episode_number metadata, as {season:d}{episode:02d} + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + with contextlib.suppress(ValueError): + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode // 100) + return self._device.media_season_number + + @property + def media_episode(self) -> str | None: + """Episode number of current playing media, TV show only.""" + if not self._device: + return None + # Complement to media_season math above + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + with contextlib.suppress(ValueError): + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode % 100) + return self._device.media_episode_number + + @property + def media_channel(self) -> str | None: + """Channel name currently playing.""" + if not self._device: + return None + return self._device.media_channel_name + + @property + def media_playlist(self) -> str | None: + """Title of Playlist currently playing.""" + if not self._device: + return None + return self._device.media_playlist_title diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json new file mode 100644 index 0000000000000..ac77009e0cb61 --- /dev/null +++ b/homeassistant/components/dlna_dmr/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Discovered DLNA DMR devices", + "description": "Choose a device to configure or leave blank to enter a URL", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "manual": { + "title": "Manual DLNA DMR device connection", + "description": "URL to a device description XML file", + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "import_turn_on": { + "description": "Please turn on the device and click submit to continue migration" + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "alternative_integration": "Device is better supported by another integration", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "discovery_error": "Failed to discover a matching DLNA device", + "incomplete_config": "Configuration is missing a required variable", + "non_unique_id": "Multiple devices found with the same unique ID", + "not_dmr": "Device is not a supported Digital Media Renderer" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "not_dmr": "Device is not a supported Digital Media Renderer" + } + }, + "options": { + "step": { + "init": { + "title": "DLNA Digital Media Renderer configuration", + "data": { + "listen_port": "Event listener port (random if not set)", + "callback_url_override": "Event listener callback URL", + "poll_availability": "Poll for device availability" + } + } + }, + "error": { + "invalid_url": "Invalid URL" + } + } +} diff --git a/homeassistant/components/dlna_dmr/translations/bg.json b/homeassistant/components/dlna_dmr/translations/bg.json new file mode 100644 index 0000000000000..00e64e1568d4b --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/bg.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "could_not_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 DLNA \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "incomplete_config": "\u0412 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043b\u0438\u043f\u0441\u0432\u0430 \u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u0430 \u043f\u0440\u043e\u043c\u0435\u043d\u043b\u0438\u0432\u0430", + "non_unique_id": "\u041d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0441\u0430 \u043d\u044f\u043a\u043e\u043b\u043a\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u0435\u0434\u0438\u043d \u0438 \u0441\u044a\u0449 \u0443\u043d\u0438\u043a\u0430\u043b\u0435\u043d \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "could_not_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 DLNA \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0437\u0430\u043f\u043e\u0447\u043d\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430?" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "url": "URL" + }, + "title": "\u041e\u0442\u043a\u0440\u0438\u0442\u0438 DLNA DMR \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } + }, + "options": { + "error": { + "invalid_url": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d URL" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/ca.json b/homeassistant/components/dlna_dmr/translations/ca.json new file mode 100644 index 0000000000000..944af3bfebc30 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ca.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "alternative_integration": "El dispositiu t\u00e9 millor compatibilitat amb una altra integraci\u00f3", + "cannot_connect": "Ha fallat la connexi\u00f3", + "could_not_connect": "No s'ha pogut connectar amb el dispositiu DLNA", + "discovery_error": "No s'ha pogut descobrir cap dispositiu DLNA coincident", + "incomplete_config": "Falta una variable obligat\u00f2ria a la configuraci\u00f3", + "non_unique_id": "S'han trobat diversos dispositius amb el mateix identificador \u00fanic", + "not_dmr": "El dispositiu no \u00e9s un renderitzador de mitjans digitals compatible" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "could_not_connect": "No s'ha pogut connectar amb el dispositiu DLNA", + "not_dmr": "El dispositiu no \u00e9s un renderitzador de mitjans digitals compatible" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + }, + "import_turn_on": { + "description": "Engega el dispositiu i fes clic a Envia per continuar la migraci\u00f3" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL al fitxer XML de descripci\u00f3 del dispositiu", + "title": "Connexi\u00f3 manual de dispositiu DLNA DMR" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "url": "URL" + }, + "description": "Tria un dispositiu a configurar o deixeu-ho en blanc per introduir un URL", + "title": "Dispositius descoberts DLNA DMR" + } + } + }, + "options": { + "error": { + "invalid_url": "URL inv\u00e0lid" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL de crida de l'oient d'esdeveniments", + "listen_port": "Port de l'oient d'esdeveniments (aleatori si no es defineix)", + "poll_availability": "Sondeja per saber la disponibilitat del dispositiu" + }, + "title": "Configuraci\u00f3 del renderitzador de mitjans digitals DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/cs.json b/homeassistant/components/dlna_dmr/translations/cs.json new file mode 100644 index 0000000000000..85c9a831dda22 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Chcete za\u010d\u00edt nastavovat?" + }, + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/de.json b/homeassistant/components/dlna_dmr/translations/de.json new file mode 100644 index 0000000000000..e37786c401cae --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/de.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "alternative_integration": "Das Ger\u00e4t wird besser durch eine andere Integration unterst\u00fctzt", + "cannot_connect": "Verbindung fehlgeschlagen", + "could_not_connect": "Verbindung zum DLNA-Ger\u00e4t fehlgeschlagen", + "discovery_error": "Ein passendes DLNA-Ger\u00e4t konnte nicht gefunden werden", + "incomplete_config": "In der Konfiguration fehlt eine erforderliche Variable", + "non_unique_id": "Mehrere Ger\u00e4te mit derselben eindeutigen ID gefunden", + "not_dmr": "Ger\u00e4t ist kein unterst\u00fctzter Digital Media Renderer" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "could_not_connect": "Verbindung zum DLNA-Ger\u00e4t fehlgeschlagen", + "not_dmr": "Ger\u00e4t ist kein unterst\u00fctzter Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + }, + "import_turn_on": { + "description": "Bitte schalte das Ger\u00e4t ein und klicke auf Senden, um die Migration fortzusetzen" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL zu einer XML-Datei mit Ger\u00e4tebeschreibung", + "title": "Manuelle DLNA DMR-Ger\u00e4teverbindung" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "W\u00e4hle ein zu konfigurierendes Ger\u00e4t oder lasse es leer, um eine URL einzugeben.", + "title": "Erkannte DLNA-DMR-Ger\u00e4te" + } + } + }, + "options": { + "error": { + "invalid_url": "Ung\u00fcltige URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "R\u00fcckruf-URL des Ereignis-Listeners", + "listen_port": "Port des Ereignis-Listeners (zuf\u00e4llig, wenn nicht festgelegt)", + "poll_availability": "Abfrage der Ger\u00e4teverf\u00fcgbarkeit" + }, + "title": "DLNA Digital Media Renderer Konfiguration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/en.json b/homeassistant/components/dlna_dmr/translations/en.json new file mode 100644 index 0000000000000..512dfe7f11c48 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/en.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "alternative_integration": "Device is better supported by another integration", + "cannot_connect": "Failed to connect", + "could_not_connect": "Failed to connect to DLNA device", + "discovery_error": "Failed to discover a matching DLNA device", + "incomplete_config": "Configuration is missing a required variable", + "non_unique_id": "Multiple devices found with the same unique ID", + "not_dmr": "Device is not a supported Digital Media Renderer" + }, + "error": { + "cannot_connect": "Failed to connect", + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a supported Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to start set up?" + }, + "import_turn_on": { + "description": "Please turn on the device and click submit to continue migration" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL to a device description XML file", + "title": "Manual DLNA DMR device connection" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Choose a device to configure or leave blank to enter a URL", + "title": "Discovered DLNA DMR devices" + } + } + }, + "options": { + "error": { + "invalid_url": "Invalid URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Event listener port (random if not set)", + "poll_availability": "Poll for device availability" + }, + "title": "DLNA Digital Media Renderer configuration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/et.json b/homeassistant/components/dlna_dmr/translations/et.json new file mode 100644 index 0000000000000..b5019f5f9a13f --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/et.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "alternative_integration": "Seadet toetab paremini teine sidumine", + "cannot_connect": "\u00dchendamine nurjus", + "could_not_connect": "DLNA seadmega \u00fchenduse loomine nurjus", + "discovery_error": "Sobiva DLNA -seadme leidmine nurjus", + "incomplete_config": "Seadetes puudub n\u00f5utav muutuja", + "non_unique_id": "Leiti mitu sama unikaalse ID-ga seadet", + "not_dmr": "Seade ei ole toetatud digitaalne meediumiedastusseade" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "could_not_connect": "DLNA seadmega \u00fchenduse loomine nurjus", + "not_dmr": "Seade ei ole toetatud digitaalne meediumiedastusseade" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kas alustada seadistamist?" + }, + "import_turn_on": { + "description": "L\u00fclita seade sisse ja kl\u00f5psa migreerimise j\u00e4tkamiseks nuppu Edasta" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "Seadme kirjelduse XML-faili URL", + "title": "DLNA DMR seadme k\u00e4sitsi \u00fchendamine" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Vali h\u00e4\u00e4lestatav seade v\u00f5i j\u00e4ta URL -i sisestamiseks t\u00fchjaks", + "title": "Avastatud DLNA DMR-seadmed" + } + } + }, + "options": { + "error": { + "invalid_url": "Sobimatu URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "S\u00fcndmuse kuulaja URL", + "listen_port": "S\u00fcndmuste kuulaja port (juhuslik kui pole m\u00e4\u00e4ratud)", + "poll_availability": "K\u00fcsitle seadme saadavuse kohta" + }, + "title": "DLNA digitaalse meediumi renderdaja s\u00e4tted" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/fr.json b/homeassistant/components/dlna_dmr/translations/fr.json new file mode 100644 index 0000000000000..f7a1b9cd71c95 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/fr.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "alternative_integration": "L'appareil est mieux pris en charge par une autre int\u00e9gration", + "cannot_connect": "\u00c9chec de connexion", + "could_not_connect": "\u00c9chec de la connexion au p\u00e9riph\u00e9rique DLNA", + "discovery_error": "\u00c9chec de la d\u00e9couverte d'un p\u00e9riph\u00e9rique DLNA correspondant", + "incomplete_config": "Il manque une variable requise dans la configuration", + "non_unique_id": "Plusieurs appareils trouv\u00e9s avec le m\u00eame identifiant unique", + "not_dmr": "L'appareil n'est pas un moteur de rendu multim\u00e9dia num\u00e9rique pris en charge" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "could_not_connect": "\u00c9chec de la connexion au p\u00e9riph\u00e9rique DLNA", + "not_dmr": "L'appareil n'est pas un moteur de rendu multim\u00e9dia num\u00e9rique pris en charge" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration ?" + }, + "import_turn_on": { + "description": "Veuillez allumer l'appareil et cliquer sur soumettre pour continuer la migration" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL vers un fichier XML de description d'appareil", + "title": "Connexion manuelle de l'appareil DLNA DMR" + }, + "user": { + "data": { + "host": "H\u00f4te", + "url": "URL" + }, + "description": "Choisissez un appareil \u00e0 configurer ou laissez vide pour saisir une URL", + "title": "P\u00e9riph\u00e9riques DLNA DMR d\u00e9couverts" + } + } + }, + "options": { + "error": { + "invalid_url": "URL invalide" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL de rappel de l'\u00e9couteur d'\u00e9v\u00e9nement", + "listen_port": "Port d'\u00e9coute d'\u00e9v\u00e9nement (al\u00e9atoire s'il n'est pas d\u00e9fini)", + "poll_availability": "Sondage pour la disponibilit\u00e9 de l'appareil" + }, + "title": "Configuration du moteur de rendu multim\u00e9dia num\u00e9rique DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/he.json b/homeassistant/components/dlna_dmr/translations/he.json new file mode 100644 index 0000000000000..7025721fa430c --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + }, + "manual": { + "data": { + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/hu.json b/homeassistant/components/dlna_dmr/translations/hu.json new file mode 100644 index 0000000000000..6596e71e05e26 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/hu.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van konfigur\u00e1lva", + "alternative_integration": "Az eszk\u00f6zt jobban t\u00e1mogatja egy m\u00e1sik integr\u00e1ci\u00f3", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "could_not_connect": "Nem siker\u00fclt csatlakozni a DLNA-eszk\u00f6zh\u00f6z", + "discovery_error": "Nem siker\u00fclt megfelel\u0151 DLNA-eszk\u00f6zt tal\u00e1lni", + "incomplete_config": "A konfigur\u00e1ci\u00f3b\u00f3l hi\u00e1nyzik egy sz\u00fcks\u00e9ges \u00e9rt\u00e9k", + "non_unique_id": "T\u00f6bb eszk\u00f6z tal\u00e1lhat\u00f3 ugyanazzal az egyedi azonos\u00edt\u00f3val", + "not_dmr": "Az eszk\u00f6z nem digit\u00e1lis m\u00e9dia renderel\u0151" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "could_not_connect": "Nem siker\u00fclt csatlakozni a DLNA-eszk\u00f6zh\u00f6z", + "not_dmr": "Az eszk\u00f6z nem digit\u00e1lis m\u00e9dia renderel\u0151" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kezd\u0151dhet a be\u00e1ll\u00edt\u00e1s?" + }, + "import_turn_on": { + "description": "Kapcsolja be az eszk\u00f6zt, \u00e9s kattintson a K\u00fcld\u00e9s gombra a migr\u00e1ci\u00f3 folytat\u00e1s\u00e1hoz" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL egy eszk\u00f6zle\u00edr\u00f3 XML f\u00e1jlhoz", + "title": "DLNA DMR eszk\u00f6z manu\u00e1lis csatlakoztat\u00e1sa" + }, + "user": { + "data": { + "host": "C\u00edm", + "url": "URL" + }, + "description": "V\u00e1lassz egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt vagy adj meg egy URL-t", + "title": "DLNA digit\u00e1lis m\u00e9dia renderel\u0151" + } + } + }, + "options": { + "error": { + "invalid_url": "\u00c9rv\u00e9nytelen URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Esem\u00e9nyfigyel\u0151 visszah\u00edv\u00e1si URL (callback)", + "listen_port": "Esem\u00e9nyfigyel\u0151 port (v\u00e9letlenszer\u0171, ha nincs be\u00e1ll\u00edtva)", + "poll_availability": "Eszk\u00f6z el\u00e9r\u00e9s\u00e9nek tesztel\u00e9se lek\u00e9rdez\u00e9ssel" + }, + "title": "DLNA konfigur\u00e1ci\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/id.json b/homeassistant/components/dlna_dmr/translations/id.json new file mode 100644 index 0000000000000..152c4f73a563a --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/id.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "alternative_integration": "Perangkat dapat didukung lebih baik lewat integrasi lainnya", + "cannot_connect": "Gagal terhubung", + "could_not_connect": "Gagal terhubung ke perangkat DLNA", + "discovery_error": "Gagal menemukan perangkat DLNA yang cocok", + "incomplete_config": "Konfigurasi tidak memiliki variabel yang diperlukan", + "non_unique_id": "Beberapa perangkat ditemukan dengan ID unik yang sama", + "not_dmr": "Perangkat bukan Digital Media Renderer yang didukung" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "could_not_connect": "Gagal terhubung ke perangkat DLNA", + "not_dmr": "Perangkat bukan Digital Media Renderer yang didukung" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + }, + "import_turn_on": { + "description": "Nyalakan perangkat dan klik kirim untuk melanjutkan migrasi" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL ke file XML deskripsi perangkat", + "title": "Koneksi perangkat DLNA DMR manual" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Pilih perangkat untuk dikonfigurasi atau biarkan kosong untuk memasukkan URL", + "title": "Perangkat DLNA DMR yang ditemukan" + } + } + }, + "options": { + "error": { + "invalid_url": "URL tidak valid" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL panggilan balik pendengar peristiwa", + "listen_port": "Port pendengar peristiwa (acak jika tidak diatur)", + "poll_availability": "Polling untuk ketersediaan perangkat" + }, + "title": "Konfigurasi Digital Media Renderer DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/is.json b/homeassistant/components/dlna_dmr/translations/is.json new file mode 100644 index 0000000000000..9ca9e2791b60f --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/is.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "import_turn_on": { + "description": "Kveiktu \u00e1 t\u00e6kinu og smelltu \u00e1 senda til a\u00f0 halda \u00e1fram flutningi" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/it.json b/homeassistant/components/dlna_dmr/translations/it.json new file mode 100644 index 0000000000000..545d3cadbcbc0 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/it.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "alternative_integration": "Il dispositivo \u00e8 meglio supportato da un'altra integrazione", + "cannot_connect": "Impossibile connettersi", + "could_not_connect": "Impossibile connettersi al dispositivo DLNA", + "discovery_error": "Impossibile individuare un dispositivo DLNA corrispondente", + "incomplete_config": "Nella configurazione manca una variabile richiesta", + "non_unique_id": "Pi\u00f9 dispositivi trovati con lo stesso ID univoco", + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer supportato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "could_not_connect": "Impossibile connettersi al dispositivo DLNA", + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer supportato" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vuoi iniziare la configurazione?" + }, + "import_turn_on": { + "description": "Accendi il dispositivo e fai clic su Invia per continuare la migrazione" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL di un file XML di descrizione del dispositivo", + "title": "Connessione manuale del dispositivo DLNA DMR" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Scegli un dispositivo da configurare o lascia vuoto per inserire un URL", + "title": "Rilevati dispositivi DLNA DMR" + } + } + }, + "options": { + "error": { + "invalid_url": "URL non valido" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL di richiamata dell'ascoltatore di eventi", + "listen_port": "Porta dell'ascoltatore di eventi (casuale se non impostata)", + "poll_availability": "Interrogazione per la disponibilit\u00e0 del dispositivo" + }, + "title": "Configurazione DLNA Digital Media Renderer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/ja.json b/homeassistant/components/dlna_dmr/translations/ja.json new file mode 100644 index 0000000000000..9edb91565349d --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ja.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "alternative_integration": "\u30c7\u30d0\u30a4\u30b9\u306f\u5225\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u3001\u3088\u308a\u9069\u5207\u306b\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "could_not_connect": "DLNA\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "discovery_error": "\u4e00\u81f4\u3059\u308bDLNA \u30c7\u30d0\u30a4\u30b9\u3092\u691c\u51fa\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", + "incomplete_config": "\u8a2d\u5b9a\u306b\u5fc5\u8981\u306a\u5909\u6570\u304c\u3042\u308a\u307e\u305b\u3093", + "non_unique_id": "\u540c\u4e00\u306eID\u3067\u8907\u6570\u306e\u30c7\u30d0\u30a4\u30b9\u304c\u691c\u51fa\u3055\u308c\u307e\u3057\u305f", + "not_dmr": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\u672a\u30b5\u30dd\u30fc\u30c8\u306aDigital Media Renderer\u3067\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "could_not_connect": "DLNA\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "not_dmr": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\u672a\u30b5\u30dd\u30fc\u30c8\u306aDigital Media Renderer\u3067\u3059" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "import_turn_on": { + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u96fb\u6e90\u3092\u5165\u308c\u3001\u9001\u4fe1(submit)\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u79fb\u884c\u3092\u7d9a\u3051\u3066\u304f\u3060\u3055\u3044" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u8a18\u8ff0XML\u30d5\u30a1\u30a4\u30eb\u3078\u306eURL", + "title": "\u624b\u52d5\u3067DLNA DMR\u6a5f\u5668\u306b\u63a5\u7d9a" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "url": "URL" + }, + "description": "\u8a2d\u5b9a\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3059\u308b\u304b\u3001\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u3066URL\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "\u767a\u898b\u3055\u308c\u305fDLNA DMR\u6a5f\u5668" + } + } + }, + "options": { + "error": { + "invalid_url": "\u7121\u52b9\u306aURL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "\u30a4\u30d9\u30f3\u30c8\u30ea\u30b9\u30ca\u30fc\u306e\u30b3\u30fc\u30eb\u30d0\u30c3\u30afURL", + "listen_port": "\u30a4\u30d9\u30f3\u30c8\u30ea\u30b9\u30ca\u30fc\u30dd\u30fc\u30c8(\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u30e9\u30f3\u30c0\u30e0)", + "poll_availability": "\u30c7\u30d0\u30a4\u30b9\u306e\u53ef\u7528\u6027\u3092\u30dd\u30fc\u30ea\u30f3\u30b0" + }, + "title": "DLNA Digital Media Renderer\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/nl.json b/homeassistant/components/dlna_dmr/translations/nl.json new file mode 100644 index 0000000000000..5331f3340dda5 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/nl.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "alternative_integration": "Apparaat wordt beter ondersteund door een andere integratie", + "cannot_connect": "Kan geen verbinding maken", + "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", + "discovery_error": "Kan geen overeenkomend DLNA-apparaat vinden", + "incomplete_config": "Configuratie mist een vereiste variabele", + "non_unique_id": "Meerdere apparaten gevonden met hetzelfde unieke ID", + "not_dmr": "Apparaat is een niet-ondersteund Digital Media Renderer" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", + "not_dmr": "Apparaat is een niet-ondersteund Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Wilt u beginnen met instellen?" + }, + "import_turn_on": { + "description": "Zet het apparaat aan en klik op verzenden om door te gaan met de migratie" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL naar een XML-bestand met apparaatbeschrijvingen", + "title": "Handmatige DLNA DMR-apparaatverbinding" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Kies een apparaat om te configureren of laat leeg om een URL in te voeren", + "title": "Ontdekt DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Ongeldige URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Poort om naar gebeurtenissen te luisteren (willekeurige poort indien niet ingesteld)", + "poll_availability": "Pollen voor apparaat beschikbaarheid" + }, + "title": "DLNA Digital Media Renderer instellingen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/no.json b/homeassistant/components/dlna_dmr/translations/no.json new file mode 100644 index 0000000000000..a1ce1fdce32b5 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/no.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "alternative_integration": "Enheten st\u00f8ttes bedre av en annen integrasjon", + "cannot_connect": "Tilkobling mislyktes", + "could_not_connect": "Kunne ikke koble til DLNA -enhet", + "discovery_error": "Kunne ikke finne en matchende DLNA -enhet", + "incomplete_config": "Konfigurasjonen mangler en n\u00f8dvendig variabel", + "non_unique_id": "Flere enheter ble funnet med samme unike ID", + "not_dmr": "Enheten er ikke en st\u00f8ttet Digital Media Renderer" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "could_not_connect": "Kunne ikke koble til DLNA -enhet", + "not_dmr": "Enheten er ikke en st\u00f8ttet Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + }, + "import_turn_on": { + "description": "Sl\u00e5 p\u00e5 enheten og klikk p\u00e5 send for \u00e5 fortsette overf\u00f8ringen" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL til en enhetsbeskrivelse XML -fil", + "title": "Manuell DLNA DMR -enhetstilkobling" + }, + "user": { + "data": { + "host": "Vert", + "url": "URL" + }, + "description": "Velg en enhet du vil konfigurere, eller la den st\u00e5 tom for \u00e5 angi en URL", + "title": "Oppdaget DLNA DMR -enheter" + } + } + }, + "options": { + "error": { + "invalid_url": "ugyldig URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL for tilbakeringing av hendelseslytter", + "listen_port": "Hendelseslytterport (tilfeldig hvis den ikke er angitt)", + "poll_availability": "Avstemning for tilgjengelighet av enheter" + }, + "title": "DLNA Digital Media Renderer -konfigurasjon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/pl.json b/homeassistant/components/dlna_dmr/translations/pl.json new file mode 100644 index 0000000000000..7f831c92f9945 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/pl.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "alternative_integration": "Urz\u0105dzenie jest lepiej obs\u0142ugiwane przez inn\u0105 integracj\u0119", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "could_not_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem DLNA", + "discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 pasuj\u0105cego urz\u0105dzenia DLNA", + "incomplete_config": "W konfiguracji brakuje wymaganej zmiennej", + "non_unique_id": "Znaleziono wiele urz\u0105dze\u0144 z tym samym unikalnym identyfikatorem", + "not_dmr": "Urz\u0105dzenie nie jest obs\u0142ugiwanym rendererem multimedi\u00f3w cyfrowych (DMR)" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "could_not_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem DLNA", + "not_dmr": "Urz\u0105dzenie nie jest obs\u0142ugiwanym rendererem multimedi\u00f3w cyfrowych (DMR)" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" + }, + "import_turn_on": { + "description": "W\u0142\u0105cz urz\u0105dzenie i kliknij \"Zatwierd\u017a\", aby kontynuowa\u0107 migracj\u0119" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL do pliku XML z opisem urz\u0105dzenia", + "title": "R\u0119czne pod\u0142\u0105czanie urz\u0105dzenia DLNA DMR" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "url": "URL" + }, + "description": "Wybierz urz\u0105dzenie do skonfigurowania lub pozostaw puste, aby wprowadzi\u0107 adres URL", + "title": "Wykryto urz\u0105dzenia DLNA DMR" + } + } + }, + "options": { + "error": { + "invalid_url": "Nieprawid\u0142owy adres URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Adres Callback URL dla detektora zdarze\u0144", + "listen_port": "Port detektora zdarze\u0144 (losowy, je\u015bli nie jest ustawiony)", + "poll_availability": "Sondowanie na dost\u0119pno\u015b\u0107 urz\u0105dze\u0144" + }, + "title": "Konfiguracja rendera multimedi\u00f3w cyfrowych (DMR) dla DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/ru.json b/homeassistant/components/dlna_dmr/translations/ru.json new file mode 100644 index 0000000000000..d8931e268a004 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ru.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "alternative_integration": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043b\u0443\u0447\u0448\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u0434\u0440\u0443\u0433\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "could_not_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "discovery_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u043f\u043e\u0434\u0445\u043e\u0434\u044f\u0449\u0435\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e DLNA.", + "incomplete_config": "\u0412 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f.", + "non_unique_id": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u0441 \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u044b\u043c \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c.", + "not_dmr": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 Digital Media Renderer." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "could_not_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "not_dmr": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 Digital Media Renderer." + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + }, + "import_turn_on": { + "description": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c' \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438" + }, + "manual": { + "data": { + "url": "URL-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "URL-\u0430\u0434\u0440\u0435\u0441 XML-\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 DLNA DMR" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "url": "URL-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043b\u0438 \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0447\u0442\u043e\u0431\u044b \u0432\u0432\u0435\u0441\u0442\u0438 URL.", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 DLNA DMR" + } + } + }, + "options": { + "error": { + "invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "init": { + "data": { + "callback_url_override": "Callback URL \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439", + "listen_port": "\u041f\u043e\u0440\u0442 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 (\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439, \u0435\u0441\u043b\u0438 \u043d\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d)", + "poll_availability": "\u041e\u043f\u0440\u043e\u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0441\u0442\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u0430 DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/sl.json b/homeassistant/components/dlna_dmr/translations/sl.json new file mode 100644 index 0000000000000..5a85ea9dd01c2 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "alternative_integration": "Naprava je bolje podprta z drugo integracijo", + "cannot_connect": "Povezava ni uspela" + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "step": { + "manual": { + "data": { + "url": "URL" + } + }, + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/tr.json b/homeassistant/components/dlna_dmr/translations/tr.json new file mode 100644 index 0000000000000..59c5221b8705c --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/tr.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "alternative_integration": "Cihaz ba\u015fka bir entegrasyon taraf\u0131ndan daha iyi destekleniyor", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "could_not_connect": "DLNA cihaz\u0131na ba\u011flan\u0131lamad\u0131", + "discovery_error": "E\u015fle\u015fen bir DLNA cihaz\u0131 bulunamad\u0131", + "incomplete_config": "Yap\u0131land\u0131rmada gerekli bir de\u011fi\u015fken eksik", + "non_unique_id": "Ayn\u0131 benzersiz kimli\u011fe sahip birden fazla cihaz bulundu", + "not_dmr": "Cihaz, desteklenen bir Dijital Medya Olu\u015fturucu de\u011fil" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "could_not_connect": "DLNA cihaz\u0131na ba\u011flan\u0131lamad\u0131", + "not_dmr": "Cihaz, desteklenen bir Dijital Medya Olu\u015fturucu de\u011fil" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "import_turn_on": { + "description": "L\u00fctfen cihaz\u0131 a\u00e7\u0131n ve ta\u015f\u0131maya devam etmek i\u00e7in g\u00f6nder'i t\u0131klay\u0131n" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "Ayg\u0131t a\u00e7\u0131klamas\u0131 XML dosyas\u0131n\u0131n URL'si", + "title": "Manuel DLNA DMR ayg\u0131t ba\u011flant\u0131s\u0131" + }, + "user": { + "data": { + "host": "Sunucu", + "url": "URL" + }, + "description": "Yap\u0131land\u0131rmak i\u00e7in bir cihaz se\u00e7in veya bir URL girmek i\u00e7in bo\u015f b\u0131rak\u0131n", + "title": "Ke\u015ffedilen DLNA DMR cihazlar\u0131" + } + } + }, + "options": { + "error": { + "invalid_url": "Ge\u00e7ersiz URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Olay dinleyici geri \u00e7a\u011f\u0131rma URL'si", + "listen_port": "Olay dinleyici ba\u011flant\u0131 noktas\u0131 (ayarlanmam\u0131\u015fsa rastgele)", + "poll_availability": "Cihaz kullan\u0131labilirli\u011fi i\u00e7in anket" + }, + "title": "DLNA Dijital Medya \u0130\u015fleyici yap\u0131land\u0131rmas\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/zh-Hans.json b/homeassistant/components/dlna_dmr/translations/zh-Hans.json new file mode 100644 index 0000000000000..2046f1c2a47e3 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/zh-Hans.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", + "alternative_integration": "\u8be5\u8bbe\u5907\u5728\u53e6\u4e00\u96c6\u6210\u80fd\u63d0\u4f9b\u66f4\u597d\u7684\u652f\u6301", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "could_not_connect": "\u8fde\u63a5 DLNA \u8bbe\u5907\u5931\u8d25", + "discovery_error": "\u672a\u53d1\u73b0\u53ef\u7528\u7684 DLNA \u8bbe\u5907", + "incomplete_config": "\u914d\u7f6e\u7f3a\u5c11\u5fc5\u8981\u7684\u53d8\u91cf\u4fe1\u606f", + "non_unique_id": "\u53d1\u73b0\u591a\u53f0\u8bbe\u5907\u5177\u6709\u76f8\u540c\u7684 unique ID", + "not_dmr": "\u8be5\u8bbe\u5907\u4e0d\u662f\u53d7\u652f\u6301\u7684\u6570\u5b57\u5a92\u4f53\u6e32\u67d3\u5668\uff08DMR\uff09" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "could_not_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 DLNA \u8bbe\u5907", + "not_dmr": "\u8be5\u8bbe\u5907\u4e0d\u662f\u53d7\u652f\u6301\u7684\u6570\u5b57\u5a92\u4f53\u6e32\u67d3\u5668\uff08DMR\uff09" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u60a8\u8981\u5f00\u59cb\u8bbe\u7f6e\u5417\uff1f" + }, + "import_turn_on": { + "description": "\u8bf7\u6253\u5f00\u8bbe\u5907\uff0c\u7136\u540e\u70b9\u51fb\u201c\u63d0\u4ea4\u201d\u4ee5\u7ee7\u7eed\u8fc1\u79fb" + }, + "manual": { + "data": { + "url": "\u7f51\u5740" + }, + "description": "\u8bbe\u5907\u63cf\u8ff0 XML \u6587\u4ef6\u7f51\u5740", + "title": "\u624b\u52a8\u914d\u7f6e DLNA DMR \u8bbe\u5907\u8fde\u63a5" + }, + "user": { + "data": { + "host": "\u4e3b\u673a", + "url": "\u7f51\u5740" + }, + "title": "\u53d1\u73b0 DLNA DMR \u8bbe\u5907" + } + } + }, + "options": { + "error": { + "invalid_url": "\u65e0\u6548\u7f51\u5740" + }, + "step": { + "init": { + "data": { + "callback_url_override": "\u4e8b\u4ef6\u76d1\u542c\u5668\u7684\u56de\u8c03 URL", + "listen_port": "\u4e8b\u4ef6\u76d1\u542c\u5668\u7aef\u53e3\uff08\u5982\u4e0d\u6307\u5b9a\u5219\u968f\u673a\u7aef\u53e3\u53f7\uff09", + "poll_availability": "\u8f6e\u8be2\u8bbe\u5907\u53ef\u7528\u6027" + }, + "title": "DLNA \u6570\u5b57\u5a92\u4f53\u6e32\u67d3\u5668\u914d\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/zh-Hant.json b/homeassistant/components/dlna_dmr/translations/zh-Hant.json new file mode 100644 index 0000000000000..406b23b573f0e --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/zh-Hant.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "alternative_integration": "\u4f7f\u7528\u5176\u4ed6\u6574\u5408\u4ee5\u53d6\u5f97\u66f4\u4f73\u7684\u88dd\u7f6e\u652f\u63f4", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "could_not_connect": "DLNA \u88dd\u7f6e\u9023\u7dda\u5931\u6557\u3002", + "discovery_error": "DLNA \u88dd\u7f6e\u63a2\u7d22\u5931\u6557", + "incomplete_config": "\u6240\u7f3a\u5c11\u7684\u8a2d\u5b9a\u70ba\u5fc5\u9808\u8b8a\u6578", + "non_unique_id": "\u627e\u5230\u591a\u7d44\u88dd\u7f6e\u4f7f\u7528\u4e86\u76f8\u540c\u552f\u4e00 ID", + "not_dmr": "\u88dd\u7f6e\u70ba\u975e\u652f\u63f4 Digital Media Renderer" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "could_not_connect": "DLNA \u88dd\u7f6e\u9023\u7dda\u5931\u6557\u3002", + "not_dmr": "\u88dd\u7f6e\u70ba\u975e\u652f\u63f4 Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + }, + "import_turn_on": { + "description": "\u8acb\u958b\u555f\u88dd\u7f6e\u4e26\u9ede\u9078\u50b3\u9001\u4ee5\u7e7c\u7e8c\u9077\u79fb" + }, + "manual": { + "data": { + "url": "\u7db2\u5740" + }, + "description": "\u88dd\u7f6e\u8aaa\u660e XML \u6a94\u6848\u4e4b URL", + "title": "\u624b\u52d5 DLNA DMR \u88dd\u7f6e\u9023\u7dda" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "url": "\u7db2\u5740" + }, + "description": "\u9078\u64c7\u88dd\u7f6e\u9032\u884c\u8a2d\u5b9a\u6216\u4fdd\u7559\u7a7a\u767d\u4ee5\u8f38\u5165 URL", + "title": "\u5df2\u63a2\u7d22\u5230\u7684 DLNA DMR \u88dd\u7f6e" + } + } + }, + "options": { + "error": { + "invalid_url": "URL \u7121\u6548" + }, + "step": { + "init": { + "data": { + "callback_url_override": "\u4e8b\u4ef6\u76e3\u807d\u56de\u547c URL", + "listen_port": "\u4e8b\u4ef6\u76e3\u807d\u901a\u8a0a\u57e0\uff08\u672a\u8a2d\u7f6e\u5247\u70ba\u96a8\u6a5f\uff09", + "poll_availability": "\u67e5\u8a62\u88dd\u7f6e\u53ef\u7528\u6027" + }, + "title": "DLNA Digital Media Renderer \u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 2254314804bfe..2a277c3ceebf0 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -2,7 +2,7 @@ "domain": "dnsip", "name": "DNS IP", "documentation": "https://www.home-assistant.io/integrations/dnsip", - "requirements": ["aiodns==2.0.0"], + "requirements": ["aiodns==3.0.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 01d6e2f4f2aa9..a429d336379fb 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -1,4 +1,6 @@ """Get your own public IP address or that of any host.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -8,7 +10,10 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -36,57 +41,44 @@ ) -async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_devices: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the DNS IP sensor.""" hostname = config[CONF_HOSTNAME] name = config.get(CONF_NAME) - if not name: - if hostname == DEFAULT_HOSTNAME: - name = DEFAULT_NAME - else: - name = hostname ipv6 = config[CONF_IPV6] - if ipv6: - resolver = config[CONF_RESOLVER_IPV6] - else: - resolver = config[CONF_RESOLVER] - async_add_devices([WanIpSensor(hass, name, hostname, resolver, ipv6)], True) + if not name: + name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname + resolver = config[CONF_RESOLVER_IPV6] if ipv6 else config[CONF_RESOLVER] + + async_add_devices([WanIpSensor(name, hostname, resolver, ipv6)], True) class WanIpSensor(SensorEntity): """Implementation of a DNS IP sensor.""" - def __init__(self, hass, name, hostname, resolver, ipv6): + def __init__(self, name: str, hostname: str, resolver: str, ipv6: bool) -> None: """Initialize the DNS IP sensor.""" - - self.hass = hass - self._name = name + self._attr_name = name self.hostname = hostname self.resolver = aiodns.DNSResolver() self.resolver.nameservers = [resolver] self.querytype = "AAAA" if ipv6 else "A" - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the current DNS IP address for hostname.""" - return self._state - async def async_update(self): + async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" - try: response = await self.resolver.query(self.hostname, self.querytype) except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) response = None + if response: - self._state = response[0].host + self._attr_native_value = response[0].host else: - self._state = None + self._attr_native_value = None diff --git a/homeassistant/components/dominos/services.yaml b/homeassistant/components/dominos/services.yaml index 93f8b2851f17c..6a354bc3a63a3 100644 --- a/homeassistant/components/dominos/services.yaml +++ b/homeassistant/components/dominos/services.yaml @@ -1,6 +1,10 @@ order: + name: Order description: Places a set of orders with Dominos Pizza. fields: order_entity_id: + name: Order Entity description: The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed. example: dominos.medium_pan + selector: + text: diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 3e41c1871bfe1..cfc2b7c11c758 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -154,8 +154,7 @@ def __init__(self, hass, camera_entity, name, doods, detector, config): continue # If label confidence is not specified, use global confidence - label_confidence = label.get(CONF_CONFIDENCE) - if not label_confidence: + if not (label_confidence := label.get(CONF_CONFIDENCE)): label_confidence = confidence if label_name not in dconfig or dconfig[label_name] > label_confidence: dconfig[label_name] = label_confidence @@ -187,8 +186,7 @@ def __init__(self, hass, camera_entity, name, doods, detector, config): # Handle global detection area self._area = [0, 0, 1, 1] self._covers = True - area_config = config.get(CONF_AREA) - if area_config: + if area_config := config.get(CONF_AREA): self._area = [ area_config[CONF_TOP], area_config[CONF_LEFT], @@ -272,8 +270,7 @@ def _save_image(self, image, matches, paths): for path in paths: _LOGGER.info("Saving results image to %s", path) - if not os.path.exists(os.path.dirname(path)): - os.makedirs(os.path.dirname(path), exist_ok=True) + os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path) def process_image(self, image): diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 4e31ca03371b3..ae584af5916fb 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -2,7 +2,7 @@ "domain": "doods", "name": "DOODS - Dedicated Open Object Detection Service", "documentation": "https://www.home-assistant.io/integrations/doods", - "requirements": ["pydoods==1.0.2", "pillow==8.1.2"], + "requirements": ["pydoods==1.0.2", "pillow==8.2.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 30c2613f0d581..70198c2f2d5a1 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -1,4 +1,5 @@ """Support for DoorBird devices.""" +from http import HTTPStatus import logging from aiohttp import web @@ -15,13 +16,12 @@ CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, - HTTP_OK, - HTTP_UNAUTHORIZED, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import get_url +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, slugify from .const import ( @@ -55,10 +55,10 @@ } ) -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the DoorBird component.""" hass.data.setdefault(DOMAIN, {}) @@ -67,9 +67,7 @@ async def async_setup(hass: HomeAssistant, config: dict): def _reset_device_favorites_handler(event): """Handle clearing favorites on device.""" - token = event.data.get("token") - - if token is None: + if (token := event.data.get("token")) is None: return doorstation = get_doorstation_by_token(hass, token) @@ -90,7 +88,7 @@ def _reset_device_favorites_handler(event): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DoorBird from a config entry.""" _async_import_options_from_data_if_missing(hass, entry) @@ -107,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): try: status, info = await hass.async_add_executor_job(_init_doorbird_device, device) except requests.exceptions.HTTPError as err: - if err.response.status_code == HTTP_UNAUTHORIZED: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: _LOGGER.error( "Authorization rejected by DoorBird for %s@%s", username, device_ip ) @@ -153,7 +151,7 @@ def _init_doorbird_device(device): return device.ready(), device.info() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() @@ -195,7 +193,7 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): options = dict(entry.options) modified = False - for importable_option in [CONF_EVENTS]: + for importable_option in (CONF_EVENTS,): if importable_option not in entry.options and importable_option in entry.data: options[importable_option] = entry.data[importable_option] modified = True @@ -271,9 +269,7 @@ def _register_event(self, hass_url, event): if not self.webhook_is_registered(url): self.device.change_favorite("http", f"Home Assistant ({event})", url) - fav_id = self.get_webhook_id(url) - - if not fav_id: + if not self.get_webhook_id(url): _LOGGER.warning( 'Could not find favorite for URL "%s". ' 'Skipping sensor "%s"', url, @@ -323,6 +319,7 @@ class DoorBirdRequestView(HomeAssistantView): async def get(self, request, event): """Respond to requests from the device.""" + # pylint: disable=no-self-use hass = request.app["hass"] token = request.query.get("token") @@ -331,7 +328,7 @@ async def get(self, request, event): if device is None: return web.Response( - status=HTTP_UNAUTHORIZED, text="Invalid token provided." + status=HTTPStatus.UNAUTHORIZED, text="Invalid token provided." ) if device: @@ -343,7 +340,7 @@ async def get(self, request, event): hass.bus.async_fire(RESET_DEVICE_FAVORITES, {"token": token}) message = f"HTTP Favorites cleared for {device.slug}" - return web.Response(status=HTTP_OK, text=message) + return web.Response(text=message) event_data[ATTR_ENTITY_ID] = hass.data[DOMAIN][ DOOR_STATION_EVENT_ENTITY_IDS @@ -351,4 +348,4 @@ async def get(self, request, event): hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - return web.Response(status=HTTP_OK, text="OK") + return web.Response(text="OK") diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 53fcdbcee700c..8331570fd2f9e 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -1,4 +1,6 @@ """Support for viewing the camera feed from a DoorBird video doorbell.""" +from __future__ import annotations + import asyncio import datetime import logging @@ -112,7 +114,9 @@ def name(self): """Get the name of the camera.""" return self._name - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Pull a still image from the camera.""" now = dt_util.utcnow() @@ -121,7 +125,7 @@ async def async_camera_image(self): try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(_TIMEOUT): + async with async_timeout.timeout(_TIMEOUT): response = await websession.get(self._url) self._last_image = await response.read() diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 5d207fbbbced8..31ddd1f6193a4 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -1,4 +1,5 @@ """Config flow for DoorBird integration.""" +from http import HTTPStatus from ipaddress import ip_address import logging @@ -7,14 +8,10 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - HTTP_UNAUTHORIZED, -) +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.util.network import is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI @@ -45,7 +42,7 @@ async def validate_input(hass: core.HomeAssistant, data): try: status, info = await hass.async_add_executor_job(_check_device, device) except requests.exceptions.HTTPError as err: - if err.response.status_code == HTTP_UNAUTHORIZED: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: raise InvalidAuth from err raise CannotConnect from err except OSError as err: @@ -66,7 +63,7 @@ async def async_verify_supported_device(hass, host): try: await hass.async_add_executor_job(device.doorbell_state) except requests.exceptions.HTTPError as err: - if err.response.status_code == HTTP_UNAUTHORIZED: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: return True except OSError: return False @@ -95,24 +92,28 @@ async def async_step_user(self, user_input=None): data = self.discovery_schema or _schema_with_defaults() return self.async_show_form(step_id="user", data_schema=data, errors=errors) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered doorbird device.""" - macaddress = discovery_info["properties"]["macaddress"] - host = discovery_info[CONF_HOST] + macaddress = discovery_info.properties["macaddress"] + host = discovery_info.host if macaddress[:6] != DOORBIRD_OUI: return self.async_abort(reason="not_doorbird_device") if is_link_local(ip_address(host)): return self.async_abort(reason="link_local_address") - if not await async_verify_supported_device(self.hass, host): - return self.async_abort(reason="not_doorbird_device") await self.async_set_unique_id(macaddress) - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._async_abort_entries_match({CONF_HOST: host}) + + if not await async_verify_supported_device(self.hass, host): + return self.async_abort(reason="not_doorbird_device") + chop_ending = "._axis-video._tcp.local." - friendly_hostname = discovery_info["name"] + friendly_hostname = discovery_info.name if friendly_hostname.endswith(chop_ending): friendly_hostname = friendly_hostname[: -len(chop_ending)] @@ -149,7 +150,7 @@ def async_get_options_flow(config_entry): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for doorbird.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index 46a95f0d5009f..46c37e5b05054 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -1,8 +1,8 @@ """The DoorBird integration constants.""" - +from homeassistant.const import Platform DOMAIN = "doorbird" -PLATFORMS = ["switch", "camera"] +PLATFORMS = [Platform.SWITCH, Platform.CAMERA] DOOR_STATION = "door_station" DOOR_STATION_INFO = "door_station_info" DOOR_STATION_EVENT_ENTITY_IDS = "door_station_event_entity_ids" diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py index 44cbb1f42deee..2cf97aa4b5714 100644 --- a/homeassistant/components/doorbird/entity.py +++ b/homeassistant/components/doorbird/entity.py @@ -1,7 +1,7 @@ """The DoorBird integration base entity.""" from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( DOORBIRD_INFO_KEY_BUILD_NUMBER, @@ -23,14 +23,15 @@ def __init__(self, doorstation, doorstation_info): self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Doorbird device info.""" firmware = self._doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE] firmware_build = self._doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER] - return { - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_addr)}, - "name": self._doorstation.name, - "manufacturer": MANUFACTURER, - "sw_version": f"{firmware} {firmware_build}", - "model": self._doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], - } + return DeviceInfo( + configuration_url="https://webadmin.doorbird.com/", + connections={(dr.CONNECTION_NETWORK_MAC, self._mac_addr)}, + manufacturer=MANUFACTURER, + model=self._doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], + name=self._doorstation.name, + sw_version=f"{firmware} {firmware_build}", + ) diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 5dd9ecbd0db81..b379dab7e98a8 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "zeroconf": [ { "type": "_axis-video._tcp.local.", - "macaddress": "1CCAE3*" + "properties": {"macaddress": "1ccae3*"} } ], "codeowners": ["@oblogic7", "@bdraco"], diff --git a/homeassistant/components/doorbird/translations/bg.json b/homeassistant/components/doorbird/translations/bg.json new file mode 100644 index 0000000000000..628eaf62894fc --- /dev/null +++ b/homeassistant/components/doorbird/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/ca.json b/homeassistant/components/doorbird/translations/ca.json index 2adc6227ab4a9..880360234b1eb 100644 --- a/homeassistant/components/doorbird/translations/ca.json +++ b/homeassistant/components/doorbird/translations/ca.json @@ -10,7 +10,7 @@ "invalid_auth": "[%key::common::config_flow::error::invalid_auth%]", "unknown": "[%key::common::config_flow::error::unknown%]" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/de.json b/homeassistant/components/doorbird/translations/de.json index 0d6bef7a63fb4..3f025e673862d 100644 --- a/homeassistant/components/doorbird/translations/de.json +++ b/homeassistant/components/doorbird/translations/de.json @@ -7,10 +7,10 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifikation", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { @@ -19,7 +19,7 @@ "password": "Passwort", "username": "Benutzername" }, - "title": "Stellen Sie eine Verbindung zu DoorBird her" + "title": "Stelle eine Verbindung zu DoorBird her" } } }, @@ -29,7 +29,7 @@ "data": { "events": "Durch Kommas getrennte Liste von Ereignissen." }, - "description": "F\u00fcgen Sie f\u00fcr jedes Ereignis, das Sie verfolgen m\u00f6chten, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem Sie sie hier eingegeben haben, verwenden Sie die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen finden Sie in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung" + "description": "F\u00fcge f\u00fcr jedes Ereignis, das du verfolgen m\u00f6chtest, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem du sie hier eingegeben hast, verwende die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen findest du in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung" } } } diff --git a/homeassistant/components/doorbird/translations/et.json b/homeassistant/components/doorbird/translations/et.json index 2d967a09cda01..08b163c0ca2f1 100644 --- a/homeassistant/components/doorbird/translations/et.json +++ b/homeassistant/components/doorbird/translations/et.json @@ -10,7 +10,7 @@ "invalid_auth": "Tuvastamine nurjus", "unknown": "Tundmatu viga" }, - "flow_title": "", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/fr.json b/homeassistant/components/doorbird/translations/fr.json index fd8bf04d29e32..68165d762f9a3 100644 --- a/homeassistant/components/doorbird/translations/fr.json +++ b/homeassistant/components/doorbird/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce DoorBird est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "link_local_address": "Les adresses locales ne sont pas prises en charge", "not_doorbird_device": "Cet appareil n'est pas un DoorBird" }, @@ -10,14 +10,14 @@ "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom de l'appareil", "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous au DoorBird" } diff --git a/homeassistant/components/doorbird/translations/he.json b/homeassistant/components/doorbird/translations/he.json index f08cbbdff1106..5143667adfb34 100644 --- a/homeassistant/components/doorbird/translations/he.json +++ b/homeassistant/components/doorbird/translations/he.json @@ -1,11 +1,18 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05e6\u05e4\u05d5\u05d9\u05d9\u05d4" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { + "host": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } diff --git a/homeassistant/components/doorbird/translations/hu.json b/homeassistant/components/doorbird/translations/hu.json index 3f74783b7ac78..48a124b4f1775 100644 --- a/homeassistant/components/doorbird/translations/hu.json +++ b/homeassistant/components/doorbird/translations/hu.json @@ -1,22 +1,35 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "link_local_address": "A linkek helyi c\u00edmei nem t\u00e1mogatottak", + "not_doorbird_device": "Ez az eszk\u00f6z nem DoorBird" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Eszk\u00f6z neve", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a DoorBird-hez" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Vessz\u0151vel elv\u00e1lasztott esem\u00e9nyek list\u00e1ja." + }, + "description": "Adjon hozz\u00e1 vessz\u0151vel elv\u00e1lasztott esem\u00e9nynevet minden k\u00f6vetni k\u00edv\u00e1nt esem\u00e9nyhez. Miut\u00e1n itt megadta \u0151ket, haszn\u00e1lja a DoorBird alkalmaz\u00e1st, hogy hozz\u00e1rendelje \u0151ket egy adott esem\u00e9nyhez. Tekintse meg a dokument\u00e1ci\u00f3t a https://www.home-assistant.io/integrations/doorbird/#events c\u00edmen. P\u00e9lda: valaki_pr\u00e9selt_gomb, mozg\u00e1s" } } } diff --git a/homeassistant/components/doorbird/translations/id.json b/homeassistant/components/doorbird/translations/id.json index f708780ce311f..60348ec26a198 100644 --- a/homeassistant/components/doorbird/translations/id.json +++ b/homeassistant/components/doorbird/translations/id.json @@ -10,7 +10,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/it.json b/homeassistant/components/doorbird/translations/it.json index 51b45cb79bb27..4a39ef36f3c3d 100644 --- a/homeassistant/components/doorbird/translations/it.json +++ b/homeassistant/components/doorbird/translations/it.json @@ -10,7 +10,7 @@ "invalid_auth": "Autenticazione non valida", "unknown": "Errore imprevisto" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { @@ -29,7 +29,7 @@ "data": { "events": "Elenco di eventi separati da virgole." }, - "description": "Aggiungere un nome di evento separato da virgola per ogni evento che si desidera monitorare. Dopo averli inseriti qui, usa l'applicazione DoorBird per assegnarli a un evento specifico. Consultare la documentazione su https://www.home-assistant.io/integrations/doorbird/#events. Esempio: qualcuno_premuto_il_pulsante, movimento" + "description": "Aggiungere un nome di evento separato da virgola per ogni evento che desideri monitorare. Dopo averli inseriti qui, usa l'applicazione DoorBird per assegnarli a un evento specifico. Consulta la documentazione su https://www.home-assistant.io/integrations/doorbird/#events. Esempio: qualcuno_premuto_il_pulsante, movimento" } } } diff --git a/homeassistant/components/doorbird/translations/ja.json b/homeassistant/components/doorbird/translations/ja.json new file mode 100644 index 0000000000000..179edc8943ca4 --- /dev/null +++ b/homeassistant/components/doorbird/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "link_local_address": "\u30ed\u30fc\u30ab\u30eb\u30a2\u30c9\u30ec\u30b9\u306e\u30ea\u30f3\u30af\u306b\u306f\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093", + "not_doorbird_device": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u3001DoorBird\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u30c7\u30d0\u30a4\u30b9\u540d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "DoorBird\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "\u30a4\u30d9\u30f3\u30c8\u306e\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u3002" + }, + "description": "\u8ffd\u8de1\u3059\u308b\u30a4\u30d9\u30f3\u30c8\u3054\u3068\u306b\u3001\u30b3\u30f3\u30de\u533a\u5207\u308a\u3067\u30a4\u30d9\u30f3\u30c8\u540d\u3092\u8ffd\u52a0\u3057\u307e\u3059\u3002\u3053\u3053\u306b\u5165\u529b\u3057\u305f\u5f8c\u3001DoorBird\u30a2\u30d7\u30ea\u3092\u4f7f\u7528\u3057\u3066\u7279\u5b9a\u306e\u30a4\u30d9\u30f3\u30c8\u306b\u5272\u308a\u5f53\u3066\u307e\u3059\u3002https://www.home-assistant.io/integrations/doorbird/#events. \u306e\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4f8b: somebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/nl.json b/homeassistant/components/doorbird/translations/nl.json index 1c43ee2d9c28c..db32d96d83115 100644 --- a/homeassistant/components/doorbird/translations/nl.json +++ b/homeassistant/components/doorbird/translations/nl.json @@ -10,7 +10,7 @@ "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/no.json b/homeassistant/components/doorbird/translations/no.json index b2a8928dc4d43..356c86a8b542e 100644 --- a/homeassistant/components/doorbird/translations/no.json +++ b/homeassistant/components/doorbird/translations/no.json @@ -10,7 +10,7 @@ "invalid_auth": "Ugyldig godkjenning", "unknown": "Uventet feil" }, - "flow_title": "", + "flow_title": "{name} ( {host} )", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/pl.json b/homeassistant/components/doorbird/translations/pl.json index 79cdb5f5c8beb..f55d2d406f2bb 100644 --- a/homeassistant/components/doorbird/translations/pl.json +++ b/homeassistant/components/doorbird/translations/pl.json @@ -10,7 +10,7 @@ "invalid_auth": "Niepoprawne uwierzytelnienie", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/ru.json b/homeassistant/components/doorbird/translations/ru.json index 4d5695a3ab236..df156bb640adc 100644 --- a/homeassistant/components/doorbird/translations/ru.json +++ b/homeassistant/components/doorbird/translations/ru.json @@ -10,7 +10,7 @@ "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/doorbird/translations/tr.json b/homeassistant/components/doorbird/translations/tr.json index d7a1ca8a93a9f..1bb08b6f81ee7 100644 --- a/homeassistant/components/doorbird/translations/tr.json +++ b/homeassistant/components/doorbird/translations/tr.json @@ -1,21 +1,35 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "link_local_address": "Ba\u011flant\u0131 yerel adresleri desteklenmiyor", + "not_doorbird_device": "Bu cihaz bir DoorBird de\u011fil" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "name": "Cihaz ad\u0131", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "DoorBird'e ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Virg\u00fclle ayr\u0131lm\u0131\u015f olaylar\u0131n listesi." + }, + "description": "\u0130zlemek istedi\u011finiz her etkinlik i\u00e7in virg\u00fclle ayr\u0131lm\u0131\u015f bir etkinlik ad\u0131 ekleyin. Bunlar\u0131 buraya girdikten sonra, onlar\u0131 belirli bir etkinli\u011fe atamak i\u00e7in DoorBird uygulamas\u0131n\u0131 kullan\u0131n. https://www.home-assistant.io/integrations/doorbird/#events adresindeki belgelere bak\u0131n. \u00d6rnek: birisi_pressed_the_button, hareket" } } } diff --git a/homeassistant/components/doorbird/translations/zh-Hant.json b/homeassistant/components/doorbird/translations/zh-Hant.json index b475a474ed932..020267b4921e6 100644 --- a/homeassistant/components/doorbird/translations/zh-Hant.json +++ b/homeassistant/components/doorbird/translations/zh-Hant.json @@ -10,7 +10,7 @@ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py index 02ce994b1df95..c599ad918e8dd 100644 --- a/homeassistant/components/dovado/notify.py +++ b/homeassistant/components/dovado/notify.py @@ -22,9 +22,7 @@ def __init__(self, client): def send_message(self, message, **kwargs): """Send SMS to the specified target phone number.""" - target = kwargs.get(ATTR_TARGET) - - if not target: + if not (target := kwargs.get(ATTR_TARGET)): _LOGGER.error("One target is required") return diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index e7b3dbdd36308..180a886740fb8 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -1,10 +1,17 @@ """Support for sensors from the Dovado router.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import re import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES, PERCENTAGE import homeassistant.helpers.config_validation as cv @@ -18,26 +25,59 @@ SENSOR_NETWORK = "network" SENSOR_SMS_UNREAD = "sms" -SENSORS = { - SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), - SENSOR_SIGNAL: ( - "signal strength", - "Signal Strength", - PERCENTAGE, - "mdi:signal", + +@dataclass +class DovadoRequiredKeysMixin: + """Mixin for required keys.""" + + identifier: str + + +@dataclass +class DovadoSensorEntityDescription(SensorEntityDescription, DovadoRequiredKeysMixin): + """Describes Dovado sensor entity.""" + + +SENSOR_TYPES: tuple[DovadoSensorEntityDescription, ...] = ( + DovadoSensorEntityDescription( + identifier=SENSOR_NETWORK, + key="signal strength", + name="Network", + icon="mdi:access-point-network", + ), + DovadoSensorEntityDescription( + identifier=SENSOR_SIGNAL, + key="signal strength", + name="Signal Strength", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:signal", ), - SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), - SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"), - SENSOR_DOWNLOAD: ( - "traffic modem rx", - "Received", - DATA_GIGABYTES, - "mdi:cloud-download", + DovadoSensorEntityDescription( + identifier=SENSOR_SMS_UNREAD, + key="sms unread", + name="SMS unread", + icon="mdi:message-text-outline", ), -} + DovadoSensorEntityDescription( + identifier=SENSOR_UPLOAD, + key="traffic modem tx", + name="Sent", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:cloud-upload", + ), + DovadoSensorEntityDescription( + identifier=SENSOR_DOWNLOAD, + key="traffic modem rx", + name="Received", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:cloud-download", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)])} + {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)])} ) @@ -45,63 +85,50 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Dovado sensor platform.""" dovado = hass.data[DOVADO_DOMAIN] - entities = [] - for sensor in config[CONF_SENSORS]: - entities.append(DovadoSensor(dovado, sensor)) - + sensors = config[CONF_SENSORS] + entities = [ + DovadoSensor(dovado, description) + for description in SENSOR_TYPES + if description.key in sensors + ] add_entities(entities) class DovadoSensor(SensorEntity): """Representation of a Dovado sensor.""" - def __init__(self, data, sensor): + entity_description: DovadoSensorEntityDescription + + def __init__(self, data, description: DovadoSensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._data = data - self._sensor = sensor - self._state = self._compute_state() + + self._attr_name = f"{data.name} {description.name}" + self._attr_native_value = self._compute_state() def _compute_state(self): """Compute the state of the sensor.""" - state = self._data.state.get(SENSORS[self._sensor][0]) - if self._sensor == SENSOR_NETWORK: + state = self._data.state.get(self.entity_description.key) + sensor_identifier = self.entity_description.identifier + if sensor_identifier == SENSOR_NETWORK: match = re.search(r"\((.+)\)", state) return match.group(1) if match else None - if self._sensor == SENSOR_SIGNAL: + if sensor_identifier == SENSOR_SIGNAL: try: return int(state.split()[0]) except ValueError: return None - if self._sensor == SENSOR_SMS_UNREAD: + if sensor_identifier == SENSOR_SMS_UNREAD: return int(state) - if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + if sensor_identifier in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: return round(float(state) / 1e6, 1) return state def update(self): """Update sensor values.""" self._data.update() - self._state = self._compute_state() - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data.name} {SENSORS[self._sensor][1]}" - - @property - def state(self): - """Return the sensor state.""" - return self._state - - @property - def icon(self): - """Return the icon for the sensor.""" - return SENSORS[self._sensor][3] - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSORS[self._sensor][2] + self._attr_native_value = self._compute_state() @property def extra_state_attributes(self): diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 89aa4a465cfef..8753d3e06f1c1 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -1,4 +1,5 @@ """Support for functionality to download files.""" +from http import HTTPStatus import logging import os import re @@ -7,7 +8,6 @@ import requests import voluptuous as vol -from homeassistant.const import HTTP_OK import homeassistant.helpers.config_validation as cv from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -78,7 +78,7 @@ def do_download(): req = requests.get(url, stream=True, timeout=10) - if req.status_code != HTTP_OK: + if req.status_code != HTTPStatus.OK: _LOGGER.warning( "Downloading '%s' failed, status_code=%d", url, req.status_code ) @@ -110,8 +110,7 @@ def do_download(): subdir_path = os.path.join(download_path, subdir) # Ensure subdir exist - if not os.path.isdir(subdir_path): - os.makedirs(subdir_path) + os.makedirs(subdir_path, exist_ok=True) final_path = os.path.join(subdir_path, filename) diff --git a/homeassistant/components/dsmr/__init__.py b/homeassistant/components/dsmr/__init__.py index 3af620df19ca0..0e238363fc042 100644 --- a/homeassistant/components/dsmr/__init__.py +++ b/homeassistant/components/dsmr/__init__.py @@ -5,26 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DATA_LISTENER, DATA_TASK, DOMAIN, PLATFORMS +from .const import DATA_TASK, DOMAIN, PLATFORMS -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DSMR from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = {} hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - listener = entry.add_update_listener(async_update_options) - hass.data[DOMAIN][entry.entry_id][DATA_LISTENER] = listener + entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" task = hass.data[DOMAIN][entry.entry_id][DATA_TASK] - listener = hass.data[DOMAIN][entry.entry_id][DATA_LISTENER] # Cancel the reconnect task task.cancel() @@ -33,13 +30,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - listener() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry): +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" - await hass.config_entries.async_reload(config_entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index bdaaea22f64c1..587d51d13c7d5 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -3,18 +3,22 @@ import asyncio from functools import partial -import logging +import os from typing import Any from async_timeout import timeout from dsmr_parser import obis_references as obis_ref from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader +from dsmr_parser.objects import DSMRObject import serial +import serial.tools.list_ports import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_DSMR_VERSION, @@ -23,44 +27,53 @@ CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE, DOMAIN, + DSMR_VERSIONS, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) +CONF_MANUAL_PATH = "Enter Manually" class DSMRConnection: """Test the connection to DSMR and receive telegram to read serial ids.""" - def __init__(self, host, port, dsmr_version): + def __init__(self, host: str | None, port: int, dsmr_version: str) -> None: """Initialize.""" self._host = host self._port = port self._dsmr_version = dsmr_version - self._telegram = {} + self._telegram: dict[str, DSMRObject] = {} + self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER if dsmr_version == "5L": self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER - else: - self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER - def equipment_identifier(self): + def equipment_identifier(self) -> str | None: """Equipment identifier.""" if self._equipment_identifier in self._telegram: dsmr_object = self._telegram[self._equipment_identifier] - return getattr(dsmr_object, "value", None) + identifier: str | None = getattr(dsmr_object, "value", None) + return identifier + return None - def equipment_identifier_gas(self): + def equipment_identifier_gas(self) -> str | None: """Equipment identifier gas.""" if obis_ref.EQUIPMENT_IDENTIFIER_GAS in self._telegram: dsmr_object = self._telegram[obis_ref.EQUIPMENT_IDENTIFIER_GAS] - return getattr(dsmr_object, "value", None) + identifier: str | None = getattr(dsmr_object, "value", None) + return identifier + return None async def validate_connect(self, hass: core.HomeAssistant) -> bool: """Test if we can validate connection with the device.""" - def update_telegram(telegram): + def update_telegram(telegram: dict[str, DSMRObject]) -> None: if self._equipment_identifier in telegram: self._telegram = telegram transport.close() + # Swedish meters have no equipment identifier + if self._dsmr_version == "5S" and obis_ref.P1_MESSAGE_TIMESTAMP in telegram: + self._telegram = telegram + transport.close() if self._host is None: reader_factory = partial( @@ -83,7 +96,7 @@ def update_telegram(telegram): try: transport, protocol = await asyncio.create_task(reader_factory()) except (serial.serialutil.SerialException, OSError): - _LOGGER.exception("Error connecting to DSMR") + LOGGER.exception("Error connecting to DSMR") return False if transport: @@ -97,7 +110,9 @@ def update_telegram(telegram): return True -async def _validate_dsmr_connection(hass: core.HomeAssistant, data): +async def _validate_dsmr_connection( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str | None]: """Validate the user input allows us to connect.""" conn = DSMRConnection(data.get(CONF_HOST), data[CONF_PORT], data[CONF_DSMR_VERSION]) @@ -108,35 +123,35 @@ async def _validate_dsmr_connection(hass: core.HomeAssistant, data): equipment_identifier_gas = conn.equipment_identifier_gas() # Check only for equipment identifier in case no gas meter is connected - if equipment_identifier is None: + if equipment_identifier is None and data[CONF_DSMR_VERSION] != "5S": raise CannotCommunicate - info = { + return { CONF_SERIAL_ID: equipment_identifier, CONF_SERIAL_ID_GAS: equipment_identifier_gas, } - return info - class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for DSMR.""" VERSION = 1 + _dsmr_version: str | None = None + @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> DSMROptionFlowHandler: """Get the options flow for this handler.""" return DSMROptionFlowHandler(config_entry) def _abort_if_host_port_configured( self, port: str, - host: str = None, + host: str | None = None, updates: dict[Any, Any] | None = None, reload_on_update: bool = True, - ): + ) -> FlowResult | None: """Test if host and port are already configured.""" for entry in self._async_current_entries(): if entry.data.get(CONF_HOST) == host and entry.data[CONF_PORT] == port: @@ -149,8 +164,8 @@ def _abort_if_host_port_configured( and reload_on_update and entry.state in ( - config_entries.ENTRY_STATE_LOADED, - config_entries.ENTRY_STATE_SETUP_RETRY, + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.SETUP_RETRY, ) ): self.hass.async_create_task( @@ -160,43 +175,144 @@ def _abort_if_host_port_configured( return None - async def async_step_import(self, import_config=None): - """Handle the initial step.""" - host = import_config.get(CONF_HOST) - port = import_config[CONF_PORT] + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when user initializes a integration.""" + if user_input is not None: + user_selection = user_input[CONF_TYPE] + if user_selection == "Serial": + return await self.async_step_setup_serial() - status = self._abort_if_host_port_configured(port, host, import_config) - if status is not None: - return status + return await self.async_step_setup_network() - try: - info = await _validate_dsmr_connection(self.hass, import_config) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - except CannotCommunicate: - return self.async_abort(reason="cannot_communicate") + list_of_types = ["Serial", "Network"] - if host is not None: - name = f"{host}:{port}" - else: - name = port + schema = vol.Schema({vol.Required(CONF_TYPE): vol.In(list_of_types)}) + return self.async_show_form(step_id="user", data_schema=schema) - data = {**import_config, **info} + async def async_step_setup_network( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when setting up network configuration.""" + errors: dict[str, str] = {} + if user_input is not None: + data = await self.async_validate_dsmr(user_input, errors) + if not errors: + return self.async_create_entry( + title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data + ) + + schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), + } + ) + return self.async_show_form( + step_id="setup_network", + data_schema=schema, + errors=errors, + ) - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured(data) + async def async_step_setup_serial( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when setting up serial configuration.""" + errors: dict[str, str] = {} + if user_input is not None: + user_selection = user_input[CONF_PORT] + if user_selection == CONF_MANUAL_PATH: + self._dsmr_version = user_input[CONF_DSMR_VERSION] + return await self.async_step_setup_serial_manual_path() - return self.async_create_entry(title=name, data=data) + dev_path = await self.hass.async_add_executor_job( + get_serial_by_id, user_selection + ) + + validate_data = { + CONF_PORT: dev_path, + CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION], + } + + data = await self.async_validate_dsmr(validate_data, errors) + if not errors: + return self.async_create_entry(title=data[CONF_PORT], data=data) + + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = { + port.device: f"{port}, s/n: {port.serial_number or 'n/a'}" + + (f" - {port.manufacturer}" if port.manufacturer else "") + for port in ports + } + list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH + + schema = vol.Schema( + { + vol.Required(CONF_PORT): vol.In(list_of_ports), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), + } + ) + return self.async_show_form( + step_id="setup_serial", + data_schema=schema, + errors=errors, + ) + + async def async_step_setup_serial_manual_path( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select path manually.""" + if user_input is not None: + validate_data = { + CONF_PORT: user_input[CONF_PORT], + CONF_DSMR_VERSION: self._dsmr_version, + } + + errors: dict[str, str] = {} + data = await self.async_validate_dsmr(validate_data, errors) + if not errors: + return self.async_create_entry(title=data[CONF_PORT], data=data) + + schema = vol.Schema({vol.Required(CONF_PORT): str}) + return self.async_show_form( + step_id="setup_serial_manual_path", + data_schema=schema, + ) + + async def async_validate_dsmr( + self, input_data: dict[str, Any], errors: dict[str, str] + ) -> dict[str, Any]: + """Validate dsmr connection and create data.""" + data = input_data + + try: + info = await _validate_dsmr_connection(self.hass, data) + + data = {**data, **info} + + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured() + except CannotConnect: + errors["base"] = "cannot_connect" + except CannotCommunicate: + errors["base"] = "cannot_communicate" + + return data class DSMROptionFlowHandler(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry + self.entry = entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -207,7 +323,7 @@ async def async_step_init(self, user_input=None): { vol.Optional( CONF_TIME_BETWEEN_UPDATE, - default=self.config_entry.options.get( + default=self.entry.options.get( CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE ), ): vol.All(vol.Coerce(int), vol.Range(min=0)), @@ -216,6 +332,18 @@ async def async_step_init(self, user_input=None): ) +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path + + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path + + class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index da804857845ca..a8c4de930dfa9 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -1,9 +1,21 @@ """Constants for the DSMR integration.""" +from __future__ import annotations + +import logging + +from dsmr_parser import obis_references + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import Platform +from homeassistant.helpers.entity import EntityCategory + +from .models import DSMRSensorEntityDescription DOMAIN = "dsmr" -PLATFORMS = ["sensor"] +LOGGER = logging.getLogger(__package__) +PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" @@ -18,13 +30,282 @@ DEFAULT_RECONNECT_INTERVAL = 30 DEFAULT_TIME_BETWEEN_UPDATE = 30 -DATA_LISTENER = "listener" DATA_TASK = "task" DEVICE_NAME_ENERGY = "Energy Meter" DEVICE_NAME_GAS = "Gas Meter" -ICON_GAS = "mdi:fire" -ICON_POWER = "mdi:flash" -ICON_POWER_FAILURE = "mdi:flash-off" -ICON_SWELL_SAG = "mdi:pulse" +DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S"} + +SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key=obis_references.CURRENT_ELECTRICITY_USAGE, + name="Power Consumption", + device_class=SensorDeviceClass.POWER, + force_update=True, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.CURRENT_ELECTRICITY_DELIVERY, + name="Power Production", + device_class=SensorDeviceClass.POWER, + force_update=True, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_ACTIVE_TARIFF, + name="Power Tariff", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + icon="mdi:flash", + ), + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_USED_TARIFF_1, + name="Energy Consumption (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + device_class=SensorDeviceClass.ENERGY, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_USED_TARIFF_2, + name="Energy Consumption (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, + name="Energy Production (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, + name="Energy Production (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, + name="Power Consumption Phase L1", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, + name="Power Consumption Phase L2", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, + name="Power Consumption Phase L3", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, + name="Power Production Phase L1", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, + name="Power Production Phase L2", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, + name="Power Production Phase L3", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRSensorEntityDescription( + key=obis_references.SHORT_POWER_FAILURE_COUNT, + name="Short Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + icon="mdi:flash-off", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.LONG_POWER_FAILURE_COUNT, + name="Long Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + icon="mdi:flash-off", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SAG_L1_COUNT, + name="Voltage Sags Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SAG_L2_COUNT, + name="Voltage Sags Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SAG_L3_COUNT, + name="Voltage Sags Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SWELL_L1_COUNT, + name="Voltage Swells Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + icon="mdi:pulse", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SWELL_L2_COUNT, + name="Voltage Swells Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + icon="mdi:pulse", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SWELL_L3_COUNT, + name="Voltage Swells Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + entity_registry_enabled_default=False, + icon="mdi:pulse", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_VOLTAGE_L1, + name="Voltage Phase L1", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_VOLTAGE_L2, + name="Voltage Phase L2", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_VOLTAGE_L3, + name="Voltage Phase L3", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_CURRENT_L1, + name="Current Phase L1", + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_CURRENT_L2, + name="Current Phase L2", + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_CURRENT_L3, + name="Current Phase L3", + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, + name="Energy Consumption (total)", + dsmr_versions={"5L"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + name="Energy Production (total)", + dsmr_versions={"5L"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + name="Energy Consumption (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + name="Energy Production (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_IMPORTED_TOTAL, + name="Energy Consumption (total)", + dsmr_versions={"2.2", "4", "5", "5B"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.HOURLY_GAS_METER_READING, + name="Gas Consumption", + dsmr_versions={"4", "5", "5L"}, + is_gas=True, + force_update=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.BELGIUM_HOURLY_GAS_METER_READING, + name="Gas Consumption", + dsmr_versions={"5B"}, + is_gas=True, + force_update=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.GAS_METER_READING, + name="Gas Consumption", + dsmr_versions={"2.2"}, + is_gas=True, + force_update=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index a5c9b8e62bced..fbbfac559597c 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -2,8 +2,8 @@ "domain": "dsmr", "name": "DSMR Slimme Meter", "documentation": "https://www.home-assistant.io/integrations/dsmr", - "requirements": ["dsmr_parser==0.29"], - "codeowners": ["@Robbie1221"], - "config_flow": false, + "requirements": ["dsmr_parser==0.30"], + "codeowners": ["@Robbie1221", "@frenck"], + "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/components/dsmr/models.py b/homeassistant/components/dsmr/models.py new file mode 100644 index 0000000000000..e7b47d8b74d12 --- /dev/null +++ b/homeassistant/components/dsmr/models.py @@ -0,0 +1,14 @@ +"""Models for the DSMR integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class DSMRSensorEntityDescription(SensorEntityDescription): + """Represents an DSMR Sensor.""" + + dsmr_versions: set[str] | None = None + is_gas: bool = False diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 237f3b2f9299d..94ae2864905bd 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -6,24 +6,24 @@ from contextlib import suppress from datetime import timedelta from functools import partial -import logging from dsmr_parser import obis_references as obis_ref from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader +from dsmr_parser.objects import DSMRObject import serial -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, - TIME_HOURS, + VOLUME_CUBIC_METERS, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import EventType, StateType from homeassistant.util import Throttle from .const import ( @@ -34,186 +34,55 @@ CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, DATA_TASK, - DEFAULT_DSMR_VERSION, - DEFAULT_PORT, DEFAULT_PRECISION, DEFAULT_RECONNECT_INTERVAL, DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ENERGY, DEVICE_NAME_GAS, DOMAIN, - ICON_GAS, - ICON_POWER, - ICON_POWER_FAILURE, - ICON_SWELL_SAG, + LOGGER, + SENSORS, ) +from .models import DSMRSensorEntityDescription -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(["5L", "5B", "5", "4", "2.2"]) - ), - vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, - vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import the platform into a config entry.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) +UNIT_CONVERSION = {"m3": VOLUME_CUBIC_METERS} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the DSMR sensor.""" - config = entry.data - options = entry.options - - dsmr_version = config[CONF_DSMR_VERSION] - - # Define list of name,obis,force_update mappings to generate entities - obis_mapping = [ - ["Power Consumption", obis_ref.CURRENT_ELECTRICITY_USAGE, True], - ["Power Production", obis_ref.CURRENT_ELECTRICITY_DELIVERY, True], - ["Power Tariff", obis_ref.ELECTRICITY_ACTIVE_TARIFF, False], - ["Energy Consumption (tarif 1)", obis_ref.ELECTRICITY_USED_TARIFF_1, True], - ["Energy Consumption (tarif 2)", obis_ref.ELECTRICITY_USED_TARIFF_2, True], - ["Energy Production (tarif 1)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1, True], - ["Energy Production (tarif 2)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_2, True], - [ - "Power Consumption Phase L1", - obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, - False, - ], - [ - "Power Consumption Phase L2", - obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, - False, - ], - [ - "Power Consumption Phase L3", - obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, - False, - ], - [ - "Power Production Phase L1", - obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, - False, - ], - [ - "Power Production Phase L2", - obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, - False, - ], - [ - "Power Production Phase L3", - obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, - False, - ], - ["Short Power Failure Count", obis_ref.SHORT_POWER_FAILURE_COUNT, False], - ["Long Power Failure Count", obis_ref.LONG_POWER_FAILURE_COUNT, False], - ["Voltage Sags Phase L1", obis_ref.VOLTAGE_SAG_L1_COUNT, False], - ["Voltage Sags Phase L2", obis_ref.VOLTAGE_SAG_L2_COUNT, False], - ["Voltage Sags Phase L3", obis_ref.VOLTAGE_SAG_L3_COUNT, False], - ["Voltage Swells Phase L1", obis_ref.VOLTAGE_SWELL_L1_COUNT, False], - ["Voltage Swells Phase L2", obis_ref.VOLTAGE_SWELL_L2_COUNT, False], - ["Voltage Swells Phase L3", obis_ref.VOLTAGE_SWELL_L3_COUNT, False], - ["Voltage Phase L1", obis_ref.INSTANTANEOUS_VOLTAGE_L1, False], - ["Voltage Phase L2", obis_ref.INSTANTANEOUS_VOLTAGE_L2, False], - ["Voltage Phase L3", obis_ref.INSTANTANEOUS_VOLTAGE_L3, False], - ["Current Phase L1", obis_ref.INSTANTANEOUS_CURRENT_L1, False], - ["Current Phase L2", obis_ref.INSTANTANEOUS_CURRENT_L2, False], - ["Current Phase L3", obis_ref.INSTANTANEOUS_CURRENT_L3, False], - ] - - if dsmr_version == "5L": - obis_mapping.extend( - [ - [ - "Energy Consumption (total)", - obis_ref.LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, - True, - ], - [ - "Energy Production (total)", - obis_ref.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, - True, - ], - ] - ) - else: - obis_mapping.extend( - [["Energy Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL, True]] - ) - - # Generate device entities - devices = [ - DSMREntity( - name, DEVICE_NAME_ENERGY, config[CONF_SERIAL_ID], obis, config, force_update + dsmr_version = entry.data[CONF_DSMR_VERSION] + entities = [ + DSMREntity(description, entry) + for description in SENSORS + if ( + description.dsmr_versions is None + or dsmr_version in description.dsmr_versions ) - for name, obis, force_update in obis_mapping + and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) ] - - # Protocol version specific obis - if CONF_SERIAL_ID_GAS in config: - if dsmr_version in ("4", "5", "5L"): - gas_obis = obis_ref.HOURLY_GAS_METER_READING - elif dsmr_version in ("5B",): - gas_obis = obis_ref.BELGIUM_HOURLY_GAS_METER_READING - else: - gas_obis = obis_ref.GAS_METER_READING - - # Add gas meter reading and derivative for usage - devices += [ - DSMREntity( - "Gas Consumption", - DEVICE_NAME_GAS, - config[CONF_SERIAL_ID_GAS], - gas_obis, - config, - True, - ), - DerivativeDSMREntity( - "Hourly Gas Consumption", - DEVICE_NAME_GAS, - config[CONF_SERIAL_ID_GAS], - gas_obis, - config, - False, - ), - ] - - async_add_entities(devices) + async_add_entities(entities) min_time_between_updates = timedelta( - seconds=options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) + seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) ) @Throttle(min_time_between_updates) - def update_entities_telegram(telegram): + def update_entities_telegram(telegram: dict[str, DSMRObject]) -> None: """Update entities with latest telegram and trigger state update.""" # Make all device entities aware of new telegram - for device in devices: - device.update_data(telegram) + for entity in entities: + entity.update_data(telegram) # Creates an asyncio.Protocol factory for reading DSMR telegrams from # serial and calls update_entities_telegram to update entities on arrival - if CONF_HOST in config: + if CONF_HOST in entry.data: reader_factory = partial( create_tcp_dsmr_reader, - config[CONF_HOST], - config[CONF_PORT], - config[CONF_DSMR_VERSION], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + dsmr_version, update_entities_telegram, loop=hass.loop, keep_alive_interval=60, @@ -221,34 +90,41 @@ def update_entities_telegram(telegram): else: reader_factory = partial( create_dsmr_reader, - config[CONF_PORT], - config[CONF_DSMR_VERSION], + entry.data[CONF_PORT], + dsmr_version, update_entities_telegram, loop=hass.loop, ) - async def connect_and_reconnect(): + async def connect_and_reconnect() -> None: """Connect to DSMR and keep reconnecting until Home Assistant stops.""" stop_listener = None transport = None protocol = None - while hass.state != CoreState.stopping: + while hass.state == CoreState.not_running or hass.is_running: # Start DSMR asyncio.Protocol reader try: transport, protocol = await hass.loop.create_task(reader_factory()) if transport: # Register listener to close transport on HA shutdown + @callback + def close_transport(_event: EventType) -> None: + """Close the transport on HA shutdown.""" + if not transport: + return + transport.close() + stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, transport.close + EVENT_HOMEASSISTANT_STOP, close_transport ) # Wait for reader to close await protocol.wait_closed() # Unexpected disconnect - if not hass.is_stopping: + if hass.state == CoreState.not_running or hass.is_running: stop_listener() transport = None @@ -259,16 +135,25 @@ async def connect_and_reconnect(): update_entities_telegram({}) # throttle reconnect attempts - await asyncio.sleep(config[CONF_RECONNECT_INTERVAL]) + await asyncio.sleep( + entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) + ) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait - _LOGGER.exception("Error connecting to DSMR") + LOGGER.exception("Error connecting to DSMR") transport = None protocol = None + + # throttle reconnect attempts + await asyncio.sleep( + entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) + ) except CancelledError: - if stop_listener: + if stop_listener and ( + hass.state == CoreState.not_running or hass.is_running + ): stop_listener() # pylint: disable=not-callable if transport: @@ -289,62 +174,64 @@ async def connect_and_reconnect(): class DSMREntity(SensorEntity): """Entity reading values from DSMR telegram.""" - def __init__(self, name, device_name, device_serial, obis, config, force_update): - """Initialize entity.""" - self._name = name - self._obis = obis - self._config = config - self.telegram = {} + entity_description: DSMRSensorEntityDescription + _attr_should_poll = False - self._device_name = device_name - self._device_serial = device_serial - self._force_update = force_update - self._unique_id = f"{device_serial}_{name}".replace(" ", "_") + def __init__( + self, entity_description: DSMRSensorEntityDescription, entry: ConfigEntry + ) -> None: + """Initialize entity.""" + self.entity_description = entity_description + self._entry = entry + self.telegram: dict[str, DSMRObject] = {} + + device_serial = entry.data[CONF_SERIAL_ID] + device_name = DEVICE_NAME_ENERGY + if entity_description.is_gas: + device_serial = entry.data[CONF_SERIAL_ID_GAS] + device_name = DEVICE_NAME_GAS + if device_serial is None: + device_serial = entry.entry_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_serial)}, + name=device_name, + ) + self._attr_unique_id = f"{device_serial}_{entity_description.name}".replace( + " ", "_" + ) @callback - def update_data(self, telegram): + def update_data(self, telegram: dict[str, DSMRObject]) -> None: """Update data.""" self.telegram = telegram - if self.hass and self._obis in self.telegram: + if self.hass and self.entity_description.key in self.telegram: self.async_write_ha_state() - def get_dsmr_object_attr(self, attribute): + def get_dsmr_object_attr(self, attribute: str) -> str | None: """Read attribute from last received telegram for this DSMR object.""" # Make sure telegram contains an object for this entities obis - if self._obis not in self.telegram: + if self.entity_description.key not in self.telegram: return None # Get the attribute value if the object has it - dsmr_object = self.telegram[self._obis] - return getattr(dsmr_object, attribute, None) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - if "Sags" in self._name or "Swells" in self.name: - return ICON_SWELL_SAG - if "Failure" in self._name: - return ICON_POWER_FAILURE - if "Power" in self._name: - return ICON_POWER - if "Gas" in self._name: - return ICON_GAS + dsmr_object = self.telegram[self.entity_description.key] + attr: str | None = getattr(dsmr_object, attribute) + return attr @property - def state(self): + def native_value(self) -> StateType: """Return the state of sensor, if available, translate if needed.""" - value = self.get_dsmr_object_attr("value") + if (value := self.get_dsmr_object_attr("value")) is None: + return None - if self._obis == obis_ref.ELECTRICITY_ACTIVE_TARIFF: - return self.translate_tariff(value, self._config[CONF_DSMR_VERSION]) + if self.entity_description.key == obis_ref.ELECTRICITY_ACTIVE_TARIFF: + return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round(float(value), self._config[CONF_PRECISION]) + value = round( + float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) + ) if value is not None: return value @@ -352,39 +239,19 @@ def state(self): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" - return self.get_dsmr_object_attr("unit") - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return { - "identifiers": {(DOMAIN, self._device_serial)}, - "name": self._device_name, - } - - @property - def force_update(self): - """Force update.""" - return self._force_update - - @property - def should_poll(self): - """Disable polling.""" - return False + unit_of_measurement = self.get_dsmr_object_attr("unit") + if unit_of_measurement in UNIT_CONVERSION: + return UNIT_CONVERSION[unit_of_measurement] + return unit_of_measurement @staticmethod - def translate_tariff(value, dsmr_version): + def translate_tariff(value: str, dsmr_version: str) -> str | None: """Convert 2/1 to normal/low depending on DSMR version.""" # DSMR V5B: Note: In Belgium values are swapped: # Rate code 2 is used for low rate and rate code 1 is used for normal rate. - if dsmr_version in ("5B",): + if dsmr_version == "5B": if value == "0001": value = "0002" elif value == "0002": @@ -397,66 +264,3 @@ def translate_tariff(value, dsmr_version): return "low" return None - - -class DerivativeDSMREntity(DSMREntity): - """Calculated derivative for values where the DSMR doesn't offer one. - - Gas readings are only reported per hour and don't offer a rate only - the current meter reading. This entity converts subsequents readings - into a hourly rate. - """ - - _previous_reading = None - _previous_timestamp = None - _state = None - - @property - def state(self): - """Return the calculated current hourly rate.""" - return self._state - - @property - def force_update(self): - """Disable force update.""" - return False - - @property - def should_poll(self): - """Enable polling.""" - return True - - async def async_update(self): - """Recalculate hourly rate if timestamp has changed. - - DSMR updates gas meter reading every hour. Along with the new - value a timestamp is provided for the reading. Test if the last - known timestamp differs from the current one then calculate a - new rate for the previous hour. - - """ - # check if the timestamp for the object differs from the previous one - timestamp = self.get_dsmr_object_attr("datetime") - if timestamp and timestamp != self._previous_timestamp: - current_reading = self.get_dsmr_object_attr("value") - - if self._previous_reading is None: - # Can't calculate rate without previous datapoint - # just store current point - pass - else: - # Recalculate the rate - diff = current_reading - self._previous_reading - timediff = timestamp - self._previous_timestamp - total_seconds = timediff.total_seconds() - self._state = round(float(diff) / total_seconds * 3600, 3) - - self._previous_reading = current_reading - self._previous_timestamp = timestamp - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, per hour, if any.""" - unit = self.get_dsmr_object_attr("unit") - if unit: - return f"{unit}/{TIME_HOURS}" diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 57d38f78febef..cc9cd2ae86ae7 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -1,9 +1,43 @@ { "config": { - "step": {}, - "error": {}, + "step": { + "user": { + "data": { + "type": "Connection type" + }, + "title": "Select connection type" + }, + "setup_network": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "dsmr_version": "Select DSMR version" + }, + "title": "Select connection address" + }, + "setup_serial": { + "data": { + "port": "Select device", + "dsmr_version": "Select DSMR version" + }, + "title": "Device" + }, + "setup_serial_manual_path": { + "data": { + "port": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Path" + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_communicate": "Failed to communicate" + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_communicate": "Failed to communicate" } }, "options": { diff --git a/homeassistant/components/dsmr/translations/bg.json b/homeassistant/components/dsmr/translations/bg.json new file mode 100644 index 0000000000000..153afa164a2d0 --- /dev/null +++ b/homeassistant/components/dsmr/translations/bg.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_communicate": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430 \u043a\u043e\u043c\u0443\u043d\u0438\u043a\u0430\u0446\u0438\u044f", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "error": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_communicate": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430 \u043a\u043e\u043c\u0443\u043d\u0438\u043a\u0430\u0446\u0438\u044f", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 DSMR \u0432\u0435\u0440\u0441\u0438\u044f", + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "setup_serial": { + "data": { + "dsmr_version": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 DSMR \u0432\u0435\u0440\u0441\u0438\u044f", + "port": "\u0418\u0437\u0431\u043e\u0440 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "setup_serial_manual_path": { + "title": "\u041f\u044a\u0442" + }, + "user": { + "data": { + "type": "\u0422\u0438\u043f \u0432\u0440\u044a\u0437\u043a\u0430" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u043d\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/ca.json b/homeassistant/components/dsmr/translations/ca.json index a876776fea297..1d61426ebeac7 100644 --- a/homeassistant/components/dsmr/translations/ca.json +++ b/homeassistant/components/dsmr/translations/ca.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_communicate": "No s'ha pogut comunicar", + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "error": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_communicate": "No s'ha pogut comunicar", + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "Selecciona la versi\u00f3 DSMR", + "host": "Amfitri\u00f3", + "port": "Port" + }, + "title": "Selecciona l'adre\u00e7a de connexi\u00f3" + }, + "setup_serial": { + "data": { + "dsmr_version": "Selecciona la versi\u00f3 DSMR", + "port": "Selecciona el dispositiu" + }, + "title": "Dispositiu" + }, + "setup_serial_manual_path": { + "data": { + "port": "Ruta del dispositiu USB" + }, + "title": "Ruta" + }, + "user": { + "data": { + "type": "Tipus de connexi\u00f3" + }, + "title": "Selecciona el tipus de connexi\u00f3" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/cs.json b/homeassistant/components/dsmr/translations/cs.json index 9b38d280bdf17..8078da1b1a213 100644 --- a/homeassistant/components/dsmr/translations/cs.json +++ b/homeassistant/components/dsmr/translations/cs.json @@ -2,6 +2,23 @@ "config": { "abort": { "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "step": { + "setup_serial": { + "data": { + "port": "Vyberte za\u0159\u00edzen\u00ed" + }, + "title": "Za\u0159\u00edzen\u00ed" + }, + "setup_serial_manual_path": { + "title": "Cesta" + }, + "user": { + "data": { + "type": "Typ p\u0159ipojen\u00ed" + }, + "title": "Vyberte typ p\u0159ipojen\u00ed" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/de.json b/homeassistant/components/dsmr/translations/de.json index 97d6739b787c4..fe94109634af2 100644 --- a/homeassistant/components/dsmr/translations/de.json +++ b/homeassistant/components/dsmr/translations/de.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_communicate": "Kommunikation fehlgeschlagen", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "error": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_communicate": "Kommunikation fehlgeschlagen", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "DSMR-Version ausw\u00e4hlen", + "host": "Host", + "port": "Port" + }, + "title": "Verbindungsadresse ausw\u00e4hlen" + }, + "setup_serial": { + "data": { + "dsmr_version": "DSMR-Version ausw\u00e4hlen", + "port": "Ger\u00e4t w\u00e4hlen" + }, + "title": "Ger\u00e4t" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB-Ger\u00e4te-Pfad" + }, + "title": "Pfad" + }, + "user": { + "data": { + "type": "Verbindungstyp" + }, + "title": "Verbindungstyp ausw\u00e4hlen" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/en.json b/homeassistant/components/dsmr/translations/en.json index 159ede41b4ecf..6f873729bc84b 100644 --- a/homeassistant/components/dsmr/translations/en.json +++ b/homeassistant/components/dsmr/translations/en.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "cannot_communicate": "Failed to communicate", + "cannot_connect": "Failed to connect" + }, + "error": { + "already_configured": "Device is already configured", + "cannot_communicate": "Failed to communicate", + "cannot_connect": "Failed to connect" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "Select DSMR version", + "host": "Host", + "port": "Port" + }, + "title": "Select connection address" + }, + "setup_serial": { + "data": { + "dsmr_version": "Select DSMR version", + "port": "Select device" + }, + "title": "Device" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB Device Path" + }, + "title": "Path" + }, + "user": { + "data": { + "type": "Connection type" + }, + "title": "Select connection type" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/es-419.json b/homeassistant/components/dsmr/translations/es-419.json new file mode 100644 index 0000000000000..82e9427b1717a --- /dev/null +++ b/homeassistant/components/dsmr/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "setup_serial": { + "title": "Dispositivo" + }, + "setup_serial_manual_path": { + "title": "Ruta" + }, + "user": { + "data": { + "type": "Tipo de conecci\u00f3n" + }, + "title": "Seleccione el tipo de conexi\u00f3n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "Tiempo m\u00ednimo entre actualizaciones de entidad [s]" + }, + "title": "Opciones DSMR" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/es.json b/homeassistant/components/dsmr/translations/es.json index a85293e93a0d3..880f378c7c340 100644 --- a/homeassistant/components/dsmr/translations/es.json +++ b/homeassistant/components/dsmr/translations/es.json @@ -1,11 +1,45 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_communicate": "No se ha podido comunicar", + "cannot_connect": "No se pudo conectar" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_communicate": "No se ha podido comunicar", + "cannot_connect": "No se pudo conectar" }, "step": { "one": "Vac\u00edo", - "other": "Vac\u00edo" + "other": "Vac\u00edo", + "setup_network": { + "data": { + "dsmr_version": "Seleccione la versi\u00f3n de DSMR", + "host": "Host", + "port": "Puerto" + }, + "title": "Seleccione la direcci\u00f3n de la conexi\u00f3n" + }, + "setup_serial": { + "data": { + "dsmr_version": "Seleccione la versi\u00f3n de DSMR", + "port": "Seleccione el dispositivo" + }, + "title": "Dispositivo" + }, + "setup_serial_manual_path": { + "data": { + "port": "Ruta del dispositivo USB" + }, + "title": "Ruta" + }, + "user": { + "data": { + "type": "Tipo de conexi\u00f3n" + }, + "title": "Seleccione el tipo de conexi\u00f3n" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/et.json b/homeassistant/components/dsmr/translations/et.json index 67f37f2658677..5e9131621d3fc 100644 --- a/homeassistant/components/dsmr/translations/et.json +++ b/homeassistant/components/dsmr/translations/et.json @@ -1,15 +1,47 @@ { "config": { "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_communicate": "\u00dchendamine nurjus", + "cannot_connect": "\u00dchendamine nurjus" }, "error": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_communicate": "\u00dchendamine nurjus", + "cannot_connect": "\u00dchendamine nurjus", "one": "\u00fcks", "other": "mitu" }, "step": { "one": "\u00fcks", - "other": "mitu" + "other": "mitu", + "setup_network": { + "data": { + "dsmr_version": "Vali DSMR versioon", + "host": "Host", + "port": "Port" + }, + "title": "Vali \u00fchenduse aadress" + }, + "setup_serial": { + "data": { + "dsmr_version": "Vali DSMR versioon", + "port": "Vali seade" + }, + "title": "Seade" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB-seadme asukoha rada" + }, + "title": "Rada" + }, + "user": { + "data": { + "type": "\u00dchenduse t\u00fc\u00fcp" + }, + "title": "Vali \u00fchenduse t\u00fc\u00fcp" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/fr.json b/homeassistant/components/dsmr/translations/fr.json index d156aee8ca0aa..0f348acdbff5b 100644 --- a/homeassistant/components/dsmr/translations/fr.json +++ b/homeassistant/components/dsmr/translations/fr.json @@ -1,15 +1,47 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_communicate": "\u00c9chec de la communication", + "cannot_connect": "\u00c9chec de connexion" }, "error": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_communicate": "\u00c9chec de la communication", + "cannot_connect": "\u00c9chec de connexion", "one": "Vide", "other": "Vide" }, "step": { "one": "", - "other": "Autre" + "other": "Autre", + "setup_network": { + "data": { + "dsmr_version": "S\u00e9lectionner la version DSMR", + "host": "H\u00f4te", + "port": "Port" + }, + "title": "S\u00e9lectionner l'adresse de connexion" + }, + "setup_serial": { + "data": { + "dsmr_version": "S\u00e9lectionner la version DSMR", + "port": "S\u00e9lectionner un appareil" + }, + "title": "Appareil" + }, + "setup_serial_manual_path": { + "data": { + "port": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "title": "Chemin" + }, + "user": { + "data": { + "type": "Type de connexion" + }, + "title": "S\u00e9lectionner le type de connexion" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/he.json b/homeassistant/components/dsmr/translations/he.json new file mode 100644 index 0000000000000..8e2195dbc4293 --- /dev/null +++ b/homeassistant/components/dsmr/translations/he.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "setup_network": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + }, + "setup_serial": { + "data": { + "port": "\u05d1\u05d7\u05e8 \u05d4\u05ea\u05e7\u05df" + }, + "title": "\u05d4\u05ea\u05e7\u05df" + }, + "setup_serial_manual_path": { + "data": { + "port": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + }, + "title": "\u05e0\u05ea\u05d9\u05d1" + }, + "user": { + "data": { + "type": "\u05e1\u05d5\u05d2 \u05d7\u05d9\u05d1\u05d5\u05e8" + }, + "title": "\u05d1\u05d7\u05e8 \u05e1\u05d5\u05d2 \u05d7\u05d9\u05d1\u05d5\u05e8" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/hu.json b/homeassistant/components/dsmr/translations/hu.json index 930b739fb18db..1bca962e2f5a9 100644 --- a/homeassistant/components/dsmr/translations/hu.json +++ b/homeassistant/components/dsmr/translations/hu.json @@ -1,7 +1,47 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_communicate": "Nem siker\u00fclt csatlakozni.", + "cannot_connect": "Nem siker\u00fclt csatlakozni" + }, + "error": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_communicate": "Nem siker\u00fclt kommunik\u00e1lni", + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "one": "\u00dcres", + "other": "\u00dcres" + }, + "step": { + "one": "\u00dcres", + "other": "\u00dcres", + "setup_network": { + "data": { + "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa", + "host": "C\u00edm", + "port": "Port" + }, + "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" + }, + "setup_serial": { + "data": { + "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa", + "port": "Eszk\u00f6z kiv\u00e1laszt\u00e1sa" + }, + "title": "Eszk\u00f6z" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB eszk\u00f6z \u00fatvonala" + }, + "title": "\u00datvonal" + }, + "user": { + "data": { + "type": "Kapcsolat t\u00edpusa" + }, + "title": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/id.json b/homeassistant/components/dsmr/translations/id.json index fd8299d61eddf..2c1eeccca17e7 100644 --- a/homeassistant/components/dsmr/translations/id.json +++ b/homeassistant/components/dsmr/translations/id.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_communicate": "Gagal berkomunikasi", + "cannot_connect": "Gagal terhubung" + }, + "error": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_communicate": "Gagal berkomunikasi", + "cannot_connect": "Gagal terhubung" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "Pilih versi DSMR", + "host": "Host", + "port": "Port" + }, + "title": "Pilih alamat koneksi" + }, + "setup_serial": { + "data": { + "dsmr_version": "Pilih versi DSMR", + "port": "Pilih perangkat" + }, + "title": "Perangkat" + }, + "setup_serial_manual_path": { + "data": { + "port": "Jalur Perangkat USB" + }, + "title": "Jalur" + }, + "user": { + "data": { + "type": "Jenis koneksi" + }, + "title": "Pilih jenis koneksi" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/it.json b/homeassistant/components/dsmr/translations/it.json index 75cbb713056bc..9b50979d016d9 100644 --- a/homeassistant/components/dsmr/translations/it.json +++ b/homeassistant/components/dsmr/translations/it.json @@ -1,7 +1,47 @@ { "config": { "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_communicate": "Impossibile comunicare", + "cannot_connect": "Impossibile connettersi" + }, + "error": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_communicate": "Impossibile comunicare", + "cannot_connect": "Impossibile connettersi", + "one": "Pi\u00f9", + "other": "Altri" + }, + "step": { + "one": "Pi\u00f9", + "other": "Altri", + "setup_network": { + "data": { + "dsmr_version": "Seleziona la versione DSMR", + "host": "Host", + "port": "Porta" + }, + "title": "Seleziona l'indirizzo di connessione" + }, + "setup_serial": { + "data": { + "dsmr_version": "Seleziona la versione DSMR", + "port": "Seleziona il dispositivo" + }, + "title": "Dispositivo" + }, + "setup_serial_manual_path": { + "data": { + "port": "Percorso del dispositivo USB" + }, + "title": "Percorso" + }, + "user": { + "data": { + "type": "Tipo di connessione" + }, + "title": "Seleziona il tipo di connessione" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/ja.json b/homeassistant/components/dsmr/translations/ja.json new file mode 100644 index 0000000000000..53c7d5c105057 --- /dev/null +++ b/homeassistant/components/dsmr/translations/ja.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_communicate": "\u901a\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_communicate": "\u901a\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "DSMR\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u9078\u629e", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u63a5\u7d9a\u30a2\u30c9\u30ec\u30b9\u306e\u9078\u629e" + }, + "setup_serial": { + "data": { + "dsmr_version": "DSMR\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u9078\u629e", + "port": "\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e" + }, + "title": "\u30c7\u30d0\u30a4\u30b9" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "title": "\u30d1\u30b9" + }, + "user": { + "data": { + "type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + }, + "title": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u306e\u9078\u629e" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u66f4\u65b0\u306e\u6700\u5c0f\u6642\u9593[\u79d2]" + }, + "title": "DSMR\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/nl.json b/homeassistant/components/dsmr/translations/nl.json index ba31fa36fd2db..ed6171279f14e 100644 --- a/homeassistant/components/dsmr/translations/nl.json +++ b/homeassistant/components/dsmr/translations/nl.json @@ -1,15 +1,47 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "cannot_communicate": "Kon niet verbinden.", + "cannot_connect": "Kan geen verbinding maken" }, "error": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_communicate": "Kon niet verbinden.", + "cannot_connect": "Kan geen verbinding maken", "one": "Leeg", "other": "Leeg" }, "step": { "one": "Leeg", - "other": "Leeg" + "other": "Leeg", + "setup_network": { + "data": { + "dsmr_version": "Selecteer DSMR-versie", + "host": "Host", + "port": "Poort" + }, + "title": "Selecteer verbindingsadres" + }, + "setup_serial": { + "data": { + "dsmr_version": "Selecteer DSMR-versie", + "port": "Selecteer apparaat" + }, + "title": "Apparaat" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB-apparaatpad" + }, + "title": "Pad" + }, + "user": { + "data": { + "type": "Verbindingstype" + }, + "title": "Selecteer verbindingstype" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/no.json b/homeassistant/components/dsmr/translations/no.json index e51520bf73012..ef9d2798f2b13 100644 --- a/homeassistant/components/dsmr/translations/no.json +++ b/homeassistant/components/dsmr/translations/no.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "cannot_communicate": "Kunne ikke kommunisere", + "cannot_connect": "Tilkobling mislyktes" + }, + "error": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_communicate": "Kunne ikke kommunisere", + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "Velg DSMR-versjon", + "host": "Vert", + "port": "Port" + }, + "title": "Velg tilkoblingsadresse" + }, + "setup_serial": { + "data": { + "dsmr_version": "Velg DSMR-versjon", + "port": "Velg enhet" + }, + "title": "Enhet" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB enhetsbane" + }, + "title": "Bane" + }, + "user": { + "data": { + "type": "Tilkoblingstype" + }, + "title": "Velg tilkoblingstype" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/pl.json b/homeassistant/components/dsmr/translations/pl.json index e8b8bf617f0a6..84a04cff625ca 100644 --- a/homeassistant/components/dsmr/translations/pl.json +++ b/homeassistant/components/dsmr/translations/pl.json @@ -1,9 +1,14 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_communicate": "Nie uda\u0142o si\u0119 nawi\u0105za\u0107 \u0142\u0105czno\u015bci", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, "error": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_communicate": "Nie uda\u0142o si\u0119 nawi\u0105za\u0107 \u0142\u0105czno\u015bci", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "few": "kilka", "many": "wiele", "one": "jeden", @@ -13,7 +18,34 @@ "few": "kilka", "many": "wiele", "one": "jeden", - "other": "inne" + "other": "inne", + "setup_network": { + "data": { + "dsmr_version": "Wybierz wersj\u0119 DSMR", + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "title": "Wybierz adres dla po\u0142\u0105czenia" + }, + "setup_serial": { + "data": { + "dsmr_version": "Wybierz wersj\u0119 DSMR", + "port": "Wybierz urz\u0105dzenie" + }, + "title": "Urz\u0105dzenie" + }, + "setup_serial_manual_path": { + "data": { + "port": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "title": "\u015acie\u017cka" + }, + "user": { + "data": { + "type": "Rodzaj po\u0142\u0105czenia" + }, + "title": "Wybierz typ po\u0142\u0105czenia" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/ru.json b/homeassistant/components/dsmr/translations/ru.json index 3bf0cf9f06f43..0ee5d2731569f 100644 --- a/homeassistant/components/dsmr/translations/ru.json +++ b/homeassistant/components/dsmr/translations/ru.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "cannot_communicate": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0441\u0432\u044f\u0437\u0430\u0442\u044c\u0441\u044f.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "error": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "cannot_communicate": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0441\u0432\u044f\u0437\u0430\u0442\u044c\u0441\u044f.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0432\u0435\u0440\u0441\u0438\u044e DSMR", + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f" + }, + "setup_serial": { + "data": { + "dsmr_version": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0432\u0435\u0440\u0441\u0438\u044e DSMR", + "port": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "setup_serial_manual_path": { + "data": { + "port": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "title": "\u041f\u0443\u0442\u044c" + }, + "user": { + "data": { + "type": "\u0422\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/tr.json b/homeassistant/components/dsmr/translations/tr.json index 0857160dc51b3..2bdcd58a8659a 100644 --- a/homeassistant/components/dsmr/translations/tr.json +++ b/homeassistant/components/dsmr/translations/tr.json @@ -1,7 +1,47 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_communicate": "\u0130leti\u015fim kurulamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "error": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_communicate": "\u0130leti\u015fim kurulamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "step": { + "one": "Bo\u015f", + "other": "Bo\u015f", + "setup_network": { + "data": { + "dsmr_version": "DSMR s\u00fcr\u00fcm\u00fcn\u00fc se\u00e7in", + "host": "Sunucu", + "port": "Port" + }, + "title": "Ba\u011flant\u0131 adresini se\u00e7in" + }, + "setup_serial": { + "data": { + "dsmr_version": "DSMR s\u00fcr\u00fcm\u00fcn\u00fc se\u00e7in", + "port": "Cihaz se\u00e7" + }, + "title": "Cihaz" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB Cihaz Yolu" + }, + "title": "Yol" + }, + "user": { + "data": { + "type": "Ba\u011flant\u0131 t\u00fcr\u00fc" + }, + "title": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/zh-Hant.json b/homeassistant/components/dsmr/translations/zh-Hant.json index 52e77cd352008..9d95685a87fa3 100644 --- a/homeassistant/components/dsmr/translations/zh-Hant.json +++ b/homeassistant/components/dsmr/translations/zh-Hant.json @@ -1,7 +1,43 @@ { "config": { "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_communicate": "\u901a\u8a0a\u5931\u6557", + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "error": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_communicate": "\u901a\u8a0a\u5931\u6557", + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "\u9078\u64c7 DSM \u7248\u672c", + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + }, + "title": "\u9078\u64c7\u9023\u7dda\u4f4d\u5740" + }, + "setup_serial": { + "data": { + "dsmr_version": "\u9078\u64c7 DSM \u7248\u672c", + "port": "\u9078\u64c7\u88dd\u7f6e" + }, + "title": "\u88dd\u7f6e" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "title": "\u8def\u5f91" + }, + "user": { + "data": { + "type": "\u9023\u7dda\u985e\u578b" + }, + "title": "\u9078\u64c7\u9023\u7dda\u985e\u578b" + } } }, "options": { diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index daf6b9eb950a8..9f4ee7ed918d0 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,18 +1,27 @@ """Definitions for DSMR Reader sensors added to MQTT.""" +from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( CURRENCY_EURO, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_KILO_WATT, - VOLT, VOLUME_CUBIC_METERS, ) +from homeassistant.util import dt as dt_util + +PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}" +PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}" def dsmr_transform(value): @@ -29,456 +38,523 @@ def tariff_transform(value): return "high" -DEFINITIONS = { - "dsmr/reading/electricity_delivered_1": { - "name": "Low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_returned_1": { - "name": "Low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_delivered_2": { - "name": "High tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_returned_2": { - "name": "High tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_currently_delivered": { - "name": "Current power usage", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/electricity_currently_returned": { - "name": "Current power return", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l1": { - "name": "Current power usage L1", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l2": { - "name": "Current power usage L2", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l3": { - "name": "Current power usage L3", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l1": { - "name": "Current power return L1", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l2": { - "name": "Current power return L2", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l3": { - "name": "Current power return L3", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/extra_device_delivered": { - "name": "Gas meter usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/reading/phase_voltage_l1": { - "name": "Current voltage L1", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, - }, - "dsmr/reading/phase_voltage_l2": { - "name": "Current voltage L2", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, - }, - "dsmr/reading/phase_voltage_l3": { - "name": "Current voltage L3", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, - }, - "dsmr/reading/phase_power_current_l1": { - "name": "Phase power current L1", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, - }, - "dsmr/reading/phase_power_current_l2": { - "name": "Phase power current L2", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, - }, - "dsmr/reading/phase_power_current_l3": { - "name": "Phase power current L3", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, - }, - "dsmr/reading/timestamp": { - "name": "Telegram timestamp", - "enable_default": False, - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "dsmr/consumption/gas/delivered": { - "name": "Gas usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/consumption/gas/currently_delivered": { - "name": "Current gas usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/consumption/gas/read_at": { - "name": "Gas meter read", - "enable_default": True, - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "dsmr/day-consumption/electricity1": { - "name": "Low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity2": { - "name": "High tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity1_returned": { - "name": "Low tariff return", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity2_returned": { - "name": "High tariff return", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity_merged": { - "name": "Power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity_returned_merged": { - "name": "Power return total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity1_cost": { - "name": "Low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/electricity2_cost": { - "name": "High tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/electricity_cost_merged": { - "name": "Power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/gas": { - "name": "Gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/day-consumption/gas_cost": { - "name": "Gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/total_cost": { - "name": "Total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_delivered_1": { - "name": "Low tariff delivered price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_delivered_2": { - "name": "High tariff delivered price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_returned_1": { - "name": "Low tariff returned price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_returned_2": { - "name": "High tariff returned price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_gas": { - "name": "Gas price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/meter-stats/dsmr_version": { - "name": "DSMR version", - "enable_default": True, - "icon": "mdi:alert-circle", - "transform": dsmr_transform, - }, - "dsmr/meter-stats/electricity_tariff": { - "name": "Electricity tariff", - "enable_default": True, - "icon": "mdi:flash", - "transform": tariff_transform, - }, - "dsmr/meter-stats/power_failure_count": { - "name": "Power failure count", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/long_power_failure_count": { - "name": "Long power failure count", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l1": { - "name": "Voltage sag L1", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l2": { - "name": "Voltage sag L2", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l3": { - "name": "Voltage sag L3", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l1": { - "name": "Voltage swell L1", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l2": { - "name": "Voltage swell L2", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l3": { - "name": "Voltage swell L3", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/rejected_telegrams": { - "name": "Rejected telegrams", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/current-month/electricity1": { - "name": "Current month low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity2": { - "name": "Current month high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity1_returned": { - "name": "Current month low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity2_returned": { - "name": "Current month high tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity_merged": { - "name": "Current month power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity_returned_merged": { - "name": "Current month power return total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity1_cost": { - "name": "Current month low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/electricity2_cost": { - "name": "Current month high tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/electricity_cost_merged": { - "name": "Current month power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/gas": { - "name": "Current month gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/current-month/gas_cost": { - "name": "Current month gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/fixed_cost": { - "name": "Current month fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/total_cost": { - "name": "Current month total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity1": { - "name": "Current year low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity2": { - "name": "Current year high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity1_returned": { - "name": "Current year low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity2_returned": { - "name": "Current year high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity_merged": { - "name": "Current year power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity_returned_merged": { - "name": "Current year power returned total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity1_cost": { - "name": "Current year low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity2_cost": { - "name": "Current year high tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity_cost_merged": { - "name": "Current year power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/gas": { - "name": "Current year gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/current-year/gas_cost": { - "name": "Current year gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/fixed_cost": { - "name": "Current year fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/total_cost": { - "name": "Current year total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, -} +@dataclass +class DSMRReaderSensorEntityDescription(SensorEntityDescription): + """Sensor entity description for DSMR Reader.""" + + state: Callable | None = None + + +SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_delivered_1", + name="Low tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_returned_1", + name="Low tariff returned", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_delivered_2", + name="High tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_returned_2", + name="High tariff returned", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_currently_delivered", + name="Current power usage", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_currently_returned", + name="Current power return", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l1", + name="Current power usage L1", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l2", + name="Current power usage L2", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l3", + name="Current power usage L3", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l1", + name="Current power return L1", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l2", + name="Current power return L2", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l3", + name="Current power return L3", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/extra_device_delivered", + name="Gas meter usage", + entity_registry_enabled_default=False, + icon="mdi:fire", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l1", + name="Current voltage L1", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l2", + name="Current voltage L2", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l3", + name="Current voltage L3", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l1", + name="Phase power current L1", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l2", + name="Phase power current L2", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l3", + name="Phase power current L3", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/timestamp", + name="Telegram timestamp", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + state=dt_util.parse_datetime, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/delivered", + name="Gas usage", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/currently_delivered", + name="Current gas usage", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=SensorStateClass.MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/read_at", + name="Gas meter read", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + state=dt_util.parse_datetime, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1", + name="Low tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2", + name="High tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1_returned", + name="Low tariff return", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2_returned", + name="High tariff return", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_merged", + name="Power usage total", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_returned_merged", + name="Power return total", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1_cost", + name="Low tariff cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2_cost", + name="High tariff cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_cost_merged", + name="Power total cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/gas", + name="Gas usage", + icon="mdi:counter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/gas_cost", + name="Gas cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/total_cost", + name="Total cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", + name="Low tariff delivered price", + icon="mdi:currency-eur", + native_unit_of_measurement=PRICE_EUR_KWH, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", + name="High tariff delivered price", + icon="mdi:currency-eur", + native_unit_of_measurement=PRICE_EUR_KWH, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", + name="Low tariff returned price", + icon="mdi:currency-eur", + native_unit_of_measurement=PRICE_EUR_KWH, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", + name="High tariff returned price", + icon="mdi:currency-eur", + native_unit_of_measurement=PRICE_EUR_KWH, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_gas", + name="Gas price", + icon="mdi:currency-eur", + native_unit_of_measurement=PRICE_EUR_M3, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/fixed_cost", + name="Current day fixed cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/dsmr_version", + name="DSMR version", + entity_registry_enabled_default=False, + icon="mdi:alert-circle", + state=dsmr_transform, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/electricity_tariff", + name="Electricity tariff", + icon="mdi:flash", + state=tariff_transform, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/power_failure_count", + name="Power failure count", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/long_power_failure_count", + name="Long power failure count", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l1", + name="Voltage sag L1", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l2", + name="Voltage sag L2", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l3", + name="Voltage sag L3", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l1", + name="Voltage swell L1", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l2", + name="Voltage swell L2", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l3", + name="Voltage swell L3", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/rejected_telegrams", + name="Rejected telegrams", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1", + name="Current month low tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2", + name="Current month high tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1_returned", + name="Current month low tariff returned", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2_returned", + name="Current month high tariff returned", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_merged", + name="Current month power usage total", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_returned_merged", + name="Current month power return total", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1_cost", + name="Current month low tariff cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2_cost", + name="Current month high tariff cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_cost_merged", + name="Current month power total cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/gas", + name="Current month gas usage", + icon="mdi:counter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/gas_cost", + name="Current month gas cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/fixed_cost", + name="Current month fixed cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/total_cost", + name="Current month total cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1", + name="Current year low tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2", + name="Current year high tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1_returned", + name="Current year low tariff returned", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2_returned", + name="Current year high tariff usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_merged", + name="Current year power usage total", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_returned_merged", + name="Current year power returned total", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1_cost", + name="Current year low tariff cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2_cost", + name="Current year high tariff cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_cost_merged", + name="Current year power total cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/gas", + name="Current year gas usage", + icon="mdi:counter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/gas_cost", + name="Current year gas cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/fixed_cost", + name="Current year fixed cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/total_cost", + name="Current year total cost", + icon="mdi:currency-eur", + native_unit_of_measurement=CURRENCY_EURO, + ), +) diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 0ee5932c1bbd1..84947ec41f1e5 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -4,39 +4,27 @@ from homeassistant.core import callback from homeassistant.util import slugify -from .definitions import DEFINITIONS +from .definitions import SENSORS, DSMRReaderSensorEntityDescription DOMAIN = "dsmr_reader" async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up DSMR Reader sensors.""" - - sensors = [] - for topic in DEFINITIONS: - sensors.append(DSMRSensor(topic)) - - async_add_entities(sensors) + async_add_entities(DSMRSensor(description) for description in SENSORS) class DSMRSensor(SensorEntity): """Representation of a DSMR sensor that is updated via MQTT.""" - def __init__(self, topic): - """Initialize the sensor.""" - - self._definition = DEFINITIONS[topic] + entity_description: DSMRReaderSensorEntityDescription - self._entity_id = slugify(topic.replace("/", "_")) - self._topic = topic + def __init__(self, description: DSMRReaderSensorEntityDescription) -> None: + """Initialize the sensor.""" + self.entity_description = description - self._name = self._definition.get("name", topic.split("/")[-1]) - self._device_class = self._definition.get("device_class") - self._enable_default = self._definition.get("enable_default") - self._unit_of_measurement = self._definition.get("unit") - self._icon = self._definition.get("icon") - self._transform = self._definition.get("transform") - self._state = None + slug = slugify(description.key.replace("/", "_")) + self.entity_id = f"sensor.{slug}" async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -44,47 +32,13 @@ async def async_added_to_hass(self): @callback def message_received(message): """Handle new MQTT messages.""" - - if self._transform is not None: - self._state = self._transform(message.payload) + if self.entity_description.state is not None: + self._attr_native_value = self.entity_description.state(message.payload) else: - self._state = message.payload + self._attr_native_value = message.payload self.async_write_ha_state() - await mqtt.async_subscribe(self.hass, self._topic, message_received, 1) - - @property - def name(self): - """Return the name of the sensor supplied in constructor.""" - return self._name - - @property - def entity_id(self): - """Return the entity ID for this sensor.""" - return f"sensor.{self._entity_id}" - - @property - def state(self): - """Return the current state of the entity.""" - return self._state - - @property - def device_class(self): - """Return the device_class of this sensor.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit_of_measurement of this sensor.""" - return self._unit_of_measurement - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enable_default - - @property - def icon(self): - """Return the icon of this sensor.""" - return self._icon + await mqtt.async_subscribe( + self.hass, self.entity_description.key, message_received, 1 + ) diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py index 27475990de024..9dd7cd79ac7ff 100644 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -1,11 +1,16 @@ """Support for monitoring energy usage using the DTE energy bridge.""" +from http import HTTPStatus import logging import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_NAME, HTTP_OK +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import CONF_NAME, POWER_KILO_WATT import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -41,6 +46,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DteEnergyBridgeSensor(SensorEntity): """Implementation of the DTE Energy Bridge sensors.""" + _attr_icon = ICON + _attr_native_unit_of_measurement = POWER_KILO_WATT + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, ip_address, name, version): """Initialize the sensor.""" self._version = version @@ -50,29 +59,7 @@ def __init__(self, ip_address, name, version): elif self._version == 2: self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand" - self._name = name - self._unit_of_measurement = "kW" - self._state = None - - @property - def name(self): - """Return the name of th sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON + self._attr_name = name def update(self): """Get the energy usage data from the DTE energy bridge.""" @@ -80,15 +67,15 @@ def update(self): response = requests.get(self._url, timeout=5) except (requests.exceptions.RequestException, ValueError): _LOGGER.warning( - "Could not update status for DTE Energy Bridge (%s)", self._name + "Could not update status for DTE Energy Bridge (%s)", self._attr_name ) return - if response.status_code != HTTP_OK: + if response.status_code != HTTPStatus.OK: _LOGGER.warning( "Invalid status_code from DTE Energy Bridge: %s (%s)", response.status_code, - self._name, + self._attr_name, ) return @@ -98,7 +85,7 @@ def update(self): _LOGGER.warning( 'Invalid response from DTE Energy Bridge: "%s" (%s)', response.text, - self._name, + self._attr_name, ) return @@ -111,6 +98,6 @@ def update(self): # values in the format 000000.000 kW, but the scaling is Watts # NOT kWatts if self._version == 1 and "." in response_split[0]: - self._state = val + self._attr_native_value = val else: - self._state = val / 1000 + self._attr_native_value = val / 1000 diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index dbe1d10b55360..8fcbb4dcfed0d 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -6,12 +6,13 @@ """ from contextlib import suppress from datetime import datetime, timedelta +from http import HTTPStatus import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, HTTP_OK, TIME_MINUTES +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util @@ -82,7 +83,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -105,7 +106,7 @@ def extra_state_attributes(self): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES @@ -144,7 +145,7 @@ def update(self): response = requests.get(_RESOURCE, params, timeout=10) - if response.status_code != HTTP_OK: + if response.status_code != HTTPStatus.OK: self.info = [ {ATTR_DUE_AT: "n/a", ATTR_ROUTE: self.route, ATTR_DUE_IN: "n/a"} ] diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 76353415d4f5f..2271b107b3644 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,5 +1,4 @@ """Integrate with DuckDNS.""" -from asyncio import iscoroutinefunction from datetime import timedelta import logging @@ -102,10 +101,6 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) @bind_hass def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" - if not iscoroutinefunction: - _LOGGER.error("Action needs to be a coroutine and return True/False") - return - if not isinstance(intervals, (list, tuple)): intervals = (intervals,) remove = None diff --git a/homeassistant/components/duckdns/services.yaml b/homeassistant/components/duckdns/services.yaml index e0ba27390df3b..6c8b5af819998 100644 --- a/homeassistant/components/duckdns/services.yaml +++ b/homeassistant/components/duckdns/services.yaml @@ -1,6 +1,11 @@ set_txt: + name: Set TXT description: Set the TXT record of your DuckDNS subdomain. fields: txt: + name: TXT description: Payload for the TXT record. + required: true example: "This domain name is reserved for use in documentation" + selector: + text: diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py index af81b60b38e3b..839f79bc3f4a6 100644 --- a/homeassistant/components/dunehd/__init__.py +++ b/homeassistant/components/dunehd/__init__.py @@ -1,16 +1,22 @@ """The Dune HD component.""" +from __future__ import annotations + +from typing import Final + from pdunehd import DuneHDPlayer -from homeassistant.const import CONF_HOST +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS = ["media_player"] +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - host = entry.data[CONF_HOST] + host: str = entry.data[CONF_HOST] player = DuneHDPlayer(host) @@ -22,7 +28,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/dunehd/config_flow.py b/homeassistant/components/dunehd/config_flow.py index 3094999dc62e3..6c6f12280f571 100644 --- a/homeassistant/components/dunehd/config_flow.py +++ b/homeassistant/components/dunehd/config_flow.py @@ -1,29 +1,34 @@ """Adds config flow for Dune HD integration.""" +from __future__ import annotations + import ipaddress import logging import re +from typing import Any, Final from pdunehd import DuneHDPlayer import voluptuous as vol from homeassistant import config_entries, exceptions from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -def host_valid(host): +def host_valid(host: str) -> bool: """Return True if hostname or IP address is valid.""" try: - if ipaddress.ip_address(host).version in [4, 6]: + if ipaddress.ip_address(host).version in (4, 6): return True except ValueError: - if len(host) > 253: - return False - allowed = re.compile(r"(?!-)[A-Z\d\-\_]{1,63}(? 253: + return False + allowed = re.compile(r"(?!-)[A-Z\d\-\_]{1,63}(? None: """Initialize Dune HD player.""" player = DuneHDPlayer(host) state = await self.hass.async_add_executor_job(player.update_state) if not state: raise CannotConnect() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: if host_valid(user_input[CONF_HOST]): - self.host = user_input[CONF_HOST] + host: str = user_input[CONF_HOST] try: - if self.host_already_configured(self.host): + if self.host_already_configured(host): raise AlreadyConfigured() - await self.init_device(self.host) + await self.init_device(host) except CannotConnect: errors[CONF_HOST] = "cannot_connect" except AlreadyConfigured: errors[CONF_HOST] = "already_configured" else: - return self.async_create_entry(title=self.host, data=user_input) + return self.async_create_entry(title=host, data=user_input) else: errors[CONF_HOST] = "invalid_host" @@ -69,22 +72,24 @@ async def async_step_user(self, user_input=None): errors=errors, ) - async def async_step_import(self, user_input=None): + async def async_step_import( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle configuration by yaml file.""" - self.host = user_input[CONF_HOST] + assert user_input is not None + host: str = user_input[CONF_HOST] - if self.host_already_configured(self.host): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: host}) try: - await self.init_device(self.host) + await self.init_device(host) except CannotConnect: - _LOGGER.error("Import aborted, cannot connect to %s", self.host) + _LOGGER.error("Import aborted, cannot connect to %s", host) return self.async_abort(reason="cannot_connect") else: - return self.async_create_entry(title=self.host, data=user_input) + return self.async_create_entry(title=host, data=user_input) - def host_already_configured(self, host): + def host_already_configured(self, host: str) -> bool: """See if we already have a dunehd entry matching user input configured.""" existing_hosts = { entry.data[CONF_HOST] for entry in self._async_current_entries() diff --git a/homeassistant/components/dunehd/const.py b/homeassistant/components/dunehd/const.py index eef77d4bcbd51..1cc89cf20282c 100644 --- a/homeassistant/components/dunehd/const.py +++ b/homeassistant/components/dunehd/const.py @@ -1,4 +1,8 @@ """Constants for Dune HD integration.""" -ATTR_MANUFACTURER = "Dune" -DOMAIN = "dunehd" -DEFAULT_NAME = "Dune HD" +from __future__ import annotations + +from typing import Final + +ATTR_MANUFACTURER: Final = "Dune" +DOMAIN: Final = "dunehd" +DEFAULT_NAME: Final = "Dune HD" diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index 8d73585cd69b3..437b5b66a9910 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -1,7 +1,15 @@ """Dune HD implementation of the media player.""" +from __future__ import annotations + +from typing import Any, Final + +from pdunehd import DuneHDPlayer import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + MediaPlayerEntity, +) from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, @@ -10,7 +18,7 @@ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, ) -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -19,13 +27,17 @@ STATE_PAUSED, STATE_PLAYING, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN -CONF_SOURCES = "sources" +CONF_SOURCES: Final = "sources" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_SOURCES): vol.Schema({cv.string: cv.string}), @@ -33,7 +45,7 @@ } ) -DUNEHD_PLAYER_SUPPORT = ( +DUNEHD_PLAYER_SUPPORT: Final[int] = ( SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF @@ -43,9 +55,14 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Dune HD media player platform.""" - host = config.get(CONF_HOST) + host: str = config[CONF_HOST] hass.async_create_task( hass.config_entries.flow.async_init( @@ -54,11 +71,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Add Dune HD entities from a config_entry.""" - unique_id = config_entry.entry_id + unique_id = entry.entry_id - player = hass.data[DOMAIN][config_entry.entry_id] + player: str = hass.data[DOMAIN][entry.entry_id] async_add_entities([DuneHDPlayerEntity(player, DEFAULT_NAME, unique_id)], True) @@ -66,22 +85,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DuneHDPlayerEntity(MediaPlayerEntity): """Implementation of the Dune HD player.""" - def __init__(self, player, name, unique_id): + def __init__(self, player: DuneHDPlayer, name: str, unique_id: str) -> None: """Initialize entity to control Dune HD.""" self._player = player self._name = name - self._media_title = None - self._state = None + self._media_title: str | None = None + self._state: dict[str, Any] = {} self._unique_id = unique_id - def update(self): + def update(self) -> bool: """Update internal status of the entity.""" self._state = self._player.update_state() self.__update_title() return True @property - def state(self): + def state(self) -> str | None: """Return player state.""" state = STATE_OFF if "playback_position" in self._state: @@ -95,81 +114,82 @@ def state(self): return state @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" - return bool(self._state) + return len(self._state) > 0 @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique_id for this entity.""" return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - return { - "identifiers": {(DOMAIN, self._unique_id)}, - "name": DEFAULT_NAME, - "manufacturer": ATTR_MANUFACTURER, - } + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer=ATTR_MANUFACTURER, + name=DEFAULT_NAME, + ) @property - def volume_level(self): + def volume_level(self) -> float: """Return the volume level of the media player (0..1).""" return int(self._state.get("playback_volume", 0)) / 100 @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Return a boolean if volume is currently muted.""" return int(self._state.get("playback_mute", 0)) == 1 @property - def supported_features(self): + def supported_features(self) -> int: """Flag media player features that are supported.""" return DUNEHD_PLAYER_SUPPORT - def volume_up(self): + def volume_up(self) -> None: """Volume up media player.""" self._state = self._player.volume_up() - def volume_down(self): + def volume_down(self) -> None: """Volume down media player.""" self._state = self._player.volume_down() - def mute_volume(self, mute): + def mute_volume(self, mute: bool) -> None: """Mute/unmute player volume.""" self._state = self._player.mute(mute) - def turn_off(self): + def turn_off(self) -> None: """Turn off media player.""" self._media_title = None self._state = self._player.turn_off() - def turn_on(self): + def turn_on(self) -> None: """Turn off media player.""" self._state = self._player.turn_on() - def media_play(self): + def media_play(self) -> None: """Play media player.""" self._state = self._player.play() - def media_pause(self): + def media_pause(self) -> None: """Pause media player.""" self._state = self._player.pause() @property - def media_title(self): + def media_title(self) -> str | None: """Return the current media source.""" self.__update_title() if self._media_title: return self._media_title + return None - def __update_title(self): + def __update_title(self) -> None: if self._state.get("player_state") == "bluray_playback": self._media_title = "Blu-Ray" elif self._state.get("player_state") == "photo_viewer": @@ -179,10 +199,10 @@ def __update_title(self): else: self._media_title = None - def media_previous_track(self): + def media_previous_track(self) -> None: """Send previous track command.""" self._state = self._player.previous_track() - def media_next_track(self): + def media_next_track(self) -> None: """Send next track command.""" self._state = self._player.next_track() diff --git a/homeassistant/components/dunehd/translations/bg.json b/homeassistant/components/dunehd/translations/bg.json new file mode 100644 index 0000000000000..56eb33213e474 --- /dev/null +++ b/homeassistant/components/dunehd/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/de.json b/homeassistant/components/dunehd/translations/de.json index aa87de530b811..f3d7ecd725a26 100644 --- a/homeassistant/components/dunehd/translations/de.json +++ b/homeassistant/components/dunehd/translations/de.json @@ -6,7 +6,7 @@ "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse." + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, "step": { "user": { diff --git a/homeassistant/components/dunehd/translations/es-419.json b/homeassistant/components/dunehd/translations/es-419.json new file mode 100644 index 0000000000000..5ad7a6640b4ce --- /dev/null +++ b/homeassistant/components/dunehd/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure la integraci\u00f3n de Dune HD. Si tiene problemas con la configuraci\u00f3n, vaya a: https://www.home-assistant.io/integrations/dunehd \n\n Aseg\u00farese de que su reproductor est\u00e9 encendido.", + "title": "Dune HD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/fr.json b/homeassistant/components/dunehd/translations/fr.json index 7547ceadb72fb..0e8cb6d6ff8e0 100644 --- a/homeassistant/components/dunehd/translations/fr.json +++ b/homeassistant/components/dunehd/translations/fr.json @@ -6,7 +6,7 @@ "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide." + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide" }, "step": { "user": { diff --git a/homeassistant/components/dunehd/translations/he.json b/homeassistant/components/dunehd/translations/he.json new file mode 100644 index 0000000000000..5cf4da123b51c --- /dev/null +++ b/homeassistant/components/dunehd/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/hu.json b/homeassistant/components/dunehd/translations/hu.json index cf0b593d5460d..15b2d2973635d 100644 --- a/homeassistant/components/dunehd/translations/hu.json +++ b/homeassistant/components/dunehd/translations/hu.json @@ -11,8 +11,9 @@ "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, + "description": "\u00c1ll\u00edtsa be a Dune HD integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/dunehd \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a lej\u00e1tsz\u00f3 be van kapcsolva.", "title": "Dune HD" } } diff --git a/homeassistant/components/dunehd/translations/it.json b/homeassistant/components/dunehd/translations/it.json index 57f94a2753f96..e296a59e47477 100644 --- a/homeassistant/components/dunehd/translations/it.json +++ b/homeassistant/components/dunehd/translations/it.json @@ -13,7 +13,7 @@ "data": { "host": "Host" }, - "description": "Configurare l'integrazione Dune HD. In caso di problemi con la configurazione, visitare: https://www.home-assistant.io/integrations/dunehd \n\n Assicurati che il tuo lettore sia acceso.", + "description": "Configura l'integrazione Dune HD. In caso di problemi con la configurazione, visita: https://www.home-assistant.io/integrations/dunehd \n\n Assicurati che il tuo lettore sia acceso.", "title": "Dune HD" } } diff --git a/homeassistant/components/dunehd/translations/ja.json b/homeassistant/components/dunehd/translations/ja.json new file mode 100644 index 0000000000000..79bb6b0746d4d --- /dev/null +++ b/homeassistant/components/dunehd/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Dune HD\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u95a2\u3057\u3066\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/dunehd \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\n\n\u304d\u3061\u3093\u3068\u30d7\u30ec\u30fc\u30e4\u30fc\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3082\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Dune HD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/ru.json b/homeassistant/components/dunehd/translations/ru.json index be35fe8b09200..c1537de579f88 100644 --- a/homeassistant/components/dunehd/translations/ru.json +++ b/homeassistant/components/dunehd/translations/ru.json @@ -13,7 +13,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Dune HD. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0432\u043e\u0437\u043d\u0438\u043a\u043b\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443: https://www.home-assistant.io/integrations/dunehd\n\n\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 \u043f\u043b\u0435\u0435\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Dune HD. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0432\u043e\u0437\u043d\u0438\u043a\u043b\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443: https://www.home-assistant.io/integrations/dunehd\n\n\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 \u043f\u043b\u0435\u0435\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d.", "title": "Dune HD" } } diff --git a/homeassistant/components/dunehd/translations/tr.json b/homeassistant/components/dunehd/translations/tr.json index 0f8c17228fdd1..121267c34e385 100644 --- a/homeassistant/components/dunehd/translations/tr.json +++ b/homeassistant/components/dunehd/translations/tr.json @@ -5,13 +5,15 @@ }, "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar" + "host": "Sunucu" }, + "description": "Dune HD entegrasyonunu ayarlay\u0131n. Yap\u0131land\u0131rmayla ilgili sorunlar\u0131n\u0131z varsa \u015fu adrese gidin: https://www.home-assistant.io/integrations/dunehd \n\n Oynat\u0131c\u0131n\u0131z\u0131n a\u00e7\u0131k oldu\u011fundan emin olun.", "title": "Dune HD" } } diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index 1550d9262a4a2..55c848ea219c8 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -3,6 +3,6 @@ "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "codeowners": ["@runningman84", "@stephan192", "@Hummel95"], - "requirements": ["dwdwfsapi==1.0.3"], + "requirements": ["dwdwfsapi==1.0.4"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 78fa9bd85522d..2668e573b7c15 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -9,13 +9,19 @@ Warnungen vor markantem Wetter (Stufe 2) Wetterwarnungen (Stufe 1) """ +from __future__ import annotations + from datetime import timedelta import logging from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -48,18 +54,21 @@ SCAN_INTERVAL = timedelta(minutes=15) -MONITORED_CONDITIONS = { - CURRENT_WARNING_SENSOR: [ - "Current Warning Level", - None, - "mdi:close-octagon-outline", - ], - ADVANCE_WARNING_SENSOR: [ - "Advance Warning Level", - None, - "mdi:close-octagon-outline", - ], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CURRENT_WARNING_SENSOR, + name="Current Warning Level", + icon="mdi:close-octagon-outline", + ), + SensorEntityDescription( + key=ADVANCE_WARNING_SENSOR, + name="Advance Warning Level", + icon="mdi:close-octagon-outline", + ), +) +MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -79,9 +88,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): api = WrappedDwDWWAPI(DwdWeatherWarningsAPI(region_name)) - sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(DwdWeatherWarningsSensor(api, name, sensor_type)) + sensors = [ + DwdWeatherWarningsSensor(api, name, description) + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_CONDITIONS] + ] add_entities(sensors, True) @@ -89,31 +100,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DwdWeatherWarningsSensor(SensorEntity): """Representation of a DWD-Weather-Warnings sensor.""" - def __init__(self, api, name, sensor_type): + def __init__( + self, + api, + name, + description: SensorEntityDescription, + ): """Initialize a DWD-Weather-Warnings sensor.""" self._api = api - self._name = name - self._sensor_type = sensor_type - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {MONITORED_CONDITIONS[self._sensor_type][0]}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return MONITORED_CONDITIONS[self._sensor_type][2] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return MONITORED_CONDITIONS[self._sensor_type][1] + self.entity_description = description + self._attr_name = f"{name} {description.name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" - if self._sensor_type == CURRENT_WARNING_SENSOR: + if self.entity_description.key == CURRENT_WARNING_SENSOR: return self._api.api.current_warning_level return self._api.api.expected_warning_level @@ -127,7 +128,7 @@ def extra_state_attributes(self): ATTR_LAST_UPDATE: self._api.api.last_update, } - if self._sensor_type == CURRENT_WARNING_SENSOR: + if self.entity_description.key == CURRENT_WARNING_SENSOR: searched_warnings = self._api.api.current_warnings else: searched_warnings = self._api.api.expected_warnings @@ -165,7 +166,7 @@ def update(self): "Update requested for %s (%s) by %s", self._api.api.warncell_name, self._api.api.warncell_id, - self._sensor_type, + self.entity_description.key, ) self._api.update() diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index f1243cd540771..3d980b34d00a7 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -73,12 +73,12 @@ def name(self): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 1ee609961cc8a..49e742519fdc1 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge @@ -52,6 +53,7 @@ SERVICE_REQUEST_AREA_PRESET, SERVICE_REQUEST_CHANNEL_LEVEL, ) +from .convert_config import convert_config def num_string(value: int | str) -> str: @@ -108,8 +110,8 @@ def num_string(value: int | str) -> str: def validate_area(config: dict[str, Any]) -> dict[str, Any]: """Validate that template parameters are only used if area is using the relevant template.""" conf_set = set() - for template in DEFAULT_TEMPLATES: - for conf in DEFAULT_TEMPLATES[template]: + for configs in DEFAULT_TEMPLATES.values(): + for conf in configs: conf_set.add(conf) if config.get(CONF_TEMPLATE): for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: @@ -178,7 +180,7 @@ def validate_area(config: dict[str, Any]) -> dict[str, Any]: ) -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" conf = config.get(DOMAIN) LOGGER.debug("Setting up dynalite component config = %s", conf) @@ -263,7 +265,7 @@ async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) - bridge = DynaliteBridge(hass, entry.data) + bridge = DynaliteBridge(hass, convert_config(entry.data)) # need to do it before the listener hass.data[DOMAIN][entry.entry_id] = bridge entry.async_on_unload(entry.add_update_listener(async_entry_changed)) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 71cecee8d4301..82666f20a40e3 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,7 +1,9 @@ """Code to handle a Dynalite bridge.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from types import MappingProxyType +from typing import Any from dynalite_devices_lib.dynalite_devices import ( CONF_AREA as dyn_CONF_AREA, @@ -27,9 +29,8 @@ class DynaliteBridge: def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize the system based on host parameter.""" self.hass = hass - self.area = {} - self.async_add_devices = {} - self.waiting_devices = {} + self.async_add_devices: dict[str, Callable] = {} + self.waiting_devices: dict[str, list[str]] = {} self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( @@ -37,7 +38,7 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: update_device_func=self.update_device, notification_func=self.handle_notification, ) - self.dynalite_devices.configure(convert_config(config)) + self.dynalite_devices.configure(config) async def async_setup(self) -> bool: """Set up a Dynalite bridge.""" @@ -45,7 +46,7 @@ async def async_setup(self) -> bool: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config: dict[str, Any]) -> None: + def reload_config(self, config: MappingProxyType[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(convert_config(config)) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 9b2b76318f163..d148d09354f77 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -8,6 +8,7 @@ from .bridge import DynaliteBridge from .const import DOMAIN, LOGGER +from .convert_config import convert_config class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -23,13 +24,15 @@ async def async_step_import(self, import_info: dict[str, Any]) -> Any: """Import a new bridge as a config entry.""" LOGGER.debug("Starting async_step_import - %s", import_info) host = import_info[CONF_HOST] - for entry in self.hass.config_entries.async_entries(DOMAIN): + for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: - if entry.data != import_info: - self.hass.config_entries.async_update_entry(entry, data=import_info) + self.hass.config_entries.async_update_entry( + entry, data=dict(import_info) + ) return self.async_abort(reason="already_configured") + # New entry - bridge = DynaliteBridge(self.hass, import_info) + bridge = DynaliteBridge(self.hass, convert_config(import_info)) if not await bridge.async_setup(): LOGGER.error("Unable to setup bridge - import info=%s", import_info) return self.async_abort(reason="no_connection") diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index eda43305461d7..83cc639d1da5e 100644 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -1,12 +1,12 @@ """Constants for the Dynalite component.""" import logging -from homeassistant.const import CONF_ROOM +from homeassistant.const import CONF_ROOM, Platform LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -PLATFORMS = ["light", "switch", "cover"] +PLATFORMS = [Platform.LIGHT, Platform.SWITCH, Platform.COVER] CONF_ACTIVE = "active" diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 89a7f32b47ab0..4abc02c05659f 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -1,6 +1,7 @@ """Convert the HA config to the dynalite config.""" from __future__ import annotations +from types import MappingProxyType from typing import Any from dynalite_devices_lib import const as dyn_const @@ -136,7 +137,9 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: return convert_with_map(config, my_map) -def convert_config(config: dict[str, Any]) -> dict[str, Any]: +def convert_config( + config: dict[str, Any] | MappingProxyType[str, Any] +) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { CONF_NAME: dyn_const.CONF_NAME, diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 3e6c738f066a2..930ced4ff54f8 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -1,17 +1,13 @@ """Support for the Dynalite channels as covers.""" -from homeassistant.components.cover import ( - DEVICE_CLASS_SHUTTER, - DEVICE_CLASSES, - CoverEntity, -) +from homeassistant.components.cover import DEVICE_CLASSES, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .dynalitebase import DynaliteBase, async_setup_entry_base -DEFAULT_COVER_CLASS = DEVICE_CLASS_SHUTTER +DEFAULT_COVER_CLASS = CoverDeviceClass.SHUTTER async def async_setup_entry( diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index ebb1dd2379585..c1814307d1cba 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -1,15 +1,16 @@ """Support for the Dynalite devices as entities.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any -from homeassistant.components.dynalite.bridge import DynaliteBridge from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .bridge import DynaliteBridge from .const import DOMAIN, LOGGER @@ -43,7 +44,7 @@ def __init__(self, device: Any, bridge: DynaliteBridge) -> None: """Initialize the base class.""" self._device = device self._bridge = bridge - self._unsub_dispatchers = [] + self._unsub_dispatchers: list[Callable[[], None]] = [] @property def name(self) -> str: @@ -63,11 +64,11 @@ def available(self) -> bool: @property def device_info(self) -> DeviceInfo: """Device info for this entity.""" - return { - "identifiers": {(DOMAIN, self._device.unique_id)}, - "name": self.name, - "manufacturer": "Dynalite", - } + return DeviceInfo( + identifiers={(DOMAIN, self._device.unique_id)}, + manufacturer="Dynalite", + name=self.name, + ) async def async_added_to_hass(self) -> None: """Added to hass so need to register to dispatch.""" diff --git a/homeassistant/components/dynalite/services.yaml b/homeassistant/components/dynalite/services.yaml index afdb01bf351dd..d34335ca1d344 100644 --- a/homeassistant/components/dynalite/services.yaml +++ b/homeassistant/components/dynalite/services.yaml @@ -1,26 +1,50 @@ request_area_preset: + name: Request area preset description: "Requests Dynalite to report the preset for an area." fields: host: description: "Host gateway IP to send to or all configured gateways if not specified." example: "192.168.0.101" + selector: + text: area: description: "Area to request the preset reported" - example: 2 + required: true + selector: + number: + min: 1 + max: 9999 channel: - description: "Channel to request the preset to be reported from. Default is channel 1" - example: 1 + description: "Channel to request the preset to be reported from." + default: 1 + selector: + number: + min: 1 + max: 9999 request_channel_level: + name: Request channel level description: "Requests Dynalite to report the level of a specific channel." fields: host: + name: Host description: "Host gateway IP to send to or all configured gateways if not specified." example: "192.168.0.101" + selector: + text: area: + name: Area description: "Area for the requested channel" - example: 2 + required: true + selector: + number: + min: 1 + max: 9999 channel: + name: Channel description: "Channel to request the level for." - example: 1 - + required: true + selector: + number: + min: 1 + max: 9999 diff --git a/homeassistant/components/dyson/__init__.py b/homeassistant/components/dyson/__init__.py deleted file mode 100644 index d8023b4297326..0000000000000 --- a/homeassistant/components/dyson/__init__.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Support for Dyson Pure Cool Link devices.""" -import logging - -from libpurecool.dyson import DysonAccount -import voluptuous as vol - -from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) - -CONF_LANGUAGE = "language" -CONF_RETRY = "retry" - -DEFAULT_TIMEOUT = 5 -DEFAULT_RETRY = 10 -DYSON_DEVICES = "dyson_devices" -PLATFORMS = ["sensor", "fan", "vacuum", "climate", "air_quality"] - -DOMAIN = "dyson" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_LANGUAGE): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): cv.positive_int, - vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [dict]), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Set up the Dyson parent component.""" - _LOGGER.info("Creating new Dyson component") - - if DYSON_DEVICES not in hass.data: - hass.data[DYSON_DEVICES] = [] - - dyson_account = DysonAccount( - config[DOMAIN].get(CONF_USERNAME), - config[DOMAIN].get(CONF_PASSWORD), - config[DOMAIN].get(CONF_LANGUAGE), - ) - - logged = dyson_account.login() - - timeout = config[DOMAIN].get(CONF_TIMEOUT) - retry = config[DOMAIN].get(CONF_RETRY) - - if not logged: - _LOGGER.error("Not connected to Dyson account. Unable to add devices") - return False - - _LOGGER.info("Connected to Dyson account") - dyson_devices = dyson_account.devices() - if CONF_DEVICES in config[DOMAIN] and config[DOMAIN].get(CONF_DEVICES): - configured_devices = config[DOMAIN].get(CONF_DEVICES) - for device in configured_devices: - dyson_device = next( - (d for d in dyson_devices if d.serial == device["device_id"]), None - ) - if dyson_device: - try: - connected = dyson_device.connect(device["device_ip"]) - if connected: - _LOGGER.info("Connected to device %s", dyson_device) - hass.data[DYSON_DEVICES].append(dyson_device) - else: - _LOGGER.warning("Unable to connect to device %s", dyson_device) - except OSError as ose: - _LOGGER.error( - "Unable to connect to device %s: %s", - str(dyson_device.network_device), - str(ose), - ) - else: - _LOGGER.warning( - "Unable to find device %s in Dyson account", device["device_id"] - ) - else: - # Not yet reliable - for device in dyson_devices: - _LOGGER.info( - "Trying to connect to device %s with timeout=%i and retry=%i", - device, - timeout, - retry, - ) - connected = device.auto_connect(timeout, retry) - if connected: - _LOGGER.info("Connected to device %s", device) - hass.data[DYSON_DEVICES].append(device) - else: - _LOGGER.warning("Unable to connect to device %s", device) - - # Start fan/sensors components - if hass.data[DYSON_DEVICES]: - _LOGGER.debug("Starting sensor/fan components") - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) - - return True - - -class DysonEntity(Entity): - """Representation of a Dyson entity.""" - - def __init__(self, device, state_type): - """Initialize the entity.""" - self._device = device - self._state_type = state_type - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - self._device.add_message_listener(self.on_message_filter) - - def on_message_filter(self, message): - """Filter new messages received.""" - if self._state_type is None or isinstance(message, self._state_type): - _LOGGER.debug( - "Message received for device %s : %s", - self.name, - message, - ) - self.on_message(message) - - def on_message(self, message): - """Handle new messages received.""" - self.schedule_update_ha_state() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the Dyson sensor.""" - return self._device.name - - @property - def unique_id(self): - """Return the sensor's unique id.""" - return self._device.serial diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py deleted file mode 100644 index 48b66fe768358..0000000000000 --- a/homeassistant/components/dyson/air_quality.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Support for Dyson Pure Cool Air Quality Sensors.""" -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State - -from homeassistant.components.air_quality import AirQualityEntity - -from . import DYSON_DEVICES, DysonEntity - -ATTRIBUTION = "Dyson purifier air quality sensor" - -DYSON_AIQ_DEVICES = "dyson_aiq_devices" - -ATTR_VOC = "volatile_organic_compounds" - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson Sensors.""" - - if discovery_info is None: - return - - hass.data.setdefault(DYSON_AIQ_DEVICES, []) - - # Get Dyson Devices from parent component - device_ids = [device.unique_id for device in hass.data[DYSON_AIQ_DEVICES]] - new_entities = [] - for device in hass.data[DYSON_DEVICES]: - if isinstance(device, DysonPureCool) and device.serial not in device_ids: - new_entities.append(DysonAirSensor(device)) - - if not new_entities: - return - - hass.data[DYSON_AIQ_DEVICES].extend(new_entities) - add_entities(hass.data[DYSON_AIQ_DEVICES]) - - -class DysonAirSensor(DysonEntity, AirQualityEntity): - """Representation of a generic Dyson air quality sensor.""" - - def __init__(self, device): - """Create a new generic air quality Dyson sensor.""" - super().__init__(device, DysonEnvironmentalSensorV2State) - self._old_value = None - - def on_message(self, message): - """Handle new messages which are received from the fan.""" - if ( - self._old_value is None - or self._old_value != self._device.environmental_state - ): - self._old_value = self._device.environmental_state - self.schedule_update_ha_state() - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def air_quality_index(self): - """Return the Air Quality Index (AQI).""" - return max( - self.particulate_matter_2_5, - self.particulate_matter_10, - self.nitrogen_dioxide, - self.volatile_organic_compounds, - ) - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return int(self._device.environmental_state.particulate_matter_25) - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return int(self._device.environmental_state.particulate_matter_10) - - @property - def nitrogen_dioxide(self): - """Return the NO2 (nitrogen dioxide) level.""" - return int(self._device.environmental_state.nitrogen_dioxide) - - @property - def volatile_organic_compounds(self): - """Return the VOC (Volatile Organic Compounds) level.""" - return int(self._device.environmental_state.volatile_organic_compounds) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return {ATTR_VOC: self.volatile_organic_compounds} diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py deleted file mode 100644 index 4f4c4d7cbbadf..0000000000000 --- a/homeassistant/components/dyson/climate.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Support for Dyson Pure Hot+Cool link fan.""" -import logging - -from libpurecool.const import ( - AutoMode, - FanPower, - FanSpeed, - FanState, - FocusMode, - HeatMode, - HeatState, - HeatTarget, -) -from libpurecool.dyson_pure_hotcool import DysonPureHotCool -from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink -from libpurecool.dyson_pure_state import DysonPureHotCoolState -from libpurecool.dyson_pure_state_v2 import DysonPureHotCoolV2State - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import ( - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, - FAN_AUTO, - FAN_DIFFUSE, - FAN_FOCUS, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_OFF, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - SUPPORT_FAN_MODE, - SUPPORT_TARGET_TEMPERATURE, -) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS - -from . import DYSON_DEVICES, DysonEntity - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_FAN = [FAN_FOCUS, FAN_DIFFUSE] -SUPPORT_FAN_PCOOL = [FAN_OFF, FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] -SUPPORT_HVAC = [HVAC_MODE_COOL, HVAC_MODE_HEAT] -SUPPORT_HVAC_PCOOL = [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - -DYSON_KNOWN_CLIMATE_DEVICES = "dyson_known_climate_devices" - -SPEED_MAP = { - FanSpeed.FAN_SPEED_1.value: FAN_LOW, - FanSpeed.FAN_SPEED_2.value: FAN_LOW, - FanSpeed.FAN_SPEED_3.value: FAN_LOW, - FanSpeed.FAN_SPEED_4.value: FAN_LOW, - FanSpeed.FAN_SPEED_AUTO.value: FAN_AUTO, - FanSpeed.FAN_SPEED_5.value: FAN_MEDIUM, - FanSpeed.FAN_SPEED_6.value: FAN_MEDIUM, - FanSpeed.FAN_SPEED_7.value: FAN_MEDIUM, - FanSpeed.FAN_SPEED_8.value: FAN_HIGH, - FanSpeed.FAN_SPEED_9.value: FAN_HIGH, - FanSpeed.FAN_SPEED_10.value: FAN_HIGH, -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson fan components.""" - if discovery_info is None: - return - - known_devices = hass.data.setdefault(DYSON_KNOWN_CLIMATE_DEVICES, set()) - - # Get Dyson Devices from parent component - new_entities = [] - - for device in hass.data[DYSON_DEVICES]: - if device.serial not in known_devices: - if isinstance(device, DysonPureHotCool): - dyson_entity = DysonPureHotCoolEntity(device) - new_entities.append(dyson_entity) - known_devices.add(device.serial) - elif isinstance(device, DysonPureHotCoolLink): - dyson_entity = DysonPureHotCoolLinkEntity(device) - new_entities.append(dyson_entity) - known_devices.add(device.serial) - - add_entities(new_entities) - - -class DysonClimateEntity(DysonEntity, ClimateEntity): - """Representation of a Dyson climate fan.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - if ( - self._device.environmental_state - and self._device.environmental_state.temperature - ): - temperature_kelvin = self._device.environmental_state.temperature - return float(f"{temperature_kelvin - 273:.1f}") - return None - - @property - def target_temperature(self): - """Return the target temperature.""" - heat_target = int(self._device.state.heat_target) / 10 - return int(heat_target - 273) - - @property - def current_humidity(self): - """Return the current humidity.""" - # Humidity equaling to 0 means invalid value so we don't check for None here - # https://github.com/home-assistant/core/pull/45172#discussion_r559069756 - if ( - self._device.environmental_state - and self._device.environmental_state.humidity - ): - return self._device.environmental_state.humidity - return None - - @property - def min_temp(self): - """Return the minimum temperature.""" - return 1 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 37 - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - if target_temp is None: - _LOGGER.error("Missing target temperature %s", kwargs) - return - target_temp = int(target_temp) - _LOGGER.debug("Set %s temperature %s", self.name, target_temp) - # Limit the target temperature into acceptable range. - target_temp = min(self.max_temp, target_temp) - target_temp = max(self.min_temp, target_temp) - self.set_heat_target(HeatTarget.celsius(target_temp)) - - def set_heat_target(self, heat_target): - """Set heating target temperature.""" - - -class DysonPureHotCoolLinkEntity(DysonClimateEntity): - """Representation of a Dyson climate fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureHotCoolState) - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - return HVAC_MODE_HEAT - return HVAC_MODE_COOL - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return SUPPORT_HVAC - - @property - def hvac_action(self): - """Return the current running hvac operation if supported. - - Need to be one of CURRENT_HVAC_*. - """ - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_COOL - - @property - def fan_mode(self): - """Return the fan setting.""" - if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: - return FAN_FOCUS - return FAN_DIFFUSE - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return SUPPORT_FAN - - def set_heat_target(self, heat_target): - """Set heating target temperature.""" - self._device.set_configuration( - heat_target=heat_target, heat_mode=HeatMode.HEAT_ON - ) - - def set_fan_mode(self, fan_mode): - """Set new fan mode.""" - _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) - if fan_mode == FAN_FOCUS: - self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) - elif fan_mode == FAN_DIFFUSE: - self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) - - def set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - _LOGGER.debug("Set %s heat mode %s", self.name, hvac_mode) - if hvac_mode == HVAC_MODE_HEAT: - self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) - elif hvac_mode == HVAC_MODE_COOL: - self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) - - -class DysonPureHotCoolEntity(DysonClimateEntity): - """Representation of a Dyson climate hot+cool fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureHotCoolV2State) - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._device.state.fan_power == FanPower.POWER_OFF.value: - return HVAC_MODE_OFF - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - return HVAC_MODE_HEAT - return HVAC_MODE_COOL - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return SUPPORT_HVAC_PCOOL - - @property - def hvac_action(self): - """Return the current running hvac operation if supported. - - Need to be one of CURRENT_HVAC_*. - """ - if self._device.state.fan_power == FanPower.POWER_OFF.value: - return CURRENT_HVAC_OFF - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_COOL - - @property - def fan_mode(self): - """Return the fan setting.""" - if ( - self._device.state.auto_mode != AutoMode.AUTO_ON.value - and self._device.state.fan_state == FanState.FAN_OFF.value - ): - return FAN_OFF - - return SPEED_MAP[self._device.state.speed] - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return SUPPORT_FAN_PCOOL - - def set_heat_target(self, heat_target): - """Set heating target temperature.""" - self._device.set_heat_target(heat_target) - - def set_fan_mode(self, fan_mode): - """Set new fan mode.""" - _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) - if fan_mode == FAN_OFF: - self._device.turn_off() - elif fan_mode == FAN_LOW: - self._device.set_fan_speed(FanSpeed.FAN_SPEED_4) - elif fan_mode == FAN_MEDIUM: - self._device.set_fan_speed(FanSpeed.FAN_SPEED_7) - elif fan_mode == FAN_HIGH: - self._device.set_fan_speed(FanSpeed.FAN_SPEED_10) - elif fan_mode == FAN_AUTO: - self._device.enable_auto_mode() - - def set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - _LOGGER.debug("Set %s heat mode %s", self.name, hvac_mode) - if hvac_mode == HVAC_MODE_OFF: - self._device.turn_off() - elif self._device.state.fan_power == FanPower.POWER_OFF.value: - self._device.turn_on() - if hvac_mode == HVAC_MODE_HEAT: - self._device.enable_heat_mode() - elif hvac_mode == HVAC_MODE_COOL: - self._device.disable_heat_mode() diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py deleted file mode 100644 index 38b4b511df47b..0000000000000 --- a/homeassistant/components/dyson/fan.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Support for Dyson Pure Cool link fan.""" -from __future__ import annotations - -import logging -import math - -from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_cool_link import DysonPureCoolLink -from libpurecool.dyson_pure_state import DysonPureCoolState -from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State -import voluptuous as vol - -from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity -from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.util.percentage import ( - int_states_in_range, - percentage_to_ranged_value, - ranged_value_to_percentage, -) - -from . import DYSON_DEVICES, DysonEntity - -_LOGGER = logging.getLogger(__name__) - -ATTR_NIGHT_MODE = "night_mode" -ATTR_AUTO_MODE = "auto_mode" -ATTR_ANGLE_LOW = "angle_low" -ATTR_ANGLE_HIGH = "angle_high" -ATTR_FLOW_DIRECTION_FRONT = "flow_direction_front" -ATTR_TIMER = "timer" -ATTR_HEPA_FILTER = "hepa_filter" -ATTR_CARBON_FILTER = "carbon_filter" -ATTR_DYSON_SPEED = "dyson_speed" -ATTR_DYSON_SPEED_LIST = "dyson_speed_list" - -DYSON_DOMAIN = "dyson" -DYSON_FAN_DEVICES = "dyson_fan_devices" - -SERVICE_SET_NIGHT_MODE = "set_night_mode" -SERVICE_SET_AUTO_MODE = "set_auto_mode" -SERVICE_SET_ANGLE = "set_angle" -SERVICE_SET_FLOW_DIRECTION_FRONT = "set_flow_direction_front" -SERVICE_SET_TIMER = "set_timer" -SERVICE_SET_DYSON_SPEED = "set_speed" - -SET_NIGHT_MODE_SCHEMA = { - vol.Required(ATTR_NIGHT_MODE): cv.boolean, -} - -SET_AUTO_MODE_SCHEMA = { - vol.Required(ATTR_AUTO_MODE): cv.boolean, -} - -SET_ANGLE_SCHEMA = { - vol.Required(ATTR_ANGLE_LOW): cv.positive_int, - vol.Required(ATTR_ANGLE_HIGH): cv.positive_int, -} - -SET_FLOW_DIRECTION_FRONT_SCHEMA = { - vol.Required(ATTR_FLOW_DIRECTION_FRONT): cv.boolean, -} - -SET_TIMER_SCHEMA = { - vol.Required(ATTR_TIMER): cv.positive_int, -} - -SET_DYSON_SPEED_SCHEMA = { - vol.Required(ATTR_DYSON_SPEED): cv.positive_int, -} - - -PRESET_MODE_AUTO = "auto" -PRESET_MODES = [PRESET_MODE_AUTO] - -ORDERED_DYSON_SPEEDS = [ - FanSpeed.FAN_SPEED_1, - FanSpeed.FAN_SPEED_2, - FanSpeed.FAN_SPEED_3, - FanSpeed.FAN_SPEED_4, - FanSpeed.FAN_SPEED_5, - FanSpeed.FAN_SPEED_6, - FanSpeed.FAN_SPEED_7, - FanSpeed.FAN_SPEED_8, - FanSpeed.FAN_SPEED_9, - FanSpeed.FAN_SPEED_10, -] -DYSON_SPEED_TO_INT_VALUE = {k: int(k.value) for k in ORDERED_DYSON_SPEEDS} -INT_VALUE_TO_DYSON_SPEED = {v: k for k, v in DYSON_SPEED_TO_INT_VALUE.items()} - -SPEED_LIST_DYSON = list(DYSON_SPEED_TO_INT_VALUE.values()) - -SPEED_RANGE = ( - SPEED_LIST_DYSON[0], - SPEED_LIST_DYSON[-1], -) # off is not included - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Dyson fan components.""" - - if discovery_info is None: - return - - _LOGGER.debug("Creating new Dyson fans") - if DYSON_FAN_DEVICES not in hass.data: - hass.data[DYSON_FAN_DEVICES] = [] - - # Get Dyson Devices from parent component - has_purecool_devices = False - device_serials = [device.serial for device in hass.data[DYSON_FAN_DEVICES]] - for device in hass.data[DYSON_DEVICES]: - if device.serial not in device_serials: - if isinstance(device, DysonPureCool): - has_purecool_devices = True - dyson_entity = DysonPureCoolEntity(device) - hass.data[DYSON_FAN_DEVICES].append(dyson_entity) - elif isinstance(device, DysonPureCoolLink): - dyson_entity = DysonPureCoolLinkEntity(device) - hass.data[DYSON_FAN_DEVICES].append(dyson_entity) - - async_add_entities(hass.data[DYSON_FAN_DEVICES]) - - # Register custom services - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SET_NIGHT_MODE, SET_NIGHT_MODE_SCHEMA, "set_night_mode" - ) - platform.async_register_entity_service( - SERVICE_SET_AUTO_MODE, SET_AUTO_MODE_SCHEMA, "set_auto_mode" - ) - platform.async_register_entity_service( - SERVICE_SET_DYSON_SPEED, SET_DYSON_SPEED_SCHEMA, "service_set_dyson_speed" - ) - if has_purecool_devices: - platform.async_register_entity_service( - SERVICE_SET_ANGLE, SET_ANGLE_SCHEMA, "set_angle" - ) - platform.async_register_entity_service( - SERVICE_SET_FLOW_DIRECTION_FRONT, - SET_FLOW_DIRECTION_FRONT_SCHEMA, - "set_flow_direction_front", - ) - platform.async_register_entity_service( - SERVICE_SET_TIMER, SET_TIMER_SCHEMA, "set_timer" - ) - - -class DysonFanEntity(DysonEntity, FanEntity): - """Representation of a Dyson fan.""" - - @property - def percentage(self): - """Return the current speed percentage.""" - if self.auto_mode: - return None - return ranged_value_to_percentage(SPEED_RANGE, int(self._device.state.speed)) - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) - - @property - def preset_modes(self): - """Return the available preset modes.""" - return PRESET_MODES - - @property - def preset_mode(self): - """Return the current preset mode.""" - if self.auto_mode: - return PRESET_MODE_AUTO - return None - - @property - def dyson_speed(self): - """Return the current speed.""" - if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value: - return self._device.state.speed - return int(self._device.state.speed) - - @property - def dyson_speed_list(self) -> list: - """Get the list of available dyson speeds.""" - return SPEED_LIST_DYSON - - @property - def night_mode(self): - """Return Night mode.""" - return self._device.state.night_mode == "ON" - - @property - def auto_mode(self): - """Return auto mode.""" - raise NotImplementedError - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED - - @property - def extra_state_attributes(self) -> dict: - """Return optional state attributes.""" - return { - ATTR_NIGHT_MODE: self.night_mode, - ATTR_AUTO_MODE: self.auto_mode, - ATTR_DYSON_SPEED: self.dyson_speed, - ATTR_DYSON_SPEED_LIST: self.dyson_speed_list, - } - - def set_auto_mode(self, auto_mode: bool) -> None: - """Set auto mode.""" - raise NotImplementedError - - def set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - if percentage == 0: - self.turn_off() - return - dyson_speed = INT_VALUE_TO_DYSON_SPEED[ - math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - ] - self.set_dyson_speed(dyson_speed) - - def set_preset_mode(self, preset_mode: str) -> None: - """Set a preset mode on the fan.""" - self._valid_preset_mode_or_raise(preset_mode) - # There currently is only one - self.set_auto_mode(True) - - def set_dyson_speed(self, speed: FanSpeed) -> None: - """Set the exact speed of the fan.""" - raise NotImplementedError - - def service_set_dyson_speed(self, dyson_speed: int) -> None: - """Handle the service to set dyson speed.""" - if dyson_speed not in SPEED_LIST_DYSON: - raise ValueError(f'"{dyson_speed}" is not a valid Dyson speed') - _LOGGER.debug("Set exact speed to %s", dyson_speed) - speed = FanSpeed(f"{int(dyson_speed):04d}") - self.set_dyson_speed(speed) - - def turn_on( - self, - speed: str | None = None, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs, - ) -> None: - """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s with percentage %s", self.name, percentage) - if preset_mode: - self.set_preset_mode(preset_mode) - elif percentage is None: - # percentage not set, just turn on - self._device.set_configuration(fan_mode=FanMode.FAN) - else: - self.set_percentage(percentage) - - -class DysonPureCoolLinkEntity(DysonFanEntity): - """Representation of a Dyson fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureCoolState) - - def turn_off(self, **kwargs) -> None: - """Turn off the fan.""" - _LOGGER.debug("Turn off fan %s", self.name) - self._device.set_configuration(fan_mode=FanMode.OFF) - - def set_dyson_speed(self, speed: FanSpeed) -> None: - """Set the exact speed of the fan.""" - self._device.set_configuration(fan_mode=FanMode.FAN, fan_speed=speed) - - def oscillate(self, oscillating: bool) -> None: - """Turn on/off oscillating.""" - _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) - - if oscillating: - self._device.set_configuration(oscillation=Oscillation.OSCILLATION_ON) - else: - self._device.set_configuration(oscillation=Oscillation.OSCILLATION_OFF) - - @property - def oscillating(self): - """Return the oscillation state.""" - return self._device.state.oscillation == "ON" - - @property - def is_on(self): - """Return true if the entity is on.""" - return self._device.state.fan_mode in ["FAN", "AUTO"] - - def set_night_mode(self, night_mode: bool) -> None: - """Turn fan in night mode.""" - _LOGGER.debug("Set %s night mode %s", self.name, night_mode) - if night_mode: - self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_ON) - else: - self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_OFF) - - @property - def auto_mode(self): - """Return auto mode.""" - return self._device.state.fan_mode == "AUTO" - - def set_auto_mode(self, auto_mode: bool) -> None: - """Turn fan in auto mode.""" - _LOGGER.debug("Set %s auto mode %s", self.name, auto_mode) - if auto_mode: - self._device.set_configuration(fan_mode=FanMode.AUTO) - else: - self._device.set_configuration(fan_mode=FanMode.FAN) - - -class DysonPureCoolEntity(DysonFanEntity): - """Representation of a Dyson Purecool (TP04/DP04) fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureCoolV2State) - - def turn_on( - self, - speed: str | None = None, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs, - ) -> None: - """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s with percentage %s", self.name, percentage) - if preset_mode: - self.set_preset_mode(preset_mode) - elif percentage is None: - # percentage not set, just turn on - self._device.turn_on() - else: - self.set_percentage(percentage) - - def turn_off(self, **kwargs): - """Turn off the fan.""" - _LOGGER.debug("Turn off fan %s", self.name) - self._device.turn_off() - - def set_dyson_speed(self, speed: FanSpeed) -> None: - """Set the exact speed of the purecool fan.""" - self._device.set_fan_speed(speed) - - def oscillate(self, oscillating: bool) -> None: - """Turn on/off oscillating.""" - _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) - - if oscillating: - self._device.enable_oscillation() - else: - self._device.disable_oscillation() - - def set_night_mode(self, night_mode: bool) -> None: - """Turn on/off night mode.""" - _LOGGER.debug("Turn night mode %s for device %s", night_mode, self.name) - - if night_mode: - self._device.enable_night_mode() - else: - self._device.disable_night_mode() - - def set_auto_mode(self, auto_mode: bool) -> None: - """Turn auto mode on/off.""" - _LOGGER.debug("Turn auto mode %s for device %s", auto_mode, self.name) - if auto_mode: - self._device.enable_auto_mode() - else: - self._device.disable_auto_mode() - - def set_angle(self, angle_low: int, angle_high: int) -> None: - """Set device angle.""" - _LOGGER.debug( - "set low %s and high angle %s for device %s", - angle_low, - angle_high, - self.name, - ) - self._device.enable_oscillation(angle_low, angle_high) - - def set_flow_direction_front(self, flow_direction_front: bool) -> None: - """Set frontal airflow direction.""" - _LOGGER.debug( - "Set frontal flow direction to %s for device %s", - flow_direction_front, - self.name, - ) - - if flow_direction_front: - self._device.enable_frontal_direction() - else: - self._device.disable_frontal_direction() - - def set_timer(self, timer) -> None: - """Set timer.""" - _LOGGER.debug("Set timer to %s for device %s", timer, self.name) - - if timer == 0: - self._device.disable_sleep_timer() - else: - self._device.enable_sleep_timer(timer) - - @property - def oscillating(self): - """Return the oscillation state.""" - return self._device.state and self._device.state.oscillation == "OION" - - @property - def is_on(self): - """Return true if the entity is on.""" - return self._device.state.fan_power == "ON" - - @property - def auto_mode(self): - """Return Auto mode.""" - return self._device.state.auto_mode == "ON" - - @property - def angle_low(self): - """Return angle high.""" - return int(self._device.state.oscillation_angle_low) - - @property - def angle_high(self): - """Return angle low.""" - return int(self._device.state.oscillation_angle_high) - - @property - def flow_direction_front(self): - """Return frontal flow direction.""" - return self._device.state.front_direction == "ON" - - @property - def timer(self): - """Return timer.""" - return self._device.state.sleep_timer - - @property - def hepa_filter(self): - """Return the HEPA filter state.""" - return int(self._device.state.hepa_filter_state) - - @property - def carbon_filter(self): - """Return the carbon filter state.""" - if self._device.state.carbon_filter_state == "INV": - return self._device.state.carbon_filter_state - return int(self._device.state.carbon_filter_state) - - @property - def extra_state_attributes(self) -> dict: - """Return optional state attributes.""" - return { - **super().extra_state_attributes, - ATTR_ANGLE_LOW: self.angle_low, - ATTR_ANGLE_HIGH: self.angle_high, - ATTR_FLOW_DIRECTION_FRONT: self.flow_direction_front, - ATTR_TIMER: self.timer, - ATTR_HEPA_FILTER: self.hepa_filter, - ATTR_CARBON_FILTER: self.carbon_filter, - } diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json deleted file mode 100644 index 0f5da0691c4f1..0000000000000 --- a/homeassistant/components/dyson/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "dyson", - "name": "Dyson", - "documentation": "https://www.home-assistant.io/integrations/dyson", - "requirements": ["libpurecool==0.6.4"], - "after_dependencies": ["zeroconf"], - "codeowners": [], - "iot_class": "local_push" -} diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py deleted file mode 100644 index cff4b8f550136..0000000000000 --- a/homeassistant/components/dyson/sensor.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Support for Dyson Pure Cool Link Sensors.""" -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_cool_link import DysonPureCoolLink - -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - STATE_OFF, - TEMP_CELSIUS, - TIME_HOURS, -) - -from . import DYSON_DEVICES, DysonEntity - -SENSOR_ATTRIBUTES = { - "air_quality": {ATTR_ICON: "mdi:fan"}, - "dust": {ATTR_ICON: "mdi:cloud"}, - "humidity": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - "temperature": {ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE}, - "filter_life": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: TIME_HOURS, - }, - "carbon_filter_state": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - "combi_filter_state": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - "hepa_filter_state": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, -} - -SENSOR_NAMES = { - "air_quality": "AQI", - "dust": "Dust", - "humidity": "Humidity", - "temperature": "Temperature", - "filter_life": "Filter Life", - "carbon_filter_state": "Carbon Filter Remaining Life", - "combi_filter_state": "Combi Filter Remaining Life", - "hepa_filter_state": "HEPA Filter Remaining Life", -} - -DYSON_SENSOR_DEVICES = "dyson_sensor_devices" - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson Sensors.""" - - if discovery_info is None: - return - - hass.data.setdefault(DYSON_SENSOR_DEVICES, []) - unit = hass.config.units.temperature_unit - devices = hass.data[DYSON_SENSOR_DEVICES] - - # Get Dyson Devices from parent component - device_ids = [device.unique_id for device in hass.data[DYSON_SENSOR_DEVICES]] - new_entities = [] - for device in hass.data[DYSON_DEVICES]: - if isinstance(device, DysonPureCool): - if f"{device.serial}-temperature" not in device_ids: - new_entities.append(DysonTemperatureSensor(device, unit)) - if f"{device.serial}-humidity" not in device_ids: - new_entities.append(DysonHumiditySensor(device)) - - # For PureCool+Humidify devices, a single filter exists, called "Combi Filter". - # It's reported with the HEPA state, while the Carbon state is set to INValid. - if device.state and device.state.carbon_filter_state == "INV": - if f"{device.serial}-hepa_filter_state" not in device_ids: - new_entities.append(DysonHepaFilterLifeSensor(device, "combi")) - else: - if f"{device.serial}-hepa_filter_state" not in device_ids: - new_entities.append(DysonHepaFilterLifeSensor(device)) - if f"{device.serial}-carbon_filter_state" not in device_ids: - new_entities.append(DysonCarbonFilterLifeSensor(device)) - elif isinstance(device, DysonPureCoolLink): - new_entities.append(DysonFilterLifeSensor(device)) - new_entities.append(DysonDustSensor(device)) - new_entities.append(DysonHumiditySensor(device)) - new_entities.append(DysonTemperatureSensor(device, unit)) - new_entities.append(DysonAirQualitySensor(device)) - - if not new_entities: - return - - devices.extend(new_entities) - add_entities(devices) - - -class DysonSensor(DysonEntity, SensorEntity): - """Representation of a generic Dyson sensor.""" - - def __init__(self, device, sensor_type): - """Create a new generic Dyson sensor.""" - super().__init__(device, None) - self._old_value = None - self._sensor_type = sensor_type - self._attributes = SENSOR_ATTRIBUTES[sensor_type] - - def on_message(self, message): - """Handle new messages which are received from the fan.""" - # Prevent refreshing if not needed - if self._old_value is None or self._old_value != self.state: - self._old_value = self.state - self.schedule_update_ha_state() - - @property - def name(self): - """Return the name of the Dyson sensor name.""" - return f"{super().name} {SENSOR_NAMES[self._sensor_type]}" - - @property - def unique_id(self): - """Return the sensor's unique id.""" - return f"{self._device.serial}-{self._sensor_type}" - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - @property - def icon(self): - """Return the icon for this sensor.""" - return self._attributes.get(ATTR_ICON) - - @property - def device_class(self): - """Return the device class of this sensor.""" - return self._attributes.get(ATTR_DEVICE_CLASS) - - -class DysonFilterLifeSensor(DysonSensor): - """Representation of Dyson Filter Life sensor (in hours).""" - - def __init__(self, device): - """Create a new Dyson Filter Life sensor.""" - super().__init__(device, "filter_life") - - @property - def state(self): - """Return filter life in hours.""" - return int(self._device.state.filter_life) - - -class DysonCarbonFilterLifeSensor(DysonSensor): - """Representation of Dyson Carbon Filter Life sensor (in percent).""" - - def __init__(self, device): - """Create a new Dyson Carbon Filter Life sensor.""" - super().__init__(device, "carbon_filter_state") - - @property - def state(self): - """Return filter life remaining in percent.""" - return int(self._device.state.carbon_filter_state) - - -class DysonHepaFilterLifeSensor(DysonSensor): - """Representation of Dyson HEPA (or Combi) Filter Life sensor (in percent).""" - - def __init__(self, device, filter_type="hepa"): - """Create a new Dyson Filter Life sensor.""" - super().__init__(device, f"{filter_type}_filter_state") - - @property - def state(self): - """Return filter life remaining in percent.""" - return int(self._device.state.hepa_filter_state) - - -class DysonDustSensor(DysonSensor): - """Representation of Dyson Dust sensor (lower is better).""" - - def __init__(self, device): - """Create a new Dyson Dust sensor.""" - super().__init__(device, "dust") - - @property - def state(self): - """Return Dust value.""" - return self._device.environmental_state.dust - - -class DysonHumiditySensor(DysonSensor): - """Representation of Dyson Humidity sensor.""" - - def __init__(self, device): - """Create a new Dyson Humidity sensor.""" - super().__init__(device, "humidity") - - @property - def state(self): - """Return Humidity value.""" - if self._device.environmental_state.humidity == 0: - return STATE_OFF - return self._device.environmental_state.humidity - - -class DysonTemperatureSensor(DysonSensor): - """Representation of Dyson Temperature sensor.""" - - def __init__(self, device, unit): - """Create a new Dyson Temperature sensor.""" - super().__init__(device, "temperature") - self._unit = unit - - @property - def state(self): - """Return Temperature value.""" - temperature_kelvin = self._device.environmental_state.temperature - if temperature_kelvin == 0: - return STATE_OFF - if self._unit == TEMP_CELSIUS: - return float(f"{(temperature_kelvin - 273.15):.1f}") - return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - -class DysonAirQualitySensor(DysonSensor): - """Representation of Dyson Air Quality sensor (lower is better).""" - - def __init__(self, device): - """Create a new Dyson Air Quality sensor.""" - super().__init__(device, "air_quality") - - @property - def state(self): - """Return Air Quality value.""" - return int(self._device.environmental_state.volatil_organic_compounds) diff --git a/homeassistant/components/dyson/services.yaml b/homeassistant/components/dyson/services.yaml deleted file mode 100644 index f96aa9315c1c2..0000000000000 --- a/homeassistant/components/dyson/services.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# Describes the format for available fan services - -set_night_mode: - description: Set the fan in night mode. - fields: - entity_id: - description: Name(s) of the entities to enable/disable night mode - example: "fan.living_room" - night_mode: - description: Night mode status - example: true - -set_auto_mode: - description: Set the fan in auto mode. - fields: - entity_id: - description: Name(s) of the entities to enable/disable auto mode - example: "fan.living_room" - auto_mode: - description: Auto mode status - example: true - -set_angle: - description: Set the oscillation angle of the selected fan(s). - fields: - entity_id: - description: Name(s) of the entities for which to set the angle - example: "fan.living_room" - angle_low: - description: The angle at which the oscillation should start - example: 1 - angle_high: - description: The angle at which the oscillation should end - example: 255 - -set_flow_direction_front: - description: Set the fan flow direction. - fields: - entity_id: - description: Name(s) of the entities to set frontal flow direction for - example: "fan.living_room" - flow_direction_front: - description: Frontal flow direction - example: true - -set_timer: - description: Set the sleep timer. - fields: - entity_id: - description: Name(s) of the entities to set the sleep timer for - example: "fan.living_room" - timer: - description: The value in minutes to set the timer to, 0 to disable it - example: 30 - -set_speed: - description: Set the exact speed of the fan. - fields: - entity_id: - description: Name(s) of the entities to set the speed for - example: "fan.living_room" - dyson_speed: - description: Speed - example: 1 diff --git a/homeassistant/components/dyson/vacuum.py b/homeassistant/components/dyson/vacuum.py deleted file mode 100644 index f4035d33cf3af..0000000000000 --- a/homeassistant/components/dyson/vacuum.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Support for the Dyson 360 eye vacuum cleaner robot.""" -import logging - -from libpurecool.const import Dyson360EyeMode, PowerMode -from libpurecool.dyson_360_eye import Dyson360Eye - -from homeassistant.components.vacuum import ( - SUPPORT_BATTERY, - SUPPORT_FAN_SPEED, - SUPPORT_PAUSE, - SUPPORT_RETURN_HOME, - SUPPORT_STATUS, - SUPPORT_STOP, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - VacuumEntity, -) -from homeassistant.helpers.icon import icon_for_battery_level - -from . import DYSON_DEVICES, DysonEntity - -_LOGGER = logging.getLogger(__name__) - -ATTR_CLEAN_ID = "clean_id" -ATTR_FULL_CLEAN_TYPE = "full_clean_type" -ATTR_POSITION = "position" - -DYSON_360_EYE_DEVICES = "dyson_360_eye_devices" - -SUPPORT_DYSON = ( - SUPPORT_TURN_ON - | SUPPORT_TURN_OFF - | SUPPORT_PAUSE - | SUPPORT_RETURN_HOME - | SUPPORT_FAN_SPEED - | SUPPORT_STATUS - | SUPPORT_BATTERY - | SUPPORT_STOP -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson 360 Eye robot vacuum platform.""" - _LOGGER.debug("Creating new Dyson 360 Eye robot vacuum") - if DYSON_360_EYE_DEVICES not in hass.data: - hass.data[DYSON_360_EYE_DEVICES] = [] - - # Get Dyson Devices from parent component - for device in [d for d in hass.data[DYSON_DEVICES] if isinstance(d, Dyson360Eye)]: - dyson_entity = Dyson360EyeDevice(device) - hass.data[DYSON_360_EYE_DEVICES].append(dyson_entity) - - add_entities(hass.data[DYSON_360_EYE_DEVICES]) - return True - - -class Dyson360EyeDevice(DysonEntity, VacuumEntity): - """Dyson 360 Eye robot vacuum device.""" - - def __init__(self, device): - """Dyson 360 Eye robot vacuum device.""" - super().__init__(device, None) - - @property - def status(self): - """Return the status of the vacuum cleaner.""" - dyson_labels = { - Dyson360EyeMode.INACTIVE_CHARGING: "Stopped - Charging", - Dyson360EyeMode.INACTIVE_CHARGED: "Stopped - Charged", - Dyson360EyeMode.FULL_CLEAN_PAUSED: "Paused", - Dyson360EyeMode.FULL_CLEAN_RUNNING: "Cleaning", - Dyson360EyeMode.FULL_CLEAN_ABORTED: "Returning home", - Dyson360EyeMode.FULL_CLEAN_INITIATED: "Start cleaning", - Dyson360EyeMode.FAULT_USER_RECOVERABLE: "Error - device blocked", - Dyson360EyeMode.FAULT_REPLACE_ON_DOCK: "Error - Replace device on dock", - Dyson360EyeMode.FULL_CLEAN_FINISHED: "Finished", - Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging", - } - return dyson_labels.get(self._device.state.state, self._device.state.state) - - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self._device.state.battery_level - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - speed_labels = {PowerMode.MAX: "Max", PowerMode.QUIET: "Quiet"} - return speed_labels[self._device.state.power_mode] - - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return ["Quiet", "Max"] - - @property - def extra_state_attributes(self): - """Return the specific state attributes of this vacuum cleaner.""" - return {ATTR_POSITION: str(self._device.state.position)} - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._device.state.state in [ - Dyson360EyeMode.FULL_CLEAN_INITIATED, - Dyson360EyeMode.FULL_CLEAN_ABORTED, - Dyson360EyeMode.FULL_CLEAN_RUNNING, - ] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_DYSON - - @property - def battery_icon(self): - """Return the battery icon for the vacuum cleaner.""" - charging = self._device.state.state in [Dyson360EyeMode.INACTIVE_CHARGING] - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - - def turn_on(self, **kwargs): - """Turn the vacuum on.""" - _LOGGER.debug("Turn on device %s", self.name) - if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]: - self._device.resume() - else: - self._device.start() - - def turn_off(self, **kwargs): - """Turn the vacuum off and return to home.""" - _LOGGER.debug("Turn off device %s", self.name) - self._device.pause() - - def stop(self, **kwargs): - """Stop the vacuum cleaner.""" - _LOGGER.debug("Stop device %s", self.name) - self._device.pause() - - def set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - _LOGGER.debug("Set fan speed %s on device %s", fan_speed, self.name) - power_modes = {"Quiet": PowerMode.QUIET, "Max": PowerMode.MAX} - self._device.set_power_mode(power_modes[fan_speed]) - - def start_pause(self, **kwargs): - """Start, pause or resume the cleaning task.""" - if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]: - _LOGGER.debug("Resume device %s", self.name) - self._device.resume() - elif self._device.state.state in [ - Dyson360EyeMode.INACTIVE_CHARGED, - Dyson360EyeMode.INACTIVE_CHARGING, - ]: - _LOGGER.debug("Start device %s", self.name) - self._device.start() - else: - _LOGGER.debug("Pause device %s", self.name) - self._device.pause() - - def return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - _LOGGER.debug("Return to base device %s", self.name) - self._device.abort() diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index b3d726f9cd3c4..40059f71f1a11 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -8,6 +8,8 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_METERS from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -121,13 +123,13 @@ def unique_id(self): @property def device_info(self): """Return the device info.""" - return { - "identifiers": {(DOMAIN, "measure-id", self.station_id)}, - "name": self.name, - "manufacturer": "https://environment.data.gov.uk/", - "model": self.parameter_name, - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, "measure-id", self.station_id)}, + manufacturer="https://environment.data.gov.uk/", + model=self.parameter_name, + name=self.name, + ) @property def available(self) -> bool: @@ -149,7 +151,7 @@ def available(self) -> bool: return True @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return units for the sensor.""" measure = self.coordinator.data["measures"][self.key] if "unit" not in measure: @@ -162,6 +164,6 @@ def extra_state_attributes(self): return {ATTR_ATTRIBUTION: self.attribution} @property - def state(self): + def native_value(self): """Return the current sensor value.""" return self.coordinator.data["measures"][self.key]["latestReading"]["value"] diff --git a/homeassistant/components/eafm/translations/bg.json b/homeassistant/components/eafm/translations/bg.json new file mode 100644 index 0000000000000..37b6f40c82e80 --- /dev/null +++ b/homeassistant/components/eafm/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/de.json b/homeassistant/components/eafm/translations/de.json index 9bb9fda51bfc2..c82a21b1c3e01 100644 --- a/homeassistant/components/eafm/translations/de.json +++ b/homeassistant/components/eafm/translations/de.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "no_stations": "Keine Hochwassermessstellen gefunden." }, "step": { "user": { "data": { "station": "Station" }, - "description": "W\u00e4hlen Sie die Station aus, die Sie \u00fcberwachen m\u00f6chten", - "title": "Verfolgen Sie eine Hochwasser\u00fcberwachungsstation" + "description": "W\u00e4hle die Station aus, die du \u00fcberwachen m\u00f6chtest", + "title": "Verfolge eine Hochwasser\u00fcberwachungsstation" } } } diff --git a/homeassistant/components/eafm/translations/es-419.json b/homeassistant/components/eafm/translations/es-419.json new file mode 100644 index 0000000000000..52757f0a1d552 --- /dev/null +++ b/homeassistant/components/eafm/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "no_stations": "No se encontraron estaciones de monitoreo de inundaciones." + }, + "step": { + "user": { + "data": { + "station": "Estaci\u00f3n" + }, + "description": "Seleccione la estaci\u00f3n que desea monitorear", + "title": "Seguimiento de una estaci\u00f3n de monitoreo de inundaciones" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/he.json b/homeassistant/components/eafm/translations/he.json new file mode 100644 index 0000000000000..d32dde2f05561 --- /dev/null +++ b/homeassistant/components/eafm/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "station": "\u05ea\u05d7\u05e0\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/hu.json b/homeassistant/components/eafm/translations/hu.json index 3b2d79a34a77e..820958e4e6ed1 100644 --- a/homeassistant/components/eafm/translations/hu.json +++ b/homeassistant/components/eafm/translations/hu.json @@ -1,7 +1,17 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "no_stations": "Nem tal\u00e1lhat\u00f3 \u00e1rv\u00edzfigyel\u0151 \u00e1llom\u00e1s." + }, + "step": { + "user": { + "data": { + "station": "\u00c1llom\u00e1s" + }, + "description": "V\u00e1lassza ki a figyelni k\u00edv\u00e1nt \u00e1llom\u00e1st", + "title": "\u00c1rv\u00edzfigyel\u0151 \u00e1llom\u00e1s nyomon k\u00f6vet\u00e9se" + } } } } \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/ja.json b/homeassistant/components/eafm/translations/ja.json new file mode 100644 index 0000000000000..aff9730fcf9ce --- /dev/null +++ b/homeassistant/components/eafm/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_stations": "\u6d2a\u6c34\u76e3\u8996(Track a flood monitoring)\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "station": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" + }, + "description": "\u76e3\u8996\u3057\u305f\u3044\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u6d2a\u6c34\u76e3\u8996(Track a flood monitoring)\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u8ffd\u8de1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/tr.json b/homeassistant/components/eafm/translations/tr.json index 4ed0f406e57ce..6755aa58de2b9 100644 --- a/homeassistant/components/eafm/translations/tr.json +++ b/homeassistant/components/eafm/translations/tr.json @@ -9,6 +9,7 @@ "data": { "station": "\u0130stasyon" }, + "description": "\u0130zlemek istedi\u011finiz istasyonu se\u00e7in", "title": "Ak\u0131\u015f izleme istasyonunu takip edin" } } diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 72d169f389eab..7ec453b7c3ad6 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -3,6 +3,8 @@ Get data from 'My Usage Page' page: https://client.ebox.ca/myusage """ +from __future__ import annotations + from datetime import timedelta import logging @@ -10,7 +12,11 @@ from pyebox.client import PyEboxError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -34,30 +40,94 @@ SCAN_INTERVAL = timedelta(minutes=15) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -SENSOR_TYPES = { - "usage": ["Usage", PERCENTAGE, "mdi:percent"], - "balance": ["Balance", PRICE, "mdi:cash-usd"], - "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], - "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], - "before_offpeak_download": [ - "Download before offpeak", - DATA_GIGABITS, - "mdi:download", - ], - "before_offpeak_upload": ["Upload before offpeak", DATA_GIGABITS, "mdi:upload"], - "before_offpeak_total": ["Total before offpeak", DATA_GIGABITS, "mdi:download"], - "offpeak_download": ["Offpeak download", DATA_GIGABITS, "mdi:download"], - "offpeak_upload": ["Offpeak Upload", DATA_GIGABITS, "mdi:upload"], - "offpeak_total": ["Offpeak Total", DATA_GIGABITS, "mdi:download"], - "download": ["Download", DATA_GIGABITS, "mdi:download"], - "upload": ["Upload", DATA_GIGABITS, "mdi:upload"], - "total": ["Total", DATA_GIGABITS, "mdi:download"], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="usage", + name="Usage", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + SensorEntityDescription( + key="balance", + name="Balance", + native_unit_of_measurement=PRICE, + icon="mdi:cash", + ), + SensorEntityDescription( + key="limit", + name="Data limit", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="days_left", + name="Days left", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:calendar-today", + ), + SensorEntityDescription( + key="before_offpeak_download", + name="Download before offpeak", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="before_offpeak_upload", + name="Upload before offpeak", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + SensorEntityDescription( + key="before_offpeak_total", + name="Total before offpeak", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="offpeak_download", + name="Offpeak download", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="offpeak_upload", + name="Offpeak Upload", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + SensorEntityDescription( + key="offpeak_total", + name="Offpeak Total", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="download", + name="Download", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="upload", + name="Upload", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + SensorEntityDescription( + key="total", + name="Total", + native_unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), +) + +SENSOR_TYPE_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_TYPE_KEYS)] ), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -82,9 +152,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Failed login: %s", exp) raise PlatformNotReady from exp - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(EBoxSensor(ebox_data, variable, name)) + sensors = [ + EBoxSensor(ebox_data, description, name) + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_VARIABLES] + ] async_add_entities(sensors, True) @@ -92,41 +164,24 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class EBoxSensor(SensorEntity): """Implementation of a EBox sensor.""" - def __init__(self, ebox_data, sensor_type, name): + def __init__( + self, + ebox_data, + description: SensorEntityDescription, + name, + ): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.ebox_data = ebox_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon async def async_update(self): """Get the latest data from EBox and update the state.""" await self.ebox_data.async_update() - if self.type in self.ebox_data.data: - self._state = round(self.ebox_data.data[self.type], 2) + if self.entity_description.key in self.ebox_data.data: + self._attr_native_value = round( + self.ebox_data.data[self.entity_description.key], 2 + ) class EBoxData: diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index c15cf8d4eaf46..ac5ca313f2f5a 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,6 +1,8 @@ """Constants for ebus component.""" +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, + PERCENTAGE, PRESSURE_BAR, TEMP_CELSIUS, TIME_SECONDS, @@ -16,125 +18,254 @@ "ActualFlowTemperatureDesired": [ "Hc1ActualFlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, ], "MaxFlowTemperatureDesired": [ "Hc1MaxFlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, ], "MinFlowTemperatureDesired": [ "Hc1MinFlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, ], - "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2], + "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None], "HCSummerTemperatureLimit": [ "Hc1SummerTempLimit", TEMP_CELSIUS, "mdi:weather-sunny", 0, + SensorDeviceClass.TEMPERATURE, + ], + "HolidayTemperature": [ + "HolidayTemp", + TEMP_CELSIUS, + None, + 0, + SensorDeviceClass.TEMPERATURE, + ], + "HWTemperatureDesired": [ + "HwcTempDesired", + TEMP_CELSIUS, + None, + 0, + SensorDeviceClass.TEMPERATURE, + ], + "HWActualTemperature": [ + "HwcStorageTemp", + TEMP_CELSIUS, + None, + 0, + SensorDeviceClass.TEMPERATURE, + ], + "HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None], + "HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None], + "HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None], + "HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None], + "HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None], + "HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None], + "HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None], + "HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None], + "WaterPressure": ["WaterPressure", PRESSURE_BAR, "mdi:water-pump", 0, None], + "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None], + "Zone1NightTemperature": [ + "z1NightTemp", + TEMP_CELSIUS, + "mdi:weather-night", + 0, + SensorDeviceClass.TEMPERATURE, + ], + "Zone1DayTemperature": [ + "z1DayTemp", + TEMP_CELSIUS, + "mdi:weather-sunny", + 0, + SensorDeviceClass.TEMPERATURE, ], - "HolidayTemperature": ["HolidayTemp", TEMP_CELSIUS, "mdi:thermometer", 0], - "HWTemperatureDesired": ["HwcTempDesired", TEMP_CELSIUS, "mdi:thermometer", 0], - "HWActualTemperature": ["HwcStorageTemp", TEMP_CELSIUS, "mdi:thermometer", 0], - "HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1], - "HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1], - "HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1], - "HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1], - "HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1], - "HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1], - "HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1], - "HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3], - "WaterPressure": ["WaterPressure", PRESSURE_BAR, "mdi:water-pump", 0], - "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0], - "Zone1NightTemperature": ["z1NightTemp", TEMP_CELSIUS, "mdi:weather-night", 0], - "Zone1DayTemperature": ["z1DayTemp", TEMP_CELSIUS, "mdi:weather-sunny", 0], "Zone1HolidayTemperature": [ "z1HolidayTemp", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, + ], + "Zone1RoomTemperature": [ + "z1RoomTemp", + TEMP_CELSIUS, + None, + 0, + SensorDeviceClass.TEMPERATURE, ], - "Zone1RoomTemperature": ["z1RoomTemp", TEMP_CELSIUS, "mdi:thermometer", 0], "Zone1ActualRoomTemperatureDesired": [ "z1ActualRoomTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, + ], + "Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None], + "Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None], + "Zone1TimerWednesday": [ + "z1Timer.Wednesday", + None, + "mdi:timer-outline", + 1, + None, + ], + "Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None], + "Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None], + "Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None], + "Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None], + "Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None], + "ContinuosHeating": [ + "ContinuosHeating", + TEMP_CELSIUS, + "mdi:weather-snowy", + 0, + SensorDeviceClass.TEMPERATURE, ], - "Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1], - "Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1], - "Zone1TimerWednesday": ["z1Timer.Wednesday", None, "mdi:timer-outline", 1], - "Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1], - "Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1], - "Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1], - "Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1], - "Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3], - "ContinuosHeating": ["ContinuosHeating", TEMP_CELSIUS, "mdi:weather-snowy", 0], "PowerEnergyConsumptionLastMonth": [ "PrEnergySumHcLastMonth", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, ], "PowerEnergyConsumptionThisMonth": [ "PrEnergySumHcThisMonth", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, ], }, "ehp": { - "HWTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "OutsideTemp": ["OutsideTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "HWTemperature": [ + "HwcTemp", + TEMP_CELSIUS, + None, + 4, + SensorDeviceClass.TEMPERATURE, + ], + "OutsideTemp": [ + "OutsideTemp", + TEMP_CELSIUS, + None, + 4, + SensorDeviceClass.TEMPERATURE, + ], }, "bai": { - "HotWaterTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "StorageTemperature": ["StorageTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "HotWaterTemperature": [ + "HwcTemp", + TEMP_CELSIUS, + None, + 4, + SensorDeviceClass.TEMPERATURE, + ], + "StorageTemperature": [ + "StorageTemp", + TEMP_CELSIUS, + None, + 4, + SensorDeviceClass.TEMPERATURE, + ], "DesiredStorageTemperature": [ "StorageTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, ], "OutdoorsTemperature": [ "OutdoorstempSensor", TEMP_CELSIUS, - "mdi:thermometer", + None, + 4, + SensorDeviceClass.TEMPERATURE, + ], + "WaterPreasure": ["WaterPressure", PRESSURE_BAR, "mdi:pipe", 4, None], + "AverageIgnitionTime": [ + "averageIgnitiontime", + TIME_SECONDS, + "mdi:av-timer", + 0, + None, + ], + "MaximumIgnitionTime": [ + "maxIgnitiontime", + TIME_SECONDS, + "mdi:av-timer", + 0, + None, + ], + "MinimumIgnitionTime": [ + "minIgnitiontime", + TIME_SECONDS, + "mdi:av-timer", + 0, + None, + ], + "ReturnTemperature": [ + "ReturnTemp", + TEMP_CELSIUS, + None, 4, + SensorDeviceClass.TEMPERATURE, ], - "WaterPreasure": ["WaterPressure", PRESSURE_BAR, "mdi:pipe", 4], - "AverageIgnitionTime": ["averageIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], - "MaximumIgnitionTime": ["maxIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], - "MinimumIgnitionTime": ["minIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], - "ReturnTemperature": ["ReturnTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2], - "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2], + "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None], + "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None], "DesiredFlowTemperature": [ "FlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + SensorDeviceClass.TEMPERATURE, + ], + "FlowTemperature": [ + "FlowTemp", + TEMP_CELSIUS, + None, + 4, + SensorDeviceClass.TEMPERATURE, ], - "FlowTemperature": ["FlowTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "Flame": ["Flame", None, "mdi:toggle-switch", 2], + "Flame": ["Flame", None, "mdi:toggle-switch", 2, None], "PowerEnergyConsumptionHeatingCircuit": [ "PrEnergySumHc1", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, ], "PowerEnergyConsumptionHotWaterCircuit": [ "PrEnergySumHwc1", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, + ], + "RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None], + "HeatingPartLoad": [ + "PartloadHcKW", + ENERGY_KILO_WATT_HOUR, + "mdi:flash", + 0, + None, + ], + "StateNumber": ["StateNumber", None, "mdi:fire", 3, None], + "ModulationPercentage": [ + "ModulationTempDesired", + PERCENTAGE, + "mdi:percent", + 0, + None, ], - "RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2], - "HeatingPartLoad": ["PartloadHcKW", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0], }, } diff --git a/homeassistant/components/ebusd/manifest.json b/homeassistant/components/ebusd/manifest.json index 347fee0bc8557..390e8efe7d5f4 100644 --- a/homeassistant/components/ebusd/manifest.json +++ b/homeassistant/components/ebusd/manifest.json @@ -2,7 +2,7 @@ "domain": "ebusd", "name": "ebusd", "documentation": "https://www.home-assistant.io/integrations/ebusd", - "requirements": ["ebusdpy==0.0.16"], + "requirements": ["ebusdpy==0.0.17"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index 00f6a6b2b3e46..dcfd4ec7eef4c 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -41,7 +41,13 @@ def __init__(self, data, sensor, name): """Initialize the sensor.""" self._state = None self._client_name = name - self._name, self._unit_of_measurement, self._icon, self._type = sensor + ( + self._name, + self._unit_of_measurement, + self._icon, + self._type, + self._device_class, + ) = sensor self.data = data @property @@ -50,7 +56,7 @@ def name(self): return f"{self._client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -77,13 +83,18 @@ def extra_state_attributes(self): return schedule return None + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class + @property def icon(self): """Icon to use in the frontend, if any.""" return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/ebusd/services.yaml b/homeassistant/components/ebusd/services.yaml index eee9896da108f..dc356bec22618 100644 --- a/homeassistant/components/ebusd/services.yaml +++ b/homeassistant/components/ebusd/services.yaml @@ -1,6 +1,11 @@ write: + name: Write description: Call ebusd write command. fields: call: + name: Call description: Property name and value to set + required: true example: '{"name": "Hc1MaxFlowTempDesired", "value": 21}' + selector: + object: diff --git a/homeassistant/components/ebusd/translations/ja.json b/homeassistant/components/ebusd/translations/ja.json new file mode 100644 index 0000000000000..c43ca27b22a09 --- /dev/null +++ b/homeassistant/components/ebusd/translations/ja.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u65e5", + "night": "\u591c" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/translations/tr.json b/homeassistant/components/ebusd/translations/tr.json new file mode 100644 index 0000000000000..5e802f16a5df7 --- /dev/null +++ b/homeassistant/components/ebusd/translations/tr.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "G\u00fcn", + "night": "Gece" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py index e1c9308b5a950..8ed5129b628ea 100644 --- a/homeassistant/components/ecoal_boiler/sensor.py +++ b/homeassistant/components/ecoal_boiler/sensor.py @@ -1,5 +1,5 @@ """Allows reading temperatures from ecoal/esterownik.pl controller.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import TEMP_CELSIUS from . import AVAILABLE_SENSORS, DATA_ECOAL_BOILER @@ -20,27 +20,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class EcoalTempSensor(SensorEntity): """Representation of a temperature sensor using ecoal status data.""" + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + def __init__(self, ecoal_contr, name, status_attr): """Initialize the sensor.""" self._ecoal_contr = ecoal_contr - self._name = name + self._attr_name = name self._status_attr = status_attr - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS def update(self): """Fetch new state data for the sensor. @@ -49,4 +36,4 @@ def update(self): """ # Old values read 0.5 back can still be used status = self._ecoal_contr.get_cached_status() - self._state = getattr(status, self._status_attr) + self._attr_native_value = getattr(status, self._status_attr) diff --git a/homeassistant/components/ecoal_boiler/switch.py b/homeassistant/components/ecoal_boiler/switch.py index 995a49554e648..e12d4dbf0a2c9 100644 --- a/homeassistant/components/ecoal_boiler/switch.py +++ b/homeassistant/components/ecoal_boiler/switch.py @@ -28,7 +28,7 @@ def __init__(self, ecoal_contr, name, state_attr): Sets HA switch to state as read from controller. """ self._ecoal_contr = ecoal_contr - self._name = name + self._attr_name = name self._state_attr = state_attr # Ecoalcotroller holds convention that same postfix is used # to set attribute @@ -36,13 +36,6 @@ def __init__(self, ecoal_contr, name, state_attr): # as attribute name in status instance: # status. self._contr_set_fun = getattr(self._ecoal_contr, f"set_{state_attr}") - # No value set, will be read from controller instead - self._state = None - - @property - def name(self) -> str | None: - """Return the name of the switch.""" - return self._name def update(self): """Fetch new state data for the sensor. @@ -50,7 +43,7 @@ def update(self): This is the only method that should fetch new data for Home Assistant. """ status = self._ecoal_contr.get_cached_status() - self._state = getattr(status, self._state_attr) + self._attr_is_on = getattr(status, self._state_attr) def invalidate_ecoal_cache(self): """Invalidate ecoal interface cache. @@ -59,11 +52,6 @@ def invalidate_ecoal_cache(self): """ self._ecoal_contr.status = None - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs) -> None: """Turn the device on.""" self._contr_set_fun(1) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 9593fc0e497c2..ae0d395d58ad0 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -1,10 +1,13 @@ """Support for Ecobee binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_OCCUPANCY, + BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.helpers.entity import DeviceInfo -from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER +from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER async def async_setup_entry(hass, config_entry, async_add_entities): @@ -49,7 +52,7 @@ def unique_id(self): return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device information for this sensor.""" identifier = None model = None @@ -67,25 +70,25 @@ def device_info(self): f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" ) except KeyError: - _LOGGER.error( - "Model number for ecobee thermostat %s not recognized. " - "Please visit this link and provide the following information: " - "https://github.com/home-assistant/core/issues/27172 " - "Unrecognized model number: %s", - thermostat["name"], - thermostat["modelNumber"], - ) + # Ecobee model is not in our list + model = None break - if identifier is not None and model is not None: - return { - "identifiers": {(DOMAIN, identifier)}, - "name": self.sensor_name, - "manufacturer": MANUFACTURER, - "model": model, - } + if identifier is not None: + return DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer=MANUFACTURER, + model=model, + name=self.sensor_name, + ) return None + @property + def available(self): + """Return true if device is available.""" + thermostat = self.data.ecobee.get_thermostat(self.index) + return thermostat["runtime"]["connected"] + @property def is_on(self): """Return the status of the sensor.""" @@ -94,7 +97,7 @@ def is_on(self): @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return DEVICE_CLASS_OCCUPANCY + return BinarySensorDeviceClass.OCCUPANCY async def async_update(self): """Get the latest state of the sensor.""" diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 9e9e2eff1c88d..473ba0cdd58d2 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -32,12 +32,14 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, + PRECISION_HALVES, PRECISION_TENTHS, STATE_ON, TEMP_FAHRENHEIT, ) from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util.temperature import convert from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -176,10 +178,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the ecobee thermostat.""" data = hass.data[DOMAIN] + entities = [] - devices = [Thermostat(data, index) for index in range(len(data.ecobee.thermostats))] + for index in range(len(data.ecobee.thermostats)): + thermostat = data.ecobee.get_thermostat(index) + if not thermostat["modelNumber"] in ECOBEE_MODEL_TO_NAME: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link to open a new issue: " + "https://github.com/home-assistant/core/issues " + "and include the following information: " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + entities.append(Thermostat(data, index, thermostat)) - async_add_entities(devices, True) + async_add_entities(entities, True) platform = entity_platform.async_get_current_platform() @@ -187,7 +202,7 @@ def create_vacation_service(service): """Create a vacation on the target thermostat.""" entity_id = service.data[ATTR_ENTITY_ID] - for thermostat in devices: + for thermostat in entities: if thermostat.entity_id == entity_id: thermostat.create_vacation(service.data) thermostat.schedule_update_ha_state(True) @@ -198,7 +213,7 @@ def delete_vacation_service(service): entity_id = service.data[ATTR_ENTITY_ID] vacation_name = service.data[ATTR_VACATION_NAME] - for thermostat in devices: + for thermostat in entities: if thermostat.entity_id == entity_id: thermostat.delete_vacation(vacation_name) thermostat.schedule_update_ha_state(True) @@ -211,10 +226,10 @@ def fan_min_on_time_set_service(service): if entity_id: target_thermostats = [ - device for device in devices if device.entity_id in entity_id + entity for entity in entities if entity.entity_id in entity_id ] else: - target_thermostats = devices + target_thermostats = entities for thermostat in target_thermostats: thermostat.set_fan_min_on_time(str(fan_min_on_time)) @@ -228,10 +243,10 @@ def resume_program_set_service(service): if entity_id: target_thermostats = [ - device for device in devices if device.entity_id in entity_id + entity for entity in entities if entity.entity_id in entity_id ] else: - target_thermostats = devices + target_thermostats = entities for thermostat in target_thermostats: thermostat.resume_program(resume_all) @@ -291,11 +306,11 @@ def resume_program_set_service(service): class Thermostat(ClimateEntity): """A thermostat class for Ecobee.""" - def __init__(self, data, thermostat_index): + def __init__(self, data, thermostat_index, thermostat): """Initialize the thermostat.""" self.data = data self.thermostat_index = thermostat_index - self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) + self.thermostat = thermostat self._name = self.thermostat["name"] self.vacation = None self._last_active_hvac_mode = HVAC_MODE_HEAT_COOL @@ -353,27 +368,21 @@ def unique_id(self): return self.thermostat["identifier"] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information for this ecobee thermostat.""" + model: str | None try: model = f"{ECOBEE_MODEL_TO_NAME[self.thermostat['modelNumber']]} Thermostat" except KeyError: - _LOGGER.error( - "Model number for ecobee thermostat %s not recognized. " - "Please visit this link and provide the following information: " - "https://github.com/home-assistant/core/issues/27172 " - "Unrecognized model number: %s", - self.name, - self.thermostat["modelNumber"], - ) - return None - - return { - "identifiers": {(DOMAIN, self.thermostat["identifier"])}, - "name": self.name, - "manufacturer": MANUFACTURER, - "model": model, - } + # Ecobee model is not in our list + model = None + + return DeviceInfo( + identifiers={(DOMAIN, self.thermostat["identifier"])}, + manufacturer=MANUFACTURER, + model=model, + name=self.name, + ) @property def temperature_unit(self): @@ -386,24 +395,29 @@ def precision(self) -> float: return PRECISION_TENTHS @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self.thermostat["runtime"]["actualTemperature"] / 10.0 @property - def target_temperature_low(self): + def target_temperature_low(self) -> float | None: """Return the lower bound temperature we try to reach.""" if self.hvac_mode == HVAC_MODE_HEAT_COOL: - return round(self.thermostat["runtime"]["desiredHeat"] / 10.0) + return self.thermostat["runtime"]["desiredHeat"] / 10.0 return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the upper bound temperature we try to reach.""" if self.hvac_mode == HVAC_MODE_HEAT_COOL: - return round(self.thermostat["runtime"]["desiredCool"] / 10.0) + return self.thermostat["runtime"]["desiredCool"] / 10.0 return None + @property + def target_temperature_step(self) -> float: + """Set target temperature step to halves.""" + return PRECISION_HALVES + @property def has_humidifier_control(self): """Return true if humidifier connected to thermostat and set to manual/on mode.""" @@ -430,14 +444,14 @@ def max_humidity(self) -> int: return DEFAULT_MAX_HUMIDITY @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVAC_MODE_HEAT_COOL: return None if self.hvac_mode == HVAC_MODE_HEAT: - return round(self.thermostat["runtime"]["desiredHeat"] / 10.0) + return self.thermostat["runtime"]["desiredHeat"] / 10.0 if self.hvac_mode == HVAC_MODE_COOL: - return round(self.thermostat["runtime"]["desiredCool"] / 10.0) + return self.thermostat["runtime"]["desiredCool"] / 10.0 return None @property @@ -670,11 +684,11 @@ def set_temp_hold(self, temp): heatCoolMinDelta property. https://www.ecobee.com/home/developer/api/examples/ex5.shtml """ - if self.hvac_mode == HVAC_MODE_HEAT or self.hvac_mode == HVAC_MODE_COOL: + if self.hvac_mode in (HVAC_MODE_HEAT, HVAC_MODE_COOL): heat_temp = temp cool_temp = temp else: - delta = self.thermostat["settings"]["heatCoolMinDelta"] / 10 + delta = self.thermostat["settings"]["heatCoolMinDelta"] / 10.0 heat_temp = temp - delta cool_temp = temp + delta self.set_auto_temp_hold(heat_temp, cool_temp) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index caf25690a9dbf..50dd606ad254f 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -14,6 +14,7 @@ ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ) +from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) @@ -37,7 +38,13 @@ "vulcanSmart": "ecobee4 Smart", } -PLATFORMS = ["binary_sensor", "climate", "humidifier", "sensor", "weather"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.HUMIDIFIER, + Platform.SENSOR, + Platform.WEATHER, +] MANUFACTURER = "ecobee" diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index 5067d5080cbab..a98b3712dd3d5 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -1,16 +1,18 @@ """Support for using humidifier with ecobee thermostats.""" +from __future__ import annotations + from datetime import timedelta -from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity from homeassistant.components.humidifier.const import ( DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DEVICE_CLASS_HUMIDIFIER, MODE_AUTO, SUPPORT_MODES, ) +from homeassistant.helpers.entity import DeviceInfo -from .const import DOMAIN +from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER SCAN_INTERVAL = timedelta(minutes=3) @@ -43,6 +45,38 @@ def __init__(self, data, thermostat_index): self.update_without_throttle = False + @property + def name(self): + """Return the name of the humidifier.""" + return self._name + + @property + def unique_id(self): + """Return unique_id for humidifier.""" + return f"{self.thermostat['identifier']}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information for the ecobee humidifier.""" + model: str | None + try: + model = f"{ECOBEE_MODEL_TO_NAME[self.thermostat['modelNumber']]} Thermostat" + except KeyError: + # Ecobee model is not in our list + model = None + + return DeviceInfo( + identifiers={(DOMAIN, self.thermostat["identifier"])}, + manufacturer=MANUFACTURER, + model=model, + name=self.name, + ) + + @property + def available(self): + """Return if device is available.""" + return self.thermostat["runtime"]["connected"] + async def async_update(self): """Get the latest state from the thermostat.""" if self.update_without_throttle: @@ -62,7 +96,7 @@ def available_modes(self): @property def device_class(self): """Return the device class type.""" - return DEVICE_CLASS_HUMIDIFIER + return HumidifierDeviceClass.HUMIDIFIER @property def is_on(self): @@ -84,11 +118,6 @@ def mode(self): """Return the current mode, e.g., off, auto, manual.""" return self.thermostat["settings"]["humidifierMode"] - @property - def name(self): - """Return the name of the ecobee thermostat.""" - return self._name - @property def supported_features(self): """Return the list of supported features.""" diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index c1d11a8ee7b04..a22ec48da90c1 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -3,7 +3,18 @@ "name": "ecobee", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", - "requirements": ["python-ecobee-api==0.2.11"], - "codeowners": ["@marthoc"], + "requirements": [ + "python-ecobee-api==0.2.14" + ], + "codeowners": [ + "@marthoc" + ], + "homekit": { + "models": ["EB-*", "ecobee*"] + }, + "zeroconf": [ + {"type":"_sideplay._tcp.local.", "properties": {"mdl":"eb-*"}}, + {"type":"_sideplay._tcp.local.", "properties": {"mdl":"ecobee*"}} + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 5abe809e59d4b..690a0110477e4 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -1,54 +1,66 @@ """Support for Ecobee sensors.""" +from __future__ import annotations + from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_FAHRENHEIT, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, TEMP_FAHRENHEIT +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), ) - -from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER - -SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_FAHRENHEIT], - "humidity": ["Humidity", PERCENTAGE], -} async def async_setup_entry(hass, config_entry, async_add_entities): """Set up ecobee (temperature and humidity) sensors.""" data = hass.data[DOMAIN] - dev = [] - for index in range(len(data.ecobee.thermostats)): - for sensor in data.ecobee.get_remote_sensors(index): - for item in sensor["capability"]: - if item["type"] not in ("temperature", "humidity"): - continue + entities = [ + EcobeeSensor(data, sensor["name"], index, description) + for index in range(len(data.ecobee.thermostats)) + for sensor in data.ecobee.get_remote_sensors(index) + for item in sensor["capability"] + for description in SENSOR_TYPES + if description.key == item["type"] + ] - dev.append(EcobeeSensor(data, sensor["name"], item["type"], index)) - - async_add_entities(dev, True) + async_add_entities(entities, True) class EcobeeSensor(SensorEntity): """Representation of an Ecobee sensor.""" - def __init__(self, data, sensor_name, sensor_type, sensor_index): + def __init__( + self, data, sensor_name, sensor_index, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}" self.sensor_name = sensor_name - self.type = sensor_type self.index = sensor_index self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - @property - def name(self): - """Return the name of the Ecobee sensor.""" - return self._name + self._attr_name = f"{sensor_name} {description.name}" @property def unique_id(self): @@ -61,7 +73,7 @@ def unique_id(self): return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device information for this sensor.""" identifier = None model = None @@ -79,52 +91,40 @@ def device_info(self): f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" ) except KeyError: - _LOGGER.error( - "Model number for ecobee thermostat %s not recognized. " - "Please visit this link and provide the following information: " - "https://github.com/home-assistant/core/issues/27172 " - "Unrecognized model number: %s", - thermostat["name"], - thermostat["modelNumber"], - ) + # Ecobee model is not in our list + model = None break if identifier is not None and model is not None: - return { - "identifiers": {(DOMAIN, identifier)}, - "name": self.sensor_name, - "manufacturer": MANUFACTURER, - "model": model, - } + return DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer=MANUFACTURER, + model=model, + name=self.sensor_name, + ) return None @property - def device_class(self): - """Return the device class of the sensor.""" - if self.type in (DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE): - return self.type - return None + def available(self): + """Return true if device is available.""" + thermostat = self.data.ecobee.get_thermostat(self.index) + return thermostat["runtime"]["connected"] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._state in [ + if self._state in ( ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN, "unknown", - ]: + ): return None - if self.type == "temperature": + if self.entity_description.key == "temperature": return float(self._state) / 10 return self._state - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest state of the sensor.""" await self.data.update() @@ -132,7 +132,7 @@ async def async_update(self): if sensor["name"] != self.sensor_name: continue for item in sensor["capability"]: - if item["type"] != self.type: + if item["type"] != self.entity_description.key: continue self._state = item["value"] break diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml index d88088849b1bd..aba579891195b 100644 --- a/homeassistant/components/ecobee/services.yaml +++ b/homeassistant/components/ecobee/services.yaml @@ -9,7 +9,6 @@ create_vacation: name: Entity description: ecobee thermostat on which to create the vacation. required: true - example: "climate.kitchen" selector: entity: integration: ecobee @@ -25,7 +24,6 @@ create_vacation: name: Cool temperature description: Cooling temperature during the vacation. required: true - example: 23 selector: number: min: 7 @@ -36,7 +34,6 @@ create_vacation: name: Heat temperature description: Heating temperature during the vacation. required: true - example: 25 selector: number: min: 7 @@ -74,7 +71,6 @@ create_vacation: fan_mode: name: Fan mode description: Fan mode of the thermostat during the vacation. - example: "on" default: "auto" selector: select: @@ -84,7 +80,6 @@ create_vacation: fan_min_on_time: name: Fan minimum on time description: Minimum number of minutes to run the fan each hour (0 to 60) during the vacation. - example: 30 default: 0 selector: number: @@ -121,7 +116,6 @@ resume_program: entity_id: name: Entity description: Name(s) of entities to change. - example: "climate.kitchen" selector: entity: integration: ecobee @@ -129,7 +123,6 @@ resume_program: resume_all: name: Resume all description: Resume all events and return to the scheduled program. - example: true default: false selector: boolean: @@ -141,7 +134,6 @@ set_fan_min_on_time: entity_id: name: Entity description: Name(s) of entities to change. - example: "climate.kitchen" selector: entity: integration: ecobee @@ -150,7 +142,6 @@ set_fan_min_on_time: name: Fan minimum on time description: New value of fan min on time. required: true - example: 5 selector: number: min: 0 @@ -169,7 +160,6 @@ set_dst_mode: name: Daylight savings time enabled description: Enable automatic daylight savings time. required: true - example: "true" selector: boolean: @@ -185,7 +175,6 @@ set_mic_mode: name: Mic enabled description: Enable Alexa mic. required: true - example: "true" selector: boolean: @@ -200,12 +189,10 @@ set_occupancy_modes: auto_away: name: Auto away description: Enable Smart Home/Away mode. - example: "true" selector: boolean: follow_me: name: Follow me description: Enable Follow Me mode. - example: "true" selector: boolean: diff --git a/homeassistant/components/ecobee/translations/de.json b/homeassistant/components/ecobee/translations/de.json index 0c89a696b2c41..10edbd4ecd162 100644 --- a/homeassistant/components/ecobee/translations/de.json +++ b/homeassistant/components/ecobee/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits eingerichtet. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "pin_request_failed": "Fehler beim Anfordern der PIN von ecobee; Bitte \u00fcberpr\u00fcfe, ob der API-Schl\u00fcssel korrekt ist.", @@ -14,7 +14,7 @@ }, "user": { "data": { - "api_key": "API Schl\u00fcssel" + "api_key": "API-Schl\u00fcssel" }, "description": "Bitte gib den von ecobee.com erhaltenen API-Schl\u00fcssel ein.", "title": "ecobee API-Schl\u00fcssel" diff --git a/homeassistant/components/ecobee/translations/en_GB.json b/homeassistant/components/ecobee/translations/en_GB.json new file mode 100644 index 0000000000000..21fc733743c13 --- /dev/null +++ b/homeassistant/components/ecobee/translations/en_GB.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "authorize": { + "description": "Please authorise this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, press Submit.", + "title": "Authorise app on ecobee.com" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/fr.json b/homeassistant/components/ecobee/translations/fr.json index acbc909d88114..8f8d0c42b59a6 100644 --- a/homeassistant/components/ecobee/translations/fr.json +++ b/homeassistant/components/ecobee/translations/fr.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez entrer la cl\u00e9 API obtenue aupr\u00e8s d'ecobee.com.", "title": "Cl\u00e9 API ecobee" diff --git a/homeassistant/components/ecobee/translations/he.json b/homeassistant/components/ecobee/translations/he.json new file mode 100644 index 0000000000000..bafbfda24e84a --- /dev/null +++ b/homeassistant/components/ecobee/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/ja.json b/homeassistant/components/ecobee/translations/ja.json new file mode 100644 index 0000000000000..73ac4cd16112d --- /dev/null +++ b/homeassistant/components/ecobee/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "pin_request_failed": "ecobee\u304b\u3089\u306ePIN\u30ea\u30af\u30a8\u30b9\u30c8\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f; API\u30ad\u30fc\u304c\u6b63\u3057\u3044\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "token_request_failed": "ecobee\u304b\u3089\u306e\u30c8\u30fc\u30af\u30f3\u306e\u30ea\u30af\u30a8\u30b9\u30c8\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "authorize": { + "description": "\u3053\u306e\u30a2\u30d7\u30ea\u3092 https://www.ecobee.com/consumerportal/index.html \u3067PIN\u30b3\u30fc\u30c9\u3067\u8a8d\u8a3c\u3057\u3066\u304f\u3060\u3055\u3044\u3002 \n\n {pin}\n\n\u6b21\u306b\u3001\u9001\u4fe1(submit) \u3092\u62bc\u3057\u307e\u3059\u3002", + "title": "ecobee.com\u306e\u30a2\u30d7\u30ea\u3092\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "ecobee.com \u304b\u3089\u53d6\u5f97\u3057\u305fAPI\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "ecobee API\u30ad\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/ru.json b/homeassistant/components/ecobee/translations/ru.json index f983a80b38949..2bd6ee3ea56ef 100644 --- a/homeassistant/components/ecobee/translations/ru.json +++ b/homeassistant/components/ecobee/translations/ru.json @@ -9,7 +9,7 @@ }, "step": { "authorize": { - "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://www.ecobee.com/consumerportal/index.html \u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e PIN-\u043a\u043e\u0434\u0430: \n\n {pin} \n \n \u0417\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c.", + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://www.ecobee.com/consumerportal/index.html \u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e PIN-\u043a\u043e\u0434\u0430: \n\n {pin} \n \n \u0417\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 ''\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c''.", "title": "\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430 ecobee.com" }, "user": { diff --git a/homeassistant/components/ecobee/translations/tr.json b/homeassistant/components/ecobee/translations/tr.json index 23ece38682d11..049af38b51410 100644 --- a/homeassistant/components/ecobee/translations/tr.json +++ b/homeassistant/components/ecobee/translations/tr.json @@ -3,11 +3,21 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "pin_request_failed": "ecobee'den PIN istenirken hata olu\u015ftu; l\u00fctfen API anahtar\u0131n\u0131n do\u011fru oldu\u011funu do\u011frulay\u0131n.", + "token_request_failed": "ecobee'den anahtar istenirken hata olu\u015ftu; l\u00fctfen tekrar deneyin." + }, "step": { + "authorize": { + "description": "L\u00fctfen bu uygulamay\u0131 https://www.ecobee.com/consumerportal/index.html adresinde PIN koduyla yetkilendirin: \n\n {pin}\n\n Ard\u0131ndan G\u00f6nder'e bas\u0131n.", + "title": "Uygulamay\u0131 ecobee.com'da yetkilendirin" + }, "user": { "data": { "api_key": "API Anahtar\u0131" - } + }, + "description": "L\u00fctfen ecobee.com'dan al\u0131nan API anahtar\u0131n\u0131 girin.", + "title": "ecobee API anahtar\u0131" } } } diff --git a/homeassistant/components/ecobee/translations/zh-Hant.json b/homeassistant/components/ecobee/translations/zh-Hant.json index e9789c855d0ea..b46042182063a 100644 --- a/homeassistant/components/ecobee/translations/zh-Hant.json +++ b/homeassistant/components/ecobee/translations/zh-Hant.json @@ -4,8 +4,8 @@ "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "error": { - "pin_request_failed": "ecobee \u6240\u9700\u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u8a8d\u5bc6\u9470\u6b63\u78ba\u6027\u3002", - "token_request_failed": "ecobee \u6240\u9700\u5bc6\u9470\u932f\u8aa4\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + "pin_request_failed": "ecobee \u6240\u9700\u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u8a8d\u91d1\u9470\u6b63\u78ba\u6027\u3002", + "token_request_failed": "ecobee \u6240\u9700\u91d1\u9470\u932f\u8aa4\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" }, "step": { "authorize": { @@ -14,10 +14,10 @@ }, "user": { "data": { - "api_key": "API \u5bc6\u9470" + "api_key": "API \u91d1\u9470" }, - "description": "\u8acb\u8f38\u5165\u7531 ecobee.com \u6240\u7372\u5f97\u7684 API \u5bc6\u9470\u3002", - "title": "ecobee API \u5bc6\u9470" + "description": "\u8acb\u8f38\u5165\u7531 ecobee.com \u6240\u7372\u5f97\u7684 API \u91d1\u9470\u3002", + "title": "ecobee API \u91d1\u9470" } } } diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 7774a6648a5c1..aa73bd01c53c7 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -1,4 +1,6 @@ """Support for displaying weather info from Ecobee API.""" +from __future__ import annotations + from datetime import timedelta from pyecobee.const import ECOBEE_STATE_UNKNOWN @@ -12,11 +14,12 @@ ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) -from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.const import PRESSURE_HPA, PRESSURE_INHG, TEMP_FAHRENHEIT +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import dt as dt_util +from homeassistant.util.pressure import convert as pressure_convert from .const import ( - _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, ECOBEE_WEATHER_SYMBOL_TO_HASS, @@ -65,28 +68,22 @@ def unique_id(self): return self.data.ecobee.get_thermostat(self._index)["identifier"] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information for the ecobee weather platform.""" thermostat = self.data.ecobee.get_thermostat(self._index) + model: str | None try: model = f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" except KeyError: - _LOGGER.error( - "Model number for ecobee thermostat %s not recognized. " - "Please visit this link and provide the following information: " - "https://github.com/home-assistant/core/issues/27172 " - "Unrecognized model number: %s", - thermostat["name"], - thermostat["modelNumber"], - ) - return None + # Ecobee model is not in our list + model = None - return { - "identifiers": {(DOMAIN, thermostat["identifier"])}, - "name": self.name, - "manufacturer": MANUFACTURER, - "model": model, - } + return DeviceInfo( + identifiers={(DOMAIN, thermostat["identifier"])}, + manufacturer=MANUFACTURER, + model=model, + name=self.name, + ) @property def condition(self): @@ -113,7 +110,11 @@ def temperature_unit(self): def pressure(self): """Return the pressure.""" try: - return int(self.get_forecast(0, "pressure")) + pressure = self.get_forecast(0, "pressure") + if not self.hass.config.units.is_metric: + pressure = pressure_convert(pressure, PRESSURE_HPA, PRESSURE_INHG) + return round(pressure, 2) + return round(pressure) except ValueError: return None diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 5a20337e45446..c3abea82d7fb4 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -12,18 +12,23 @@ PyeconetError, ) -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, TEMP_FAHRENHEIT +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, TEMP_FAHRENHEIT, Platform from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval from .const import API_CLIENT, DOMAIN, EQUIPMENT _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["climate", "binary_sensor", "sensor", "water_heater"] +PLATFORMS = [ + Platform.CLIMATE, + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.WATER_HEATER, +] PUSH_UPDATE = "econet.push_update" INTERVAL = timedelta(minutes=60) @@ -128,13 +133,13 @@ def available(self): return self._econet.connected @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._econet.device_id)}, - "manufacturer": "Rheem", - "name": self._econet.device_name, - } + return DeviceInfo( + identifiers={(DOMAIN, self._econet.device_id)}, + manufacturer="Rheem", + name=self._econet.device_name, + ) @property def name(self): diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 116b1243ee089..6cee3383d2333 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -1,83 +1,77 @@ """Support for Rheem EcoNet water heaters.""" +from __future__ import annotations + from pyeconet.equipment import EquipmentType from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_LOCK, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_POWER, - DEVICE_CLASS_SOUND, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT -SENSOR_NAME_RUNNING = "running" -SENSOR_NAME_SHUTOFF_VALVE = "shutoff_valve" -SENSOR_NAME_RUNNING = "running" -SENSOR_NAME_SCREEN_LOCKED = "screen_locked" -SENSOR_NAME_BEEP_ENABLED = "beep_enabled" - -ATTR = "attr" -DEVICE_CLASS = "device_class" -SENSORS = { - SENSOR_NAME_SHUTOFF_VALVE: { - ATTR: "shutoff_valve_open", - DEVICE_CLASS: DEVICE_CLASS_OPENING, - }, - SENSOR_NAME_RUNNING: {ATTR: "running", DEVICE_CLASS: DEVICE_CLASS_POWER}, - SENSOR_NAME_SCREEN_LOCKED: { - ATTR: "screen_locked", - DEVICE_CLASS: DEVICE_CLASS_LOCK, - }, - SENSOR_NAME_BEEP_ENABLED: { - ATTR: "beep_enabled", - DEVICE_CLASS: DEVICE_CLASS_SOUND, - }, -} +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="shutoff_valve_open", + name="shutoff_valve", + device_class=BinarySensorDeviceClass.OPENING, + ), + BinarySensorEntityDescription( + key="running", + name="running", + device_class=BinarySensorDeviceClass.POWER, + ), + BinarySensorEntityDescription( + key="screen_locked", + name="screen_locked", + device_class=BinarySensorDeviceClass.LOCK, + ), + BinarySensorEntityDescription( + key="beep_enabled", + name="beep_enabled", + device_class=BinarySensorDeviceClass.SOUND, + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up EcoNet binary sensor based on a config entry.""" equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] - binary_sensors = [] all_equipment = equipment[EquipmentType.WATER_HEATER].copy() all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) - for _equip in all_equipment: - for sensor_name, sensor in SENSORS.items(): - if getattr(_equip, sensor[ATTR], None) is not None: - binary_sensors.append(EcoNetBinarySensor(_equip, sensor_name)) - async_add_entities(binary_sensors) + entities = [ + EcoNetBinarySensor(_equip, description) + for _equip in all_equipment + for description in BINARY_SENSOR_TYPES + if getattr(_equip, description.key, None) is not None + ] + + async_add_entities(entities) class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): """Define a Econet binary sensor.""" - def __init__(self, econet_device, device_name): + def __init__(self, econet_device, description: BinarySensorEntityDescription): """Initialize.""" super().__init__(econet_device) + self.entity_description = description self._econet = econet_device - self._device_name = device_name @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self._econet, SENSORS[self._device_name][ATTR]) - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return SENSORS[self._device_name][DEVICE_CLASS] + return getattr(self._econet, self.entity_description.key) @property def name(self): """Return the name of the entity.""" - return f"{self._econet.device_name}_{self._device_name}" + return f"{self._econet.device_name}_{self.entity_description.name}" @property def unique_id(self): """Return the unique ID of the entity.""" - return ( - f"{self._econet.device_id}_{self._econet.device_name}_{self._device_name}" - ) + return f"{self._econet.device_id}_{self._econet.device_name}_{self.entity_description.name}" diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index fe50855d55936..24bac5164667f 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -1,6 +1,4 @@ """Support for Rheem EcoNet thermostats.""" -import logging - from pyeconet.equipment import EquipmentType from pyeconet.equipment.thermostat import ThermostatFanMode, ThermostatOperationMode @@ -28,8 +26,6 @@ from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT -_LOGGER = logging.getLogger(__name__) - ECONET_STATE_TO_HA = { ThermostatOperationMode.HEATING: HVAC_MODE_HEAT, ThermostatOperationMode.COOLING: HVAC_MODE_COOL, diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 0dfe8df7fb39c..f4c37841b17f3 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -1,13 +1,8 @@ """Support for Rheem EcoNet water heaters.""" from pyeconet.equipment import EquipmentType -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_SIGNAL_STRENGTH, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - VOLUME_GALLONS, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import ENERGY_KILO_WATT_HOUR, PERCENTAGE, VOLUME_GALLONS from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT @@ -44,7 +39,7 @@ WATER_USAGE_TODAY: VOLUME_GALLONS, POWER_USAGE_TODAY: None, # Depends on unit type ALERT_COUNT: None, - WIFI_SIGNAL: DEVICE_CLASS_SIGNAL_STRENGTH, + WIFI_SIGNAL: SensorDeviceClass.SIGNAL_STRENGTH, RUNNING_STATE: None, # This is just a string } @@ -82,7 +77,7 @@ def __init__(self, econet_device, device_name): self._device_name = device_name @property - def state(self): + def native_value(self): """Return sensors state.""" value = getattr(self._econet, SENSOR_NAMES_TO_ATTRIBUTES[self._device_name]) if isinstance(value, float): @@ -90,7 +85,7 @@ def state(self): return value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" unit_of_measurement = SENSOR_NAMES_TO_UNIT_OF_MEASUREMENT[self._device_name] if self._device_name == POWER_USAGE_TODAY: diff --git a/homeassistant/components/econet/translations/de.json b/homeassistant/components/econet/translations/de.json index 854d61f1790b4..3b487d9f0e814 100644 --- a/homeassistant/components/econet/translations/de.json +++ b/homeassistant/components/econet/translations/de.json @@ -14,7 +14,8 @@ "data": { "email": "E-Mail", "password": "Passwort" - } + }, + "title": "Rheem EcoNet-Konto einrichten" } } } diff --git a/homeassistant/components/econet/translations/es-419.json b/homeassistant/components/econet/translations/es-419.json new file mode 100644 index 0000000000000..f019a47ae4a02 --- /dev/null +++ b/homeassistant/components/econet/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Configurar cuenta Rheem EcoNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/fr.json b/homeassistant/components/econet/translations/fr.json index 64fd39c852ad5..e6081bef90a20 100644 --- a/homeassistant/components/econet/translations/fr.json +++ b/homeassistant/components/econet/translations/fr.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" }, "error": { - "cannot_connect": "\u00c9chec de la connexion", - "invalid_auth": "Authentification invalide " + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/econet/translations/he.json b/homeassistant/components/econet/translations/he.json new file mode 100644 index 0000000000000..a881cd42615a6 --- /dev/null +++ b/homeassistant/components/econet/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/hu.json b/homeassistant/components/econet/translations/hu.json index 065c648d4a041..0f9cf18f20305 100644 --- a/homeassistant/components/econet/translations/hu.json +++ b/homeassistant/components/econet/translations/hu.json @@ -14,7 +14,8 @@ "data": { "email": "E-mail", "password": "Jelsz\u00f3" - } + }, + "title": "\u00c1ll\u00edtsa be a Rheem EcoNet fi\u00f3kot" } } } diff --git a/homeassistant/components/econet/translations/it.json b/homeassistant/components/econet/translations/it.json index 3074c72b08395..1c966d7b5ece7 100644 --- a/homeassistant/components/econet/translations/it.json +++ b/homeassistant/components/econet/translations/it.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "email": "E-mail", + "email": "Email", "password": "Password" }, "title": "Imposta account Rheem EcoNet" diff --git a/homeassistant/components/econet/translations/ja.json b/homeassistant/components/econet/translations/ja.json new file mode 100644 index 0000000000000..4c29b8d305d69 --- /dev/null +++ b/homeassistant/components/econet/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Rheem EcoNet Account\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/tr.json b/homeassistant/components/econet/translations/tr.json index 237a87d02685e..5261e78e7e414 100644 --- a/homeassistant/components/econet/translations/tr.json +++ b/homeassistant/components/econet/translations/tr.json @@ -12,8 +12,8 @@ "step": { "user": { "data": { - "email": "Email", - "password": "\u015eifre" + "email": "E-posta", + "password": "Parola" }, "title": "Rheem EcoNet Hesab\u0131n\u0131 Kur" } diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index ed31e78af7c2a..7ea4d7740a56e 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -113,8 +113,7 @@ def supported_features(self): def set_temperature(self, **kwargs): """Set new target temperature.""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - if target_temp is not None: + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self.water_heater.set_set_point(target_temp) else: _LOGGER.error("A target temperature must be provided") diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 964dd7a3f2ac4..8a6475c01922d 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -59,8 +59,8 @@ def setup(hass, config): for device in devices: _LOGGER.info( "Discovered Ecovacs device on account: %s with nickname %s", - device["did"], - device["nick"], + device.get("did"), + device.get("nick"), ) vacbot = VacBot( ecovacs_api.uid, @@ -77,7 +77,8 @@ def stop(event: object) -> None: """Shut down open connections to Ecovacs XMPP server.""" for device in hass.data[ECOVACS_DEVICES]: _LOGGER.info( - "Shutting down connection to Ecovacs device %s", device.vacuum["did"] + "Shutting down connection to Ecovacs device %s", + device.vacuum.get("did"), ) device.disconnect() diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 28711821f507d..66b0ce6731ee4 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -10,7 +10,11 @@ from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) from homeassistant.const import ( CONF_NAME, EVENT_HOMEASSISTANT_START, @@ -113,12 +117,17 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.temperature @property - def unit_of_measurement(self): + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TEMP_CELSIUS diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 16502632f4f17..d9997a9c1e32f 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -10,13 +10,15 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) @@ -35,7 +37,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the EDL21 sensor.""" hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) await hass.data[DOMAIN].connect() @@ -52,6 +59,7 @@ class EDL21: "1-0:0.0.9*255": "Electricity ID", # D=2: Program entries "1-0:0.2.0*0": "Configuration program version number", + "1-0:0.2.0*1": "Firmware version number", # C=1: Active power + # D=8: Time integral 1 # E=0: Total @@ -87,6 +95,10 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:31.7.0*255": "L1 active instantaneous amperage", + # C=32: Active voltage L1 + # D=7: Instantaneous value + # E=0: Total + "1-0:32.7.0*255": "L1 active instantaneous voltage", # C=36: Active power L1 # D=7: Instantaneous value # E=0: Total @@ -95,6 +107,10 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:51.7.0*255": "L2 active instantaneous amperage", + # C=52: Active voltage L2 + # D=7: Instantaneous value + # E=0: Total + "1-0:52.7.0*255": "L2 active instantaneous voltage", # C=56: Active power L2 # D=7: Instantaneous value # E=0: Total @@ -103,13 +119,21 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:71.7.0*255": "L3 active instantaneous amperage", + # C=72: Active voltage L3 + # D=7: Instantaneous value + # E=0: Total + "1-0:72.7.0*255": "L3 active instantaneous voltage", # C=76: Active power L3 # D=7: Instantaneous value # E=0: Total "1-0:76.7.0*255": "L3 active instantaneous power", # C=81: Angles # D=7: Instantaneous value + # E=4: U(L1) x I(L1) + # E=15: U(L2) x I(L2) # E=26: U(L3) x I(L3) + "1-0:81.7.4*255": "U(L1)/I(L1) phase angle", + "1-0:81.7.15*255": "U(L2)/I(L2) phase angle", "1-0:81.7.26*255": "U(L3)/I(L3) phase angle", # C=96: Electricity-related service entries "1-0:96.1.0*255": "Metering point ID 1", @@ -119,6 +143,7 @@ class EDL21: # C=96: Electricity-related service entries "1-0:96.50.1*1", # Manufacturer specific "1-0:96.90.2*1", # Manufacturer specific + "1-0:96.90.2*2", # Manufacturer specific # A=129: Manufacturer specific "129-129:199.130.3*255", # Iskraemeco: Manufacturer "129-129:199.130.5*255", # Iskraemeco: Public Key @@ -126,7 +151,7 @@ class EDL21: def __init__(self, hass, config, async_add_entities) -> None: """Initialize an EDL21 object.""" - self._registered_obis = set() + self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities self._name = config[CONF_NAME] @@ -153,8 +178,7 @@ def event(self, message_body) -> None: new_entities = [] for telegram in message_body.get("valList", []): - obis = telegram.get("objName") - if not obis: + if not (obis := telegram.get("objName")): continue if (electricity_id, obis) in self._registered_obis: @@ -162,8 +186,7 @@ def event(self, message_body) -> None: self._hass, SIGNAL_EDL21_TELEGRAM, electricity_id, telegram ) else: - name = self._OBIS_NAMES.get(obis) - if name: + if name := self._OBIS_NAMES.get(obis): if self._name: name = f"{self._name}: {name}" new_entities.append( @@ -276,7 +299,7 @@ def name(self) -> str | None: return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the value of the last received telegram.""" return self._telegram.get("value") @@ -290,7 +313,7 @@ def extra_state_attributes(self): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._telegram.get("unit") diff --git a/homeassistant/components/ee_brightbox/device_tracker.py b/homeassistant/components/ee_brightbox/device_tracker.py index 845d557e029f1..f29eaf6f9482f 100644 --- a/homeassistant/components/ee_brightbox/device_tracker.py +++ b/homeassistant/components/ee_brightbox/device_tracker.py @@ -1,12 +1,13 @@ """Support for EE Brightbox router.""" import logging +# pylint: disable=import-error from eebrightbox import EEBrightBox, EEBrightBoxException import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -20,7 +21,7 @@ CONF_DEFAULT_USERNAME = "admin" CONF_DEFAULT_VERSION = 2 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_VERSION, default=CONF_DEFAULT_VERSION): cv.positive_int, vol.Required(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, diff --git a/homeassistant/components/ee_brightbox/manifest.json b/homeassistant/components/ee_brightbox/manifest.json index c477b9fb33986..b7aae9f5a87ee 100644 --- a/homeassistant/components/ee_brightbox/manifest.json +++ b/homeassistant/components/ee_brightbox/manifest.json @@ -1,6 +1,7 @@ { "domain": "ee_brightbox", "name": "EE Bright Box", + "disabled": "Library has incompatible requirements.", "documentation": "https://www.home-assistant.io/integrations/ee_brightbox", "requirements": ["eebrightbox==0.0.4"], "codeowners": [], diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py index 8ceeb1585a496..372dbe77e7587 100644 --- a/homeassistant/components/efergy/__init__.py +++ b/homeassistant/components/efergy/__init__.py @@ -1 +1,71 @@ -"""The efergy component.""" +"""The Efergy integration.""" +from __future__ import annotations + +from pyefergy import Efergy, exceptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import ATTRIBUTION, DATA_KEY_API, DEFAULT_NAME, DOMAIN + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Efergy from a config entry.""" + api = Efergy( + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + utc_offset=hass.config.time_zone, + currency=hass.config.currency, + ) + + try: + await api.async_status(get_sids=True) + except (exceptions.ConnectError, exceptions.DataError) as ex: + raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex + except exceptions.InvalidAuth as ex: + raise ConfigEntryAuthFailed( + "API Key is no longer valid. Please reauthenticate" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_KEY_API: api} + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +class EfergyEntity(Entity): + """Representation of a Efergy entity.""" + + def __init__( + self, + api: Efergy, + server_unique_id: str, + ) -> None: + """Initialize an Efergy entity.""" + self.api = api + self._server_unique_id = server_unique_id + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_device_info = DeviceInfo( + configuration_url="https://engage.efergy.com/user/login", + connections={(dr.CONNECTION_NETWORK_MAC, self.api.info["mac"])}, + identifiers={(DOMAIN, self._server_unique_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + model=self.api.info["type"], + sw_version=self.api.info["version"], + ) diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py new file mode 100644 index 0000000000000..8283bfce62d93 --- /dev/null +++ b/homeassistant/components/efergy/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Efergy integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyefergy import Efergy, exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class EfergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Efergy.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input is not None: + api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_API_KEY: api_key}) + hid, error = await self._async_try_connect(api_key) + if error is None: + entry = await self.async_set_unique_id(hid) + if entry: + self.hass.config_entries.async_update_entry(entry, data=user_input) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=DEFAULT_NAME, + data={CONF_API_KEY: api_key}, + ) + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + """Handle a reauthorization flow request.""" + return await self.async_step_user() + + async def _async_try_connect(self, api_key: str) -> tuple[str | None, str | None]: + """Try connecting to Efergy servers.""" + api = Efergy(api_key, session=async_get_clientsession(self.hass)) + try: + await api.async_status() + except exceptions.ConnectError: + return None, "cannot_connect" + except exceptions.InvalidAuth: + return None, "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return None, "unknown" + return api.info["hid"], None diff --git a/homeassistant/components/efergy/const.py b/homeassistant/components/efergy/const.py new file mode 100644 index 0000000000000..f5e9af6a4c808 --- /dev/null +++ b/homeassistant/components/efergy/const.py @@ -0,0 +1,12 @@ +"""Constants for the Efergy integration.""" +from datetime import timedelta + +ATTRIBUTION = "Data provided by Efergy" + +CONF_CURRENT_VALUES = "current_values" + +DATA_KEY_API = "api" +DEFAULT_NAME = "Efergy" +DOMAIN = "efergy" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index fe9ea7e60477f..966df3ed85875 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -1,7 +1,9 @@ { "domain": "efergy", "name": "Efergy", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/efergy", - "codeowners": [], + "requirements": ["pyefergy==0.1.5"], + "codeowners": ["@tkdrob"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6e2ac1c01c7f4..21d4002bcdb02 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,163 +1,175 @@ """Support for Efergy sensors.""" +from __future__ import annotations + import logging +from re import sub -import requests -import voluptuous as vol +from pyefergy import Efergy, exceptions -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - CONF_CURRENCY, - CONF_MONITORED_VARIABLES, - CONF_TYPE, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, ) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = "https://engage.efergy.com/mobile_proxy/" - -CONF_APPTOKEN = "app_token" -CONF_UTC_OFFSET = "utc_offset" - -CONF_PERIOD = "period" - -CONF_INSTANT = "instant_readings" -CONF_AMOUNT = "amount" -CONF_BUDGET = "budget" -CONF_COST = "cost" -CONF_CURRENT_VALUES = "current_values" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_platform -DEFAULT_PERIOD = "year" -DEFAULT_UTC_OFFSET = "0" +from . import EfergyEntity +from .const import CONF_CURRENT_VALUES, DATA_KEY_API, DOMAIN -SENSOR_TYPES = { - CONF_INSTANT: ["Energy Usage", POWER_WATT], - CONF_AMOUNT: ["Energy Consumed", ENERGY_KILO_WATT_HOUR], - CONF_BUDGET: ["Energy Budget", None], - CONF_COST: ["Energy Cost", None], - CONF_CURRENT_VALUES: ["Per-Device Usage", POWER_WATT], -} - -TYPES_SCHEMA = vol.In(SENSOR_TYPES) - -SENSORS_SCHEMA = vol.Schema( - { - vol.Required(CONF_TYPE): TYPES_SCHEMA, - vol.Optional(CONF_CURRENCY, default=""): cv.string, - vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.string, - } -) +_LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_APPTOKEN): cv.string, - vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, - vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA], - } +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="instant_readings", + name="Power Usage", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="energy_day", + name="Daily Consumption", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_week", + name="Weekly Consumption", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_month", + name="Monthly Consumption", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_year", + name="Yearly Consumption", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="budget", + name="Energy Budget", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="cost_day", + name="Daily Energy Cost", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="cost_week", + name="Weekly Energy Cost", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="cost_month", + name="Monthly Energy Cost", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="cost_year", + name="Yearly Energy Cost", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=CONF_CURRENT_VALUES, + name="Power Usage", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Efergy sensor.""" - app_token = config.get(CONF_APPTOKEN) - utc_offset = str(config.get(CONF_UTC_OFFSET)) - - dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - if variable[CONF_TYPE] == CONF_CURRENT_VALUES: - url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}" - response = requests.get(url_string, timeout=10) - for sensor in response.json(): - sid = sensor["sid"] - dev.append( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up Efergy sensors.""" + api: Efergy = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] + sensors = [] + for description in SENSOR_TYPES: + if description.key != CONF_CURRENT_VALUES: + sensors.append( + EfergySensor( + api, + description, + entry.entry_id, + period=sub("^energy_|^cost_", "", description.key), + currency=hass.config.currency, + ) + ) + else: + description.entity_registry_enabled_default = len(api.info["sids"]) > 1 + for sid in api.info["sids"]: + sensors.append( EfergySensor( - variable[CONF_TYPE], - app_token, - utc_offset, - variable[CONF_PERIOD], - variable[CONF_CURRENCY], - sid, + api, + description, + entry.entry_id, + sid=sid, ) ) - dev.append( - EfergySensor( - variable[CONF_TYPE], - app_token, - utc_offset, - variable[CONF_PERIOD], - variable[CONF_CURRENCY], - ) - ) + async_add_entities(sensors, True) - add_entities(dev, True) - -class EfergySensor(SensorEntity): +class EfergySensor(EfergyEntity, SensorEntity): """Implementation of an Efergy sensor.""" - def __init__(self, sensor_type, app_token, utc_offset, period, currency, sid=None): + def __init__( + self, + api: Efergy, + description: SensorEntityDescription, + server_unique_id: str, + period: str | None = None, + currency: str | None = None, + sid: str = "", + ) -> None: """Initialize the sensor.""" + super().__init__(api, server_unique_id) + self.entity_description = description + if description.key == CONF_CURRENT_VALUES: + self._attr_name = f"{description.name}_{sid}" + self._attr_unique_id = f"{server_unique_id}/{description.key}_{sid}" + if "cost" in description.key: + self._attr_native_unit_of_measurement = currency self.sid = sid - if sid: - self._name = f"efergy_{sid}" - else: - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type - self.app_token = app_token - self.utc_offset = utc_offset - self._state = None self.period = period - self.currency = currency - if self.type == "cost": - self._unit_of_measurement = f"{self.currency}/{self.period}" - else: - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - def update(self): + async def async_update(self) -> None: """Get the Efergy monitor data from the web service.""" try: - if self.type == "instant_readings": - url_string = f"{_RESOURCE}getInstant?token={self.app_token}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["reading"] - elif self.type == "amount": - url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["sum"] - elif self.type == "budget": - url_string = f"{_RESOURCE}getBudget?token={self.app_token}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["status"] - elif self.type == "cost": - url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["sum"] - elif self.type == "current_values": - url_string = ( - f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}" - ) - response = requests.get(url_string, timeout=10) - for sensor in response.json(): - if self.sid == sensor["sid"]: - measurement = next(iter(sensor["data"][0].values())) - self._state = measurement - else: - self._state = None - except (requests.RequestException, ValueError, KeyError): - _LOGGER.warning("Could not update status for %s", self.name) + self._attr_native_value = await self.api.async_get_reading( + self.entity_description.key, period=self.period, sid=self.sid + ) + except (exceptions.DataError, exceptions.ConnectError) as ex: + if self._attr_available: + self._attr_available = False + _LOGGER.error("Error getting data: %s", ex) + return + if not self._attr_available: + self._attr_available = True + _LOGGER.info("Connection has resumed") diff --git a/homeassistant/components/efergy/strings.json b/homeassistant/components/efergy/strings.json new file mode 100644 index 0000000000000..dc625c9284025 --- /dev/null +++ b/homeassistant/components/efergy/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Efergy", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/efergy/translations/bg.json b/homeassistant/components/efergy/translations/bg.json new file mode 100644 index 0000000000000..14d4c77c8f97a --- /dev/null +++ b/homeassistant/components/efergy/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/ca.json b/homeassistant/components/efergy/translations/ca.json new file mode 100644 index 0000000000000..298826e75e5ae --- /dev/null +++ b/homeassistant/components/efergy/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/cs.json b/homeassistant/components/efergy/translations/cs.json new file mode 100644 index 0000000000000..b2fe2ea015fe1 --- /dev/null +++ b/homeassistant/components/efergy/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "API kl\u00ed\u010d" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/de.json b/homeassistant/components/efergy/translations/de.json new file mode 100644 index 0000000000000..3945d3da7d42f --- /dev/null +++ b/homeassistant/components/efergy/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/en.json b/homeassistant/components/efergy/translations/en.json new file mode 100644 index 0000000000000..aa76f9c0636d0 --- /dev/null +++ b/homeassistant/components/efergy/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/et.json b/homeassistant/components/efergy/translations/et.json new file mode 100644 index 0000000000000..73c2f1a35473a --- /dev/null +++ b/homeassistant/components/efergy/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/fr.json b/homeassistant/components/efergy/translations/fr.json new file mode 100644 index 0000000000000..2e98eca19e779 --- /dev/null +++ b/homeassistant/components/efergy/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 d'API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/he.json b/homeassistant/components/efergy/translations/he.json new file mode 100644 index 0000000000000..4b0fd84974215 --- /dev/null +++ b/homeassistant/components/efergy/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/hu.json b/homeassistant/components/efergy/translations/hu.json new file mode 100644 index 0000000000000..032ef05d52765 --- /dev/null +++ b/homeassistant/components/efergy/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/id.json b/homeassistant/components/efergy/translations/id.json new file mode 100644 index 0000000000000..234e5122db2b3 --- /dev/null +++ b/homeassistant/components/efergy/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/it.json b/homeassistant/components/efergy/translations/it.json new file mode 100644 index 0000000000000..d5677424d42d7 --- /dev/null +++ b/homeassistant/components/efergy/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/ja.json b/homeassistant/components/efergy/translations/ja.json new file mode 100644 index 0000000000000..98dd06ab4f0a7 --- /dev/null +++ b/homeassistant/components/efergy/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/nl.json b/homeassistant/components/efergy/translations/nl.json new file mode 100644 index 0000000000000..4f97bad11a00e --- /dev/null +++ b/homeassistant/components/efergy/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/no.json b/homeassistant/components/efergy/translations/no.json new file mode 100644 index 0000000000000..388cbb36f6424 --- /dev/null +++ b/homeassistant/components/efergy/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/pl.json b/homeassistant/components/efergy/translations/pl.json new file mode 100644 index 0000000000000..b96038d24b342 --- /dev/null +++ b/homeassistant/components/efergy/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/ru.json b/homeassistant/components/efergy/translations/ru.json new file mode 100644 index 0000000000000..6a659c9b7c655 --- /dev/null +++ b/homeassistant/components/efergy/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/sl.json b/homeassistant/components/efergy/translations/sl.json new file mode 100644 index 0000000000000..269215c594365 --- /dev/null +++ b/homeassistant/components/efergy/translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "api_key": "API Klju\u010d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/tr.json b/homeassistant/components/efergy/translations/tr.json new file mode 100644 index 0000000000000..e13f215b5fb09 --- /dev/null +++ b/homeassistant/components/efergy/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/zh-Hant.json b/homeassistant/components/efergy/translations/zh-Hant.json new file mode 100644 index 0000000000000..c8f2d660c2f11 --- /dev/null +++ b/homeassistant/components/efergy/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "api_key": "API \u91d1\u9470" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index b133a96b82047..e386ad6beacb9 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -97,8 +97,7 @@ def should_poll(self): def handle_status_event(self, event): """Handle the Egardia system status event.""" - statuscode = event.get("status") - if statuscode is not None: + if (statuscode := event.get("status")) is not None: status = self.lookupstatusfromcode(statuscode) self.parsestatus(status) self.schedule_update_ha_state() diff --git a/homeassistant/components/egardia/binary_sensor.py b/homeassistant/components/egardia/binary_sensor.py index 61f43301fd96a..cf621277faff2 100644 --- a/homeassistant/components/egardia/binary_sensor.py +++ b/homeassistant/components/egardia/binary_sensor.py @@ -1,7 +1,6 @@ """Interfaces with Egardia/Woonveilig alarm control panel.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OPENING, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import STATE_OFF, STATE_ON @@ -9,9 +8,9 @@ from . import ATTR_DISCOVER_DEVICES, EGARDIA_DEVICE EGARDIA_TYPE_TO_DEVICE_CLASS = { - "IR Sensor": DEVICE_CLASS_MOTION, - "Door Contact": DEVICE_CLASS_OPENING, - "IR": DEVICE_CLASS_MOTION, + "IR Sensor": BinarySensorDeviceClass.MOTION, + "Door Contact": BinarySensorDeviceClass.OPENING, + "IR": BinarySensorDeviceClass.MOTION, } diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 67c195da3e62b..28de45392eb56 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,8 +1,11 @@ """Support for Eight smart mattress covers and mattresses.""" +from __future__ import annotations + from datetime import timedelta import logging from pyeight.eight import EightSleep +from pyeight.user import EightUser import voluptuous as vol from homeassistant.const import ( @@ -11,30 +14,31 @@ CONF_PASSWORD, CONF_SENSORS, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) CONF_PARTNER = "partner" DATA_EIGHT = "eight_sleep" -DEFAULT_PARTNER = False +DATA_HEAT = "heat" +DATA_USER = "user" +DATA_API = "api" DOMAIN = "eight_sleep" HEAT_ENTITY = "heat" USER_ENTITY = "user" + HEAT_SCAN_INTERVAL = timedelta(seconds=60) USER_SCAN_INTERVAL = timedelta(seconds=300) @@ -45,18 +49,9 @@ "left_current_sleep": "Left Sleep Session", "left_current_sleep_fitness": "Left Sleep Fitness", "left_last_sleep": "Left Previous Sleep Session", - "left_bed_state": "Left Bed State", - "left_presence": "Left Bed Presence", - "left_bed_temp": "Left Bed Temperature", - "left_sleep_stage": "Left Sleep Stage", "right_current_sleep": "Right Sleep Session", "right_current_sleep_fitness": "Right Sleep Fitness", "right_last_sleep": "Right Previous Sleep Session", - "right_bed_state": "Right Bed State", - "right_presence": "Right Bed Presence", - "right_bed_temp": "Right Bed Temperature", - "right_sleep_stage": "Right Sleep Stage", - "room_temp": "Room Temperature", } SENSORS = [ @@ -64,7 +59,7 @@ "current_sleep_fitness", "last_sleep", "bed_state", - "bed_temp", + "bed_temperature", "sleep_stage", ] @@ -86,25 +81,38 @@ CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PARTNER, default=DEFAULT_PARTNER): cv.boolean, - } + DOMAIN: vol.All( + cv.deprecated(CONF_PARTNER), + vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PARTNER): cv.boolean, + } + ), ) }, extra=vol.ALLOW_EXTRA, ) -async def async_setup(hass, config): +def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: + """Get the device's unique ID.""" + unique_id = eight.deviceid + if user_obj: + unique_id = f"{unique_id}.{user_obj.userid}.{user_obj.side}" + return unique_id + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Eight Sleep component.""" - conf = config.get(DOMAIN) - user = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - partner = conf.get(CONF_PARTNER) + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + user = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] if hass.config.time_zone is None: _LOGGER.error("Timezone is not set in Home Assistant") @@ -112,9 +120,9 @@ async def async_setup(hass, config): timezone = str(hass.config.time_zone) - eight = EightSleep(user, password, timezone, partner, None, hass.loop) + eight = EightSleep(user, password, timezone, async_get_clientsession(hass)) - hass.data[DATA_EIGHT] = eight + hass.data.setdefault(DATA_EIGHT, {})[DATA_API] = eight # Authenticate, build sensors success = await eight.start() @@ -122,37 +130,24 @@ async def async_setup(hass, config): # Authentication failed, cannot continue return False - async def async_update_heat_data(now): - """Update heat data from eight in HEAT_SCAN_INTERVAL.""" - await eight.update_device_data() - async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) - - async_track_point_in_utc_time( - hass, async_update_heat_data, utcnow() + HEAT_SCAN_INTERVAL - ) - - async def async_update_user_data(now): - """Update user data from eight in USER_SCAN_INTERVAL.""" - await eight.update_user_data() - async_dispatcher_send(hass, SIGNAL_UPDATE_USER) - - async_track_point_in_utc_time( - hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL - ) - - await async_update_heat_data(None) - await async_update_user_data(None) + heat_coordinator = hass.data[DOMAIN][DATA_HEAT] = EightSleepHeatDataCoordinator( + hass, eight + ) + user_coordinator = hass.data[DOMAIN][DATA_USER] = EightSleepUserDataCoordinator( + hass, eight + ) + await heat_coordinator.async_config_entry_first_refresh() + await user_coordinator.async_config_entry_first_refresh() # Load sub components sensors = [] binary_sensors = [] if eight.users: - for user in eight.users: - obj = eight.users[user] + for user, obj in eight.users.items(): for sensor in SENSORS: - sensors.append(f"{obj.side}_{sensor}") - binary_sensors.append(f"{obj.side}_presence") - sensors.append("room_temp") + sensors.append((obj.side, sensor)) + binary_sensors.append((obj.side, "bed_presence")) + sensors.append((None, "room_temperature")) else: # No users, cannot continue return False @@ -169,7 +164,7 @@ async def async_update_user_data(now): ) ) - async def async_service_handler(service): + async def async_service_handler(service: ServiceCall) -> None: """Handle eight sleep service calls.""" params = service.data.copy() @@ -178,76 +173,99 @@ async def async_service_handler(service): duration = params.pop(ATTR_HEAT_DURATION, 0) for sens in sensor: - side = sens.split("_")[1] + side = sens[0] userid = eight.fetch_userid(side) usrobj = eight.users[userid] await usrobj.set_heating_level(target, duration) - async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) + await heat_coordinator.async_request_refresh() # Register services hass.services.async_register( DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA ) - async def stop_eight(event): - """Handle stopping eight api session.""" - await eight.stop() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_eight) - return True -class EightSleepUserEntity(Entity): - """The Eight Sleep device entity.""" +class EightSleepHeatDataCoordinator(DataUpdateCoordinator): + """Class to retrieve heat data from Eight Sleep.""" - def __init__(self, eight): - """Initialize the data object.""" - self._eight = eight + def __init__(self, hass: HomeAssistant, api: EightSleep) -> None: + """Initialize coordinator.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_heat", + update_interval=HEAT_SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> None: + await self.api.update_device_data() - async def async_added_to_hass(self): - """Register update dispatcher.""" - @callback - def async_eight_user_update(): - """Update callback.""" - self.async_schedule_update_ha_state(True) +class EightSleepUserDataCoordinator(DataUpdateCoordinator): + """Class to retrieve user data from Eight Sleep.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_USER, async_eight_user_update - ) + def __init__(self, hass: HomeAssistant, api: EightSleep) -> None: + """Initialize coordinator.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_user", + update_interval=USER_SCAN_INTERVAL, ) - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False + async def _async_update_data(self) -> None: + await self.api.update_user_data() -class EightSleepHeatEntity(Entity): - """The Eight Sleep device entity.""" +class EightSleepBaseEntity(CoordinatorEntity): + """The base Eight Sleep entity class.""" - def __init__(self, eight): + def __init__( + self, + name: str, + coordinator: EightSleepUserDataCoordinator | EightSleepHeatDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + ) -> None: """Initialize the data object.""" + super().__init__(coordinator) self._eight = eight + self._side = side + self._sensor = sensor + self._usrobj: EightUser | None = None + if self._side: + self._usrobj = self._eight.users[self._eight.fetch_userid(self._side)] + full_sensor_name = self._sensor + if self._side is not None: + full_sensor_name = f"{self._side}_{full_sensor_name}" + mapped_name = NAME_MAP.get( + full_sensor_name, full_sensor_name.replace("_", " ").title() + ) - async def async_added_to_hass(self): - """Register update dispatcher.""" + self._attr_name = f"{name} {mapped_name}" + self._attr_unique_id = ( + f"{_get_device_unique_id(eight, self._usrobj)}.{self._sensor}" + ) - @callback - def async_eight_heat_update(): - """Update callback.""" - self.async_schedule_update_ha_state(True) - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update - ) - ) +class EightSleepUserEntity(EightSleepBaseEntity): + """The Eight Sleep user entity.""" - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False + def __init__( + self, + name: str, + coordinator: EightSleepUserDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + units: str, + ) -> None: + """Initialize the data object.""" + super().__init__(name, coordinator, eight, side, sensor) + self._units = units diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 803b20383b6f2..d2ca30ab580a7 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -1,63 +1,77 @@ """Support for Eight Sleep binary sensors.""" -import logging +from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +import logging -from . import CONF_BINARY_SENSORS, DATA_EIGHT, NAME_MAP, EightSleepHeatEntity +from pyeight.eight import EightSleep + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import ( + CONF_BINARY_SENSORS, + DATA_API, + DATA_EIGHT, + DATA_HEAT, + EightSleepBaseEntity, + EightSleepHeatDataCoordinator, +) _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: """Set up the eight sleep binary sensor.""" if discovery_info is None: return name = "Eight" sensors = discovery_info[CONF_BINARY_SENSORS] - eight = hass.data[DATA_EIGHT] - - all_sensors = [] + eight: EightSleep = hass.data[DATA_EIGHT][DATA_API] + heat_coordinator: EightSleepHeatDataCoordinator = hass.data[DATA_EIGHT][DATA_HEAT] - for sensor in sensors: - all_sensors.append(EightHeatSensor(name, eight, sensor)) + all_sensors = [ + EightHeatSensor(name, heat_coordinator, eight, side, sensor) + for side, sensor in sensors + ] - async_add_entities(all_sensors, True) + async_add_entities(all_sensors) -class EightHeatSensor(EightSleepHeatEntity, BinarySensorEntity): +class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): """Representation of a Eight Sleep heat-based sensor.""" - def __init__(self, name, eight, sensor): + def __init__( + self, + name: str, + coordinator: EightSleepHeatDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + ) -> None: """Initialize the sensor.""" - super().__init__(eight) - - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - - self._side = self._sensor.split("_")[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj = self._eight.users[self._userid] - + super().__init__(name, coordinator, eight, side, sensor) + self._attr_device_class = BinarySensorDeviceClass.OCCUPANCY + assert self._usrobj _LOGGER.debug( "Presence Sensor: %s, Side: %s, User: %s", self._sensor, self._side, - self._userid, + self._usrobj.userid, ) @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._state - - async def async_update(self): - """Retrieve latest state.""" - self._state = self._usrobj.bed_presence + assert self._usrobj + return bool(self._usrobj.bed_presence) diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index d0f86d5a5e450..e722f73c4e7cf 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -2,7 +2,7 @@ "domain": "eight_sleep", "name": "Eight Sleep", "documentation": "https://www.home-assistant.io/integrations/eight_sleep", - "requirements": ["pyeight==0.1.5"], - "codeowners": ["@mezz64"], + "requirements": ["pyeight==0.1.9"], + "codeowners": ["@mezz64", "@raman325"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index ae0854ec24452..6cb2d5bf13c15 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -1,14 +1,26 @@ """Support for Eight Sleep sensors.""" +from __future__ import annotations + import logging +from typing import Any + +from pyeight.eight import EightSleep from homeassistant.components.sensor import SensorEntity from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import ( CONF_SENSORS, + DATA_API, DATA_EIGHT, - NAME_MAP, - EightSleepHeatEntity, + DATA_HEAT, + DATA_USER, + EightSleepBaseEntity, + EightSleepHeatDataCoordinator, + EightSleepUserDataCoordinator, EightSleepUserEntity, ) @@ -40,79 +52,79 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, list[tuple[str, str]]] = None, +) -> None: """Set up the eight sleep sensors.""" if discovery_info is None: return name = "Eight" sensors = discovery_info[CONF_SENSORS] - eight = hass.data[DATA_EIGHT] + eight: EightSleep = hass.data[DATA_EIGHT][DATA_API] + heat_coordinator: EightSleepHeatDataCoordinator = hass.data[DATA_EIGHT][DATA_HEAT] + user_coordinator: EightSleepUserDataCoordinator = hass.data[DATA_EIGHT][DATA_USER] if hass.config.units.is_metric: units = "si" else: units = "us" - all_sensors = [] + all_sensors: list[SensorEntity] = [] - for sensor in sensors: - if "bed_state" in sensor: - all_sensors.append(EightHeatSensor(name, eight, sensor)) - elif "room_temp" in sensor: - all_sensors.append(EightRoomSensor(name, eight, sensor, units)) + for side, sensor in sensors: + if sensor == "bed_state": + all_sensors.append( + EightHeatSensor(name, heat_coordinator, eight, side, sensor) + ) + elif sensor == "room_temperature": + all_sensors.append( + EightRoomSensor(name, user_coordinator, eight, side, sensor, units) + ) else: - all_sensors.append(EightUserSensor(name, eight, sensor, units)) + all_sensors.append( + EightUserSensor(name, user_coordinator, eight, side, sensor, units) + ) - async_add_entities(all_sensors, True) + async_add_entities(all_sensors) -class EightHeatSensor(EightSleepHeatEntity, SensorEntity): +class EightHeatSensor(EightSleepBaseEntity, SensorEntity): """Representation of an eight sleep heat-based sensor.""" - def __init__(self, name, eight, sensor): + def __init__( + self, + name: str, + coordinator: EightSleepHeatDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + ) -> None: """Initialize the sensor.""" - super().__init__(eight) - - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - - self._side = self._sensor.split("_")[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj = self._eight.users[self._userid] + super().__init__(name, coordinator, eight, side, sensor) + self._attr_native_unit_of_measurement = PERCENTAGE + assert self._usrobj _LOGGER.debug( "Heat Sensor: %s, Side: %s, User: %s", self._sensor, self._side, - self._userid, + self._usrobj.userid, ) @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name - - @property - def state(self): + def native_value(self) -> int: """Return the state of the sensor.""" - return self._state + assert self._usrobj + return self._usrobj.heating_level @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return PERCENTAGE - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("Updating Heat sensor: %s", self._sensor) - self._state = self._usrobj.heating_level - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device state attributes.""" + assert self._usrobj return { ATTR_TARGET_HEAT: self._usrobj.target_heating_level, ATTR_ACTIVE_HEAT: self._usrobj.now_heating, @@ -120,170 +132,152 @@ def extra_state_attributes(self): } +def _get_breakdown_percent( + attr: dict[str, Any], key: str, denominator: int | float +) -> int | float: + """Get a breakdown percent.""" + try: + return round((attr["breakdown"][key] / denominator) * 100, 2) + except ZeroDivisionError: + return 0 + + class EightUserSensor(EightSleepUserEntity, SensorEntity): """Representation of an eight sleep user-based sensor.""" - def __init__(self, name, eight, sensor, units): + def __init__( + self, + name: str, + coordinator: EightSleepUserDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + units: str, + ) -> None: """Initialize the sensor.""" - super().__init__(eight) - - self._sensor = sensor - self._sensor_root = self._sensor.split("_", 1)[1] - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - self._attr = None - self._units = units + super().__init__(name, coordinator, eight, side, sensor, units) - self._side = self._sensor.split("_", 1)[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj = self._eight.users[self._userid] + if self._sensor == "bed_temperature": + self._attr_icon = "mdi:thermometer" _LOGGER.debug( "User Sensor: %s, Side: %s, User: %s", self._sensor, self._side, - self._userid, + self._usrobj.userid if self._usrobj else None, ) @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name - - @property - def state(self): + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" - return self._state + if not self._usrobj: + return None + + if "current" in self._sensor: + if "fitness" in self._sensor: + return self._usrobj.current_sleep_fitness_score + return self._usrobj.current_sleep_score + + if "last" in self._sensor: + return self._usrobj.last_sleep_score + + if self._sensor == "bed_temperature": + temp = self._usrobj.current_values["bed_temp"] + try: + if self._units == "si": + return round(temp, 2) + return round((temp * 1.8) + 32, 2) + except TypeError: + return None + + if self._sensor == "sleep_stage": + return self._usrobj.current_values["stage"] + + return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" - if ( - "current_sleep" in self._sensor - or "last_sleep" in self._sensor - or "current_sleep_fitness" in self._sensor - ): + if self._sensor in ("current_sleep", "last_sleep", "current_sleep_fitness"): return "Score" - if "bed_temp" in self._sensor: + if self._sensor == "bed_temperature": if self._units == "si": return TEMP_CELSIUS return TEMP_FAHRENHEIT return None + def _get_rounded_value( + self, attr: dict[str, Any], key: str, use_units: bool = True + ) -> int | float | None: + """Get rounded value based on units for given key.""" + try: + if self._units == "si" or not use_units: + return round(attr["room_temp"], 2) + return round((attr["room_temp"] * 1.8) + 32, 2) + except TypeError: + return None + @property - def icon(self): - """Icon to use in the frontend, if any.""" - if "bed_temp" in self._sensor: - return "mdi:thermometer" - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("Updating User sensor: %s", self._sensor) - if "current" in self._sensor: + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return device state attributes.""" + attr = None + if "current" in self._sensor and self._usrobj: if "fitness" in self._sensor: - self._state = self._usrobj.current_sleep_fitness_score - self._attr = self._usrobj.current_fitness_values + attr = self._usrobj.current_fitness_values else: - self._state = self._usrobj.current_sleep_score - self._attr = self._usrobj.current_values - elif "last" in self._sensor: - self._state = self._usrobj.last_sleep_score - self._attr = self._usrobj.last_values - elif "bed_temp" in self._sensor: - temp = self._usrobj.current_values["bed_temp"] - try: - if self._units == "si": - self._state = round(temp, 2) - else: - self._state = round((temp * 1.8) + 32, 2) - except TypeError: - self._state = None - elif "sleep_stage" in self._sensor: - self._state = self._usrobj.current_values["stage"] + attr = self._usrobj.current_values + elif "last" in self._sensor and self._usrobj: + attr = self._usrobj.last_values - @property - def extra_state_attributes(self): - """Return device state attributes.""" - if self._attr is None: + if attr is None: # Skip attributes if sensor type doesn't support return None - if "fitness" in self._sensor_root: + if "fitness" in self._sensor: state_attr = { - ATTR_FIT_DATE: self._attr["date"], - ATTR_FIT_DURATION_SCORE: self._attr["duration"], - ATTR_FIT_ASLEEP_SCORE: self._attr["asleep"], - ATTR_FIT_OUT_SCORE: self._attr["out"], - ATTR_FIT_WAKEUP_SCORE: self._attr["wakeup"], + ATTR_FIT_DATE: attr["date"], + ATTR_FIT_DURATION_SCORE: attr["duration"], + ATTR_FIT_ASLEEP_SCORE: attr["asleep"], + ATTR_FIT_OUT_SCORE: attr["out"], + ATTR_FIT_WAKEUP_SCORE: attr["wakeup"], } return state_attr - state_attr = {ATTR_SESSION_START: self._attr["date"]} - state_attr[ATTR_TNT] = self._attr["tnt"] - state_attr[ATTR_PROCESSING] = self._attr["processing"] + state_attr = {ATTR_SESSION_START: attr["date"]} + state_attr[ATTR_TNT] = attr["tnt"] + state_attr[ATTR_PROCESSING] = attr["processing"] - sleep_time = ( - sum(self._attr["breakdown"].values()) - self._attr["breakdown"]["awake"] - ) - state_attr[ATTR_SLEEP_DUR] = sleep_time - try: - state_attr[ATTR_LIGHT_PERC] = round( - (self._attr["breakdown"]["light"] / sleep_time) * 100, 2 + if attr.get("breakdown") is not None: + sleep_time = sum(attr["breakdown"].values()) - attr["breakdown"]["awake"] + state_attr[ATTR_SLEEP_DUR] = sleep_time + state_attr[ATTR_LIGHT_PERC] = _get_breakdown_percent( + attr, "light", sleep_time ) - except ZeroDivisionError: - state_attr[ATTR_LIGHT_PERC] = 0 - try: - state_attr[ATTR_DEEP_PERC] = round( - (self._attr["breakdown"]["deep"] / sleep_time) * 100, 2 + state_attr[ATTR_DEEP_PERC] = _get_breakdown_percent( + attr, "deep", sleep_time ) - except ZeroDivisionError: - state_attr[ATTR_DEEP_PERC] = 0 + state_attr[ATTR_REM_PERC] = _get_breakdown_percent(attr, "rem", sleep_time) - try: - state_attr[ATTR_REM_PERC] = round( - (self._attr["breakdown"]["rem"] / sleep_time) * 100, 2 - ) - except ZeroDivisionError: - state_attr[ATTR_REM_PERC] = 0 + room_temp = self._get_rounded_value(attr, "room_temp") + bed_temp = self._get_rounded_value(attr, "bed_temp") - try: - if self._units == "si": - room_temp = round(self._attr["room_temp"], 2) - else: - room_temp = round((self._attr["room_temp"] * 1.8) + 32, 2) - except TypeError: - room_temp = None - - try: - if self._units == "si": - bed_temp = round(self._attr["bed_temp"], 2) - else: - bed_temp = round((self._attr["bed_temp"] * 1.8) + 32, 2) - except TypeError: - bed_temp = None - - if "current" in self._sensor_root: - try: - state_attr[ATTR_RESP_RATE] = round(self._attr["resp_rate"], 2) - except TypeError: - state_attr[ATTR_RESP_RATE] = None - try: - state_attr[ATTR_HEART_RATE] = round(self._attr["heart_rate"], 2) - except TypeError: - state_attr[ATTR_HEART_RATE] = None - state_attr[ATTR_SLEEP_STAGE] = self._attr["stage"] + if "current" in self._sensor: + state_attr[ATTR_RESP_RATE] = self._get_rounded_value( + attr, "resp_rate", False + ) + state_attr[ATTR_HEART_RATE] = self._get_rounded_value( + attr, "heart_rate", False + ) + state_attr[ATTR_SLEEP_STAGE] = attr["stage"] state_attr[ATTR_ROOM_TEMP] = room_temp state_attr[ATTR_BED_TEMP] = bed_temp - elif "last" in self._sensor_root: - try: - state_attr[ATTR_AVG_RESP_RATE] = round(self._attr["resp_rate"], 2) - except TypeError: - state_attr[ATTR_AVG_RESP_RATE] = None - try: - state_attr[ATTR_AVG_HEART_RATE] = round(self._attr["heart_rate"], 2) - except TypeError: - state_attr[ATTR_AVG_HEART_RATE] = None + elif "last" in self._sensor: + state_attr[ATTR_AVG_RESP_RATE] = self._get_rounded_value( + attr, "resp_rate", False + ) + state_attr[ATTR_AVG_HEART_RATE] = self._get_rounded_value( + attr, "heart_rate", False + ) state_attr[ATTR_AVG_ROOM_TEMP] = room_temp state_attr[ATTR_AVG_BED_TEMP] = bed_temp @@ -293,47 +287,30 @@ def extra_state_attributes(self): class EightRoomSensor(EightSleepUserEntity, SensorEntity): """Representation of an eight sleep room sensor.""" - def __init__(self, name, eight, sensor, units): + def __init__( + self, + name: str, + coordinator: EightSleepUserDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + units: str, + ) -> None: """Initialize the sensor.""" - super().__init__(eight) - - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - self._attr = None - self._units = units + super().__init__(name, coordinator, eight, side, sensor, units) - @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name + self._attr_icon = "mdi:thermometer" + self._attr_native_unit_of_measurement: str = ( + TEMP_CELSIUS if self._units == "si" else TEMP_FAHRENHEIT + ) @property - def state(self): + def native_value(self) -> int | float | None: """Return the state of the sensor.""" - return self._state - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("Updating Room sensor: %s", self._sensor) temp = self._eight.room_temperature() try: if self._units == "si": - self._state = round(temp, 2) - else: - self._state = round((temp * 1.8) + 32, 2) + return round(temp, 2) + return round((temp * 1.8) + 32, 2) except TypeError: - self._state = None - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - if self._units == "si": - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return "mdi:thermometer" + return None diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index 05354bccc685f..537f04bd306fb 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,12 +1,30 @@ heat_set: + name: Heat set description: Set heating/cooling level for eight sleep. fields: duration: + name: Duration description: Duration to heat/cool at the target level in seconds. - example: 3600 + required: true + selector: + number: + min: 0 + max: 28800 + unit_of_measurement: seconds entity_id: + name: Entity description: Entity id of the bed state to adjust. - example: sensor.eight_left_bed_state + required: true + selector: + entity: + integration: eight_sleep + domain: sensor target: + name: Target description: Target cooling/heating level from -100 to 100. - example: 35 + required: true + selector: + number: + min: -100 + max: 100 + unit_of_measurement: '°' diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 21b8de53c1775..6ef540365bef1 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -3,16 +3,15 @@ from elgato import Elgato, ElgatoConnectionError -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_ELGATO_CLIENT, DOMAIN +from .const import DOMAIN -PLATFORMS = [LIGHT_DOMAIN] +PLATFORMS = [Platform.BUTTON, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -31,8 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: logging.getLogger(__name__).debug("Unable to connect: %s", exception) raise ConfigEntryNotReady from exception - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {DATA_ELGATO_CLIENT: elgato} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = elgato hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py new file mode 100644 index 0000000000000..5e53ecfad40c6 --- /dev/null +++ b/homeassistant/components/elgato/button.py @@ -0,0 +1,61 @@ +"""Support for Elgato button.""" +from __future__ import annotations + +import logging + +from elgato import Elgato, ElgatoError, Info + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Elgato button based on a config entry.""" + elgato: Elgato = hass.data[DOMAIN][entry.entry_id] + info = await elgato.info() + async_add_entities([ElgatoIdentifyButton(elgato, info)]) + + +class ElgatoIdentifyButton(ButtonEntity): + """Defines an Elgato identify button.""" + + def __init__(self, elgato: Elgato, info: Info) -> None: + """Initialize the button entity.""" + self.elgato = elgato + self._info = info + self.entity_description = ButtonEntityDescription( + key="identify", + name="Identify", + icon="mdi:help", + entity_category=EntityCategory.CONFIG, + ) + self._attr_unique_id = f"{info.serial_number}_{self.entity_description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Elgato Light.""" + return DeviceInfo( + identifiers={(DOMAIN, self._info.serial_number)}, + manufacturer="Elgato", + model=self._info.product_name, + name=self._info.product_name, + sw_version=f"{self._info.firmware_version} ({self._info.firmware_build_number})", + ) + + async def async_press(self) -> None: + """Identify the light, will make it blink.""" + try: + await self.elgato.identify() + except ElgatoError: + _LOGGER.exception("An error occurred while identifying the Elgato Light") diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 6008ccbee7780..12d1b5d1d936c 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -6,6 +6,7 @@ from elgato import Elgato, ElgatoError import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -41,10 +42,12 @@ async def async_step_user( return self._async_create_entry() - async def async_step_zeroconf(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - self.host = discovery_info[CONF_HOST] - self.port = discovery_info[CONF_PORT] + self.host = discovery_info.host + self.port = discovery_info.port or 9123 try: await self._get_elgato_serial_number() diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index 03a52b7e30564..6e63b598346ff 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -3,9 +3,6 @@ # Integration domain DOMAIN = "elgato" -# Home Assistant data keys -DATA_ELGATO_CLIENT = "elgato_client" - # Attributes ATTR_ON = "on" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index abd1fae410e69..d8a6e74c41a33 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -16,13 +16,6 @@ LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_SW_VERSION, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import ( @@ -30,7 +23,7 @@ async_get_current_platform, ) -from .const import DATA_ELGATO_CLIENT, DOMAIN, SERVICE_IDENTIFY +from .const import DOMAIN, SERVICE_IDENTIFY _LOGGER = logging.getLogger(__name__) @@ -44,7 +37,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato Light based on a config entry.""" - elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT] + elgato: Elgato = hass.data[DOMAIN][entry.entry_id] info = await elgato.info() settings = await elgato.settings() async_add_entities([ElgatoLight(elgato, info, settings)], True) @@ -67,32 +60,27 @@ def __init__(self, elgato: Elgato, info: Info, settings: Settings) -> None: self._state: State | None = None self.elgato = elgato - self._min_mired = 143 - self._max_mired = 344 - self._supported_color_modes = {COLOR_MODE_COLOR_TEMP} + min_mired = 143 + max_mired = 344 + supported_color_modes = {COLOR_MODE_COLOR_TEMP} # Elgato Light supporting color, have a different temperature range if settings.power_on_hue is not None: - self._supported_color_modes = {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} - self._min_mired = 153 - self._max_mired = 285 + supported_color_modes = {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} + min_mired = 153 + max_mired = 285 - @property - def name(self) -> str: - """Return the name of the entity.""" - # Return the product name, if display name is not set - return self._info.display_name or self._info.product_name + self._attr_max_mireds = max_mired + self._attr_min_mireds = min_mired + self._attr_name = info.display_name or info.product_name + self._attr_supported_color_modes = supported_color_modes + self._attr_unique_id = info.serial_number @property def available(self) -> bool: """Return True if entity is available.""" return self._state is not None - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._info.serial_number - @property def brightness(self) -> int | None: """Return the brightness of this light between 1..255.""" @@ -105,22 +93,6 @@ def color_temp(self) -> int | None: assert self._state is not None return self._state.temperature - @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._min_mired - - @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - # Elgato lights with color capabilities have a different highest value - return self._max_mired - - @property - def supported_color_modes(self) -> set[str]: - """Flag supported color modes.""" - return self._supported_color_modes - @property def color_mode(self) -> str | None: """Return the color mode of the light.""" @@ -175,6 +147,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: brightness and ATTR_HS_COLOR not in kwargs and ATTR_COLOR_TEMP not in kwargs + and self.supported_color_modes and COLOR_MODE_HS in self.supported_color_modes and self.color_mode == COLOR_MODE_COLOR_TEMP ): @@ -207,13 +180,13 @@ async def async_update(self) -> None: @property def device_info(self) -> DeviceInfo: """Return device information about this Elgato Light.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._info.serial_number)}, - ATTR_NAME: self._info.product_name, - ATTR_MANUFACTURER: "Elgato", - ATTR_MODEL: self._info.product_name, - ATTR_SW_VERSION: f"{self._info.firmware_version} ({self._info.firmware_build_number})", - } + return DeviceInfo( + identifiers={(DOMAIN, self._info.serial_number)}, + manufacturer="Elgato", + model=self._info.product_name, + name=self._info.product_name, + sw_version=f"{self._info.firmware_version} ({self._info.firmware_build_number})", + ) async def async_identify(self) -> None: """Identify the light, will make it blink.""" diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index dbb83f189959e..ebc2aca65278c 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -3,7 +3,7 @@ "name": "Elgato Light", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/elgato", - "requirements": ["elgato==2.1.0"], + "requirements": ["elgato==2.2.0"], "zeroconf": ["_elg._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/homeassistant/components/elgato/translations/bg.json b/homeassistant/components/elgato/translations/bg.json new file mode 100644 index 0000000000000..0e3a6d80ab10c --- /dev/null +++ b/homeassistant/components/elgato/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/ca.json b/homeassistant/components/elgato/translations/ca.json index 2302b83348129..79acea8100435 100644 --- a/homeassistant/components/elgato/translations/ca.json +++ b/homeassistant/components/elgato/translations/ca.json @@ -7,14 +7,14 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "Elgato Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { "host": "Amfitri\u00f3", "port": "Port" }, - "description": "Configura l'Elgato Light per integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 d'Elgato Light amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir a Home Assistant l'Elgato Light amb n\u00famero de s\u00e8rie `{serial_number}`?", diff --git a/homeassistant/components/elgato/translations/de.json b/homeassistant/components/elgato/translations/de.json index 1df8f91ecd6a2..6ff531919cb5e 100644 --- a/homeassistant/components/elgato/translations/de.json +++ b/homeassistant/components/elgato/translations/de.json @@ -1,24 +1,24 @@ { "config": { "abort": { - "already_configured": "Dieses Elgato Key Light-Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { "host": "Host", "port": "Port" }, - "description": "Richten dein Elgato Key Light f\u00fcr die Integration mit Home Assistant ein." + "description": "Richte deinen Elgato Key Light f\u00fcr die Integration mit Home Assistant ein." }, "zeroconf_confirm": { "description": "M\u00f6chtest du das Elgato Key Light mit der Seriennummer \"{serial_number} \" zu Home Assistant hinzuf\u00fcgen?", - "title": "Elgato Key Light Ger\u00e4t entdeckt" + "title": "Elgato Light Ger\u00e4t entdeckt" } } } diff --git a/homeassistant/components/elgato/translations/et.json b/homeassistant/components/elgato/translations/et.json index da01933c787cf..7f50ffc4c9895 100644 --- a/homeassistant/components/elgato/translations/et.json +++ b/homeassistant/components/elgato/translations/et.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "Elgato Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/elgato/translations/fr.json b/homeassistant/components/elgato/translations/fr.json index ccc325c84e233..5f3e99b04259d 100644 --- a/homeassistant/components/elgato/translations/fr.json +++ b/homeassistant/components/elgato/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Cet appareil Elgato Key Light est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "error": { "cannot_connect": "\u00c9chec de connexion" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "description": "Configurez votre Elgato Key Light pour l'int\u00e9grer \u00e0 Home Assistant." diff --git a/homeassistant/components/elgato/translations/he.json b/homeassistant/components/elgato/translations/he.json new file mode 100644 index 0000000000000..e0d0e50be749d --- /dev/null +++ b/homeassistant/components/elgato/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/hu.json b/homeassistant/components/elgato/translations/hu.json index ef6404bd92d5c..26740a33f21db 100644 --- a/homeassistant/components/elgato/translations/hu.json +++ b/homeassistant/components/elgato/translations/hu.json @@ -7,13 +7,18 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" - } + }, + "description": "\u00c1ll\u00edtsa be az Elgato Light-ot, hogy integr\u00e1lhat\u00f3 legyen az HomeAssistantba." + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni a \"{serial_number}\" sorozatsz\u00e1m\u00fa Elgato Light-ot az HomeAssistanthoz?", + "title": "Felfedezett Elgato Light eszk\u00f6z(\u00f6k)" } } } diff --git a/homeassistant/components/elgato/translations/id.json b/homeassistant/components/elgato/translations/id.json index b06691b9453cd..f9fa5690c1dc8 100644 --- a/homeassistant/components/elgato/translations/id.json +++ b/homeassistant/components/elgato/translations/id.json @@ -7,18 +7,18 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { "host": "Host", "port": "Port" }, - "description": "Siapkan Elgato Key Light Anda untuk diintegrasikan dengan Home Assistant." + "description": "Siapkan Elgato Light Anda untuk diintegrasikan dengan Home Assistant." }, "zeroconf_confirm": { - "description": "Ingin menambahkan Elgato Key Light dengan nomor seri `{serial_number}` ke Home Assistant?", - "title": "Perangkat Elgato Key Light yang ditemukan" + "description": "Ingin menambahkan Elgato Light dengan nomor seri `{serial_number}` ke Home Assistant?", + "title": "Perangkat Elgato Light yang ditemukan" } } } diff --git a/homeassistant/components/elgato/translations/it.json b/homeassistant/components/elgato/translations/it.json index b23a0aa93920e..52c718715b32d 100644 --- a/homeassistant/components/elgato/translations/it.json +++ b/homeassistant/components/elgato/translations/it.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Impossibile connettersi" }, - "flow_title": "Elgato Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/elgato/translations/ja.json b/homeassistant/components/elgato/translations/ja.json new file mode 100644 index 0000000000000..d7686d574fc6d --- /dev/null +++ b/homeassistant/components/elgato/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Elgato Key Light\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + }, + "zeroconf_confirm": { + "description": "\u30b7\u30ea\u30a2\u30eb\u756a\u53f7 `{serial_number}` \u306e\u3001Elgato Light\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "Elgato Light device\u3092\u767a\u898b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/nl.json b/homeassistant/components/elgato/translations/nl.json index 165d64df6b416..1605f4577ee80 100644 --- a/homeassistant/components/elgato/translations/nl.json +++ b/homeassistant/components/elgato/translations/nl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Kan geen verbinding maken" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { @@ -18,7 +18,7 @@ }, "zeroconf_confirm": { "description": "Wilt u de Elgato Key Light met serienummer `{serial_number}` toevoegen aan Home Assistant?", - "title": "Elgato Key Light apparaat ontdekt" + "title": "Elgato Light apparaat ontdekt" } } } diff --git a/homeassistant/components/elgato/translations/no.json b/homeassistant/components/elgato/translations/no.json index 8059138e3663d..0e3c4abdf6e74 100644 --- a/homeassistant/components/elgato/translations/no.json +++ b/homeassistant/components/elgato/translations/no.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "Elgato Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/elgato/translations/pl.json b/homeassistant/components/elgato/translations/pl.json index 37a6b94a5b126..94764903d1088 100644 --- a/homeassistant/components/elgato/translations/pl.json +++ b/homeassistant/components/elgato/translations/pl.json @@ -7,18 +7,18 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { "host": "Nazwa hosta lub adres IP", "port": "Port" }, - "description": "Konfiguracja Elgato Key Light w celu integracji z Home Assistantem." + "description": "Skonfiguruj Elgato Light, aby zintegrowa\u0107 go z Home Assistantem." }, "zeroconf_confirm": { - "description": "Czy chcesz doda\u0107 urz\u0105dzenie Elgato Key Light o numerze seryjnym `{serial_number}` do Home Assistanta?", - "title": "Wykryto urz\u0105dzenie Elgato Key Light" + "description": "Czy chcesz doda\u0107 urz\u0105dzenie Elgato Light o numerze seryjnym `{serial_number}` do Home Assistanta?", + "title": "Wykryto urz\u0105dzenie Elgato Light" } } } diff --git a/homeassistant/components/elgato/translations/ru.json b/homeassistant/components/elgato/translations/ru.json index e3af9572232d7..6641785b75fbb 100644 --- a/homeassistant/components/elgato/translations/ru.json +++ b/homeassistant/components/elgato/translations/ru.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, - "flow_title": "Elgato Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/elgato/translations/tr.json b/homeassistant/components/elgato/translations/tr.json index b2d1753fd68b3..eac6b36ced01a 100644 --- a/homeassistant/components/elgato/translations/tr.json +++ b/homeassistant/components/elgato/translations/tr.json @@ -7,12 +7,18 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{serial_number}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "port": "Port" - } + }, + "description": "Elgato Light'\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n." + }, + "zeroconf_confirm": { + "description": "Seri numaras\u0131 ` {serial_number} ` olan Elgato Light'\u0131 Home Assistant'a eklemek ister misiniz?", + "title": "Ke\u015ffedilen Elgato Light cihaz\u0131" } } } diff --git a/homeassistant/components/elgato/translations/zh-Hans.json b/homeassistant/components/elgato/translations/zh-Hans.json index 254f6df932756..94813c444ebb3 100644 --- a/homeassistant/components/elgato/translations/zh-Hans.json +++ b/homeassistant/components/elgato/translations/zh-Hans.json @@ -1,10 +1,25 @@ { "config": { "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 Elgato Light \u4ee5\u4e0e Home Assistant \u96c6\u6210\u3002" + }, + "zeroconf_confirm": { + "description": "\u60a8\u60f3\u5c06\u5e8f\u5217\u53f7\u4e3a `{serial_number}` \u7684 Elgato Light \u6dfb\u52a0\u5230 Home Assistant \u5417\uff1f", + "title": "\u53d1\u73b0 Elgato Light \u88c5\u7f6e" + } } } } \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/zh-Hant.json b/homeassistant/components/elgato/translations/zh-Hant.json index f1180a719baa6..9d6bd9ab761bf 100644 --- a/homeassistant/components/elgato/translations/zh-Hant.json +++ b/homeassistant/components/elgato/translations/zh-Hant.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "Elgato \u7167\u660e\uff1a{serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index a4d812850f70b..0ddb123d5f8e0 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -6,7 +6,11 @@ import eliqonline import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorStateClass, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, POWER_WATT from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -54,6 +58,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class EliqSensor(SensorEntity): """Implementation of an ELIQ Online sensor.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, api, channel_id, name): """Initialize the sensor.""" self._name = name @@ -72,12 +78,12 @@ def icon(self): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return UNIT_OF_MEASUREMENT @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index ff2f2533d24e9..8714a41a9a722 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -1,7 +1,11 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" +from __future__ import annotations + import asyncio import logging import re +from types import MappingProxyType +from typing import Any import async_timeout import elkm1_lib as elkm1 @@ -18,11 +22,12 @@ CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -53,12 +58,12 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ - "alarm_control_panel", - "climate", - "light", - "scene", - "sensor", - "switch", + Platform.ALARM_CONTROL_PANEL, + Platform.CLIMATE, + Platform.LIGHT, + Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, ] SPEAK_SERVICE_SCHEMA = vol.Schema( @@ -195,9 +200,9 @@ def _async_find_matching_config_entry(hass, prefix): return entry -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" - conf = entry.data + conf: MappingProxyType[str, Any] = entry.data _LOGGER.debug("Setting up elkm1 %s", conf["host"]) @@ -205,7 +210,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if conf[CONF_TEMPERATURE_UNIT] in (BARE_TEMP_CELSIUS, TEMP_CELSIUS): temperature_unit = TEMP_CELSIUS - config = {"temperature_unit": temperature_unit} + config: dict[str, Any] = {"temperature_unit": temperature_unit} if not conf[CONF_AUTO_CONFIGURE]: # With elkm1-lib==0.7.16 and later auto configure is available @@ -232,8 +237,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): elk.connect() def _element_changed(element, changeset): - keypress = changeset.get("last_keypress") - if keypress is None: + if (keypress := changeset.get("last_keypress")) is None: return hass.bus.async_fire( @@ -281,7 +285,7 @@ def _find_elk_by_prefix(hass, prefix): return hass.data[DOMAIN][entry_id]["elk"] -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -316,7 +320,7 @@ def sync_complete(): elk.add_handler("login", login_status) elk.add_handler("sync_complete", sync_complete) try: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): await event.wait() except asyncio.TimeoutError: _LOGGER.error( @@ -449,26 +453,26 @@ async def async_added_to_hass(self): self._element_callback(self._element, {}) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info connecting via the ElkM1 system.""" - return { - "via_device": (DOMAIN, f"{self._prefix}_system"), - } + return DeviceInfo( + via_device=(DOMAIN, f"{self._prefix}_system"), + ) class ElkAttachedEntity(ElkEntity): """An elk entity that is attached to the elk system.""" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for the underlying ElkM1 system.""" device_name = "ElkM1" if self._prefix: device_name += f" {self._prefix}" - return { - "name": device_name, - "identifiers": {(DOMAIN, f"{self._prefix}_system")}, - "sw_version": self._elk.panel.elkm1_version, - "manufacturer": "ELK Products, Inc.", - "model": "M1", - } + return DeviceInfo( + identifiers={(DOMAIN, f"{self._prefix}_system")}, + manufacturer="ELK Products, Inc.", + model="M1", + name=device_name, + sw_version=self._elk.panel.elkm1_version, + ) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index c3ed6bbc40d0b..04881b9f08595 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -117,8 +117,7 @@ async def async_added_to_hass(self): self._element.add_callback(self._watch_area) # We do not get changed_by back from resync. - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ATTR_CHANGED_BY_KEYPAD in last_state.attributes: @@ -141,8 +140,7 @@ def _watch_keypad(self, keypad, changeset): self.async_write_ha_state() def _watch_area(self, area, changeset): - last_log = changeset.get("last_log") - if not last_log: + if not (last_log := changeset.get("last_log")): return # user_number only set for arm/disarm logs if not last_log.get("user_number"): diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 6d10df45adfd7..bc5f3ae4b7af3 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -65,8 +65,9 @@ def current_temperature(self): @property def target_temperature(self): """Return the temperature we are trying to reach.""" - if (self._element.mode == ThermostatMode.HEAT.value) or ( - self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + if self._element.mode in ( + ThermostatMode.HEAT.value, + ThermostatMode.EMERGENCY_HEAT.value, ): return self._element.heat_setpoint if self._element.mode == ThermostatMode.COOL.value: diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index 919aad3d012c1..905aa35ad1900 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -25,12 +25,17 @@ _LOGGER = logging.getLogger(__name__) -PROTOCOL_MAP = {"secure": "elks://", "non-secure": "elk://", "serial": "serial://"} +PROTOCOL_MAP = { + "secure": "elks://", + "TLS 1.2": "elksv1_2://", + "non-secure": "elk://", + "serial": "serial://", +} DATA_SCHEMA = vol.Schema( { vol.Required(CONF_PROTOCOL, default="secure"): vol.In( - ["secure", "non-secure", "serial"] + ["secure", "TLS 1.2", "non-secure", "serial"] ), vol.Required(CONF_ADDRESS): str, vol.Optional(CONF_USERNAME, default=""): str, @@ -55,7 +60,7 @@ async def validate_input(data): prefix = data[CONF_PREFIX] url = _make_url_from_data(data) - requires_password = url.startswith("elks://") + requires_password = url.startswith("elks://") or url.startswith("elksv1_2") if requires_password and (not userid or not password): raise InvalidAuth @@ -74,8 +79,7 @@ async def validate_input(data): def _make_url_from_data(data): - host = data.get(CONF_HOST) - if host: + if host := data.get(CONF_HOST): return host protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]] diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 3f72ecfd7a722..3b341d90669cd 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -2,7 +2,7 @@ "domain": "elkm1", "name": "Elk-M1 Control", "documentation": "https://www.home-assistant.io/integrations/elkm1", - "requirements": ["elkm1-lib==0.8.10"], + "requirements": ["elkm1-lib==1.0.0"], "codeowners": ["@gwww", "@bdraco"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 4a75ccb242ed8..a488ce02ece2f 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -9,9 +9,10 @@ import voluptuous as vol from homeassistant.components.sensor import SensorEntity -from homeassistant.const import VOLT +from homeassistant.const import ELECTRIC_POTENTIAL_VOLT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import EntityCategory from . import ElkAttachedEntity, create_elk_entities from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA @@ -77,7 +78,7 @@ def __init__(self, element, elk, elk_data): self._state = None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -127,7 +128,7 @@ def temperature_unit(self): return self._temperature_unit @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._temperature_unit @@ -158,6 +159,8 @@ def _element_changed(self, element, changeset): class ElkPanel(ElkSensor): """Representation of an Elk-M1 Panel.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + @property def icon(self): """Icon to use in the frontend.""" @@ -250,12 +253,12 @@ def temperature_unit(self): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self._element.definition == ZoneType.TEMPERATURE.value: return self._temperature_unit if self._element.definition == ZoneType.ANALOG_ZONE.value: - return VOLT + return ELECTRIC_POTENTIAL_VOLT return None def _element_changed(self, element, changeset): diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml index 9f9fb2c39e57d..18608f3a476fd 100644 --- a/homeassistant/components/elkm1/services.yaml +++ b/homeassistant/components/elkm1/services.yaml @@ -1,126 +1,223 @@ alarm_bypass: + name: Alarm bypass description: Bypass all zones for the area. + target: + entity: + integration: elkm1 + domain: alarm_control_panel fields: - entity_id: - description: Name of alarm control panel to bypass. - example: "alarm_control_panel.main" code: + name: Code description: An code to authorize the bypass of the alarm control panel. + required: true example: 4242 + selector: + text: alarm_clear_bypass: + name: Alarm clear bypass description: Remove bypass on all zones for the area. + target: + entity: + integration: elkm1 + domain: alarm_control_panel fields: - entity_id: - description: Name of alarm control panel to clear bypass. - example: "alarm_control_panel.main" code: + name: Code description: An code to authorize the bypass clear of the alarm control panel. + required: true example: 4242 + selector: + text: alarm_arm_home_instant: + name: Alarm are home instant description: Arm the ElkM1 in home instant mode. + target: + entity: + integration: elkm1 + domain: alarm_control_panel fields: - entity_id: - description: Name of alarm control panel to arm. - example: "alarm_control_panel.main" code: + name: Code description: An code to arm the alarm control panel. + required: true example: 1234 + selector: + text: alarm_arm_night_instant: + name: Alarm arm night instant description: Arm the ElkM1 in night instant mode. + target: + entity: + integration: elkm1 + domain: alarm_control_panel fields: - entity_id: - description: Name of alarm control panel to arm. - example: "alarm_control_panel.main" code: + name: Code description: An code to arm the alarm control panel. + required: true example: 1234 + selector: + text: alarm_arm_vacation: + name: Alarm arm vacation description: Arm the ElkM1 in vacation mode. + target: + entity: + integration: elkm1 + domain: alarm_control_panel fields: - entity_id: - description: Name of alarm control panel to arm. - example: "alarm_control_panel.main" code: + name: Code description: An code to arm the alarm control panel. + required: true example: 1234 + selector: + text: alarm_display_message: + name: Alarm display message description: Display a message on all of the ElkM1 keypads for an area. + target: + entity: + integration: elkm1 + domain: alarm_control_panel fields: - entity_id: - description: Name of alarm control panel to display messages on. - example: "alarm_control_panel.main" clear: - description: 0=clear message, 1=clear message with * key, 2=Display until timeout; default 2 - example: 1 + name: Clear + description: 0=clear message, 1=clear message with * key, 2=Display until timeout + default: 2 + selector: + number: + min: 0 + max: 2 beep: - description: 0=no beep, 1=beep; default 0 - example: 1 + name: Beep + description: 0=no beep, 1=beep + default: 0 + selector: + boolean: timeout: - description: Time to display message, 0=forever, max 65535, default 0 - example: 4242 + name: Timeout + description: Time to display message, 0=forever, max 65535 + default: 0 + selector: + number: + min: 0 + max: 65535 line1: - description: Up to 16 characters of text (truncated if too long). Default blank. - example: The answer to life, + name: Line 1 + description: Up to 16 characters of text (truncated if too long). + example: The answer to life. + default: '' + selector: + text: line2: - description: Up to 16 characters of text (truncated if too long). Default blank. + name: Line 2 + description: Up to 16 characters of text (truncated if too long). example: the universe, and everything. + default: '' + selector: + text: set_time: + name: Set time description: Set the time for the panel. fields: prefix: + name: Prefix description: Prefix for the panel. example: gatehouse + selector: + text: speak_phrase: + name: Speak phrase description: Speak a phrase. See list of phrases in ElkM1 ASCII Protocol documentation. fields: number: + name: Phrase number description: Phrase number to speak. + required: true example: 42 + selector: + text: + prefix: + name: Prefix + description: Prefix to identify panel when multiple panels configured. + example: gatehouse + default: "" + selector: + text: speak_word: + name: Speak word description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation. fields: number: + name: Word number description: Word number to speak. - example: 142 + required: true + selector: + number: + min: 1 + max: 473 + prefix: + name: Prefix + description: Prefix to identify panel when multiple panels configured. + example: gatehouse + default: "" + selector: + text: sensor_counter_refresh: + name: Sensor counter refresh description: Refresh the value of a counter from the panel. - fields: - entity_id: - description: Name of counter to refresh. - example: "sensor.counting_sheep" + target: + entity: + integration: elkm1 + domain: sensor sensor_counter_set: + name: Sensor counter set description: Set the value of a counter on the panel. + target: + entity: + integration: elkm1 + domain: sensor fields: - entity_id: - description: Name of counter to set. - example: "sensor.test42" value: + name: Value description: Value to set the counter to. - example: 4242 + required: true + selector: + number: + min: 0 + max: 65536 sensor_zone_bypass: + name: Sensor zone bypass description: Bypass zone. + target: + entity: + integration: elkm1 + domain: sensor fields: - entity_id: - description: Name of zone to bypass. - example: "sensor.window42" code: + name: Code description: An code to authorize the bypass of the zone. + required: true example: 4242 + selector: + text: sensor_zone_trigger: + name: Sensor zone trigger description: Trigger zone. - fields: - entity_id: - description: Name of zone to trigger. - example: "sensor.motion42" + target: + entity: + integration: elkm1 + domain: sensor diff --git a/homeassistant/components/elkm1/translations/de.json b/homeassistant/components/elkm1/translations/de.json index 8157a061d82d5..137f781fd05a8 100644 --- a/homeassistant/components/elkm1/translations/de.json +++ b/homeassistant/components/elkm1/translations/de.json @@ -14,13 +14,13 @@ "data": { "address": "Die IP-Adresse, die Domain oder der serielle Port bei einer seriellen Verbindung.", "password": "Passwort", - "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn Sie nur einen ElkM1 haben).", + "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn du nur einen ElkM1 hast).", "protocol": "Protokoll", "temperature_unit": "Die von ElkM1 verwendete Temperatureinheit.", "username": "Benutzername" }, "description": "Die Adresszeichenfolge muss in der Form 'adresse[:port]' f\u00fcr 'sicher' und 'nicht sicher' vorliegen. Beispiel: '192.168.1.1'. Der Port ist optional und standardm\u00e4\u00dfig 2101 f\u00fcr \"nicht sicher\" und 2601 f\u00fcr \"sicher\". F\u00fcr das serielle Protokoll muss die Adresse die Form 'tty[:baud]' haben. Beispiel: '/dev/ttyS1'. Der Baudrate ist optional und standardm\u00e4\u00dfig 115200.", - "title": "Stellen Sie eine Verbindung zur Elk-M1-Steuerung her" + "title": "Stelle eine Verbindung zur Elk-M1-Steuerung her" } } } diff --git a/homeassistant/components/elkm1/translations/fr.json b/homeassistant/components/elkm1/translations/fr.json index 618299def2920..665ac4b4d92d5 100644 --- a/homeassistant/components/elkm1/translations/fr.json +++ b/homeassistant/components/elkm1/translations/fr.json @@ -5,8 +5,8 @@ "already_configured": "Un ElkM1 avec ce pr\u00e9fixe est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/elkm1/translations/he.json b/homeassistant/components/elkm1/translations/he.json index ac90b3264eab3..e85bab17ac036 100644 --- a/homeassistant/components/elkm1/translations/he.json +++ b/homeassistant/components/elkm1/translations/he.json @@ -1,9 +1,15 @@ { "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, "step": { "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "protocol": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } } diff --git a/homeassistant/components/elkm1/translations/hu.json b/homeassistant/components/elkm1/translations/hu.json index 83862dfb75f24..ff6445f0b728b 100644 --- a/homeassistant/components/elkm1/translations/hu.json +++ b/homeassistant/components/elkm1/translations/hu.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "address_already_configured": "Az ElkM1 ezzel a c\u00edmmel m\u00e1r konfigur\u00e1lva van", + "already_configured": "Az ezzel az el\u0151taggal rendelkez\u0151 ElkM1 m\u00e1r konfigur\u00e1lva van" + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", @@ -8,10 +12,15 @@ "step": { "user": { "data": { + "address": "Az IP-c\u00edm vagy tartom\u00e1ny vagy soros port, ha soros kapcsolaton kereszt\u00fcl csatlakozik.", "password": "Jelsz\u00f3", + "prefix": "Egyedi el\u0151tag (hagyja \u00fcresen, ha csak egy ElkM1 van).", "protocol": "Protokoll", + "temperature_unit": "Az ElkM1 h\u0151m\u00e9rs\u00e9kleti egys\u00e9g haszn\u00e1lja.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A c\u00edmsornak a \u201ebiztons\u00e1gos\u201d \u00e9s a \u201enem biztons\u00e1gos\u201d \u201ec\u00edm [: port]\u201d form\u00e1tum\u00fanak kell lennie. P\u00e9lda: '192.168.1.1'. A port opcion\u00e1lis, \u00e9s alap\u00e9rtelmez\u00e9s szerint 2101 \u201enem biztons\u00e1gos\u201d \u00e9s 2601 \u201ebiztons\u00e1gos\u201d. A soros protokollhoz a c\u00edmnek 'tty [: baud]' form\u00e1tum\u00fanak kell lennie. P\u00e9lda: '/dev/ttyS1'. A baud opcion\u00e1lis, \u00e9s alap\u00e9rtelmez\u00e9s szerint 115200.", + "title": "Csatlakoz\u00e1s az Elk-M1 vez\u00e9rl\u0151h\u00f6z" } } } diff --git a/homeassistant/components/elkm1/translations/it.json b/homeassistant/components/elkm1/translations/it.json index 18e53997937f5..b22ba8c852878 100644 --- a/homeassistant/components/elkm1/translations/it.json +++ b/homeassistant/components/elkm1/translations/it.json @@ -14,12 +14,12 @@ "data": { "address": "L'indirizzo IP o il dominio o la porta seriale se ci si connette tramite seriale.", "password": "Password", - "prefix": "Un prefisso univoco (lasciare vuoto se si dispone di un solo ElkM1).", + "prefix": "Un prefisso univoco (lascia vuoto se disponi di un solo ElkM1).", "protocol": "Protocollo", "temperature_unit": "L'unit\u00e0 di temperatura utilizzata da ElkM1.", "username": "Nome utente" }, - "description": "La stringa di indirizzi deve essere nella forma \"address[:port]\" per \"secure\" e \"non secure\". Esempio: '192.168.1.1.1'. La porta \u00e8 facoltativa e il valore predefinito \u00e8 2101 per 'non sicuro' e 2601 per 'sicuro'. Per il protocollo seriale, l'indirizzo deve essere nella forma 'tty[:baud]'. Esempio: '/dev/ttyS1'. Il baud \u00e8 opzionale e il valore predefinito \u00e8 115200.", + "description": "La stringa di indirizzi deve essere nella forma 'indirizzo[:porta]' per 'sicuro' e 'non sicuro'. Esempio: '192.168.1.1.1'. La porta \u00e8 facoltativa e il valore predefinito \u00e8 2101 per 'non sicuro' e 2601 per 'sicuro'. Per il protocollo seriale, l'indirizzo deve essere nella forma 'tty[:baud]'. Esempio: '/dev/ttyS1'. Il baud \u00e8 opzionale e il valore predefinito \u00e8 115200.", "title": "Collegamento al controllo Elk-M1" } } diff --git a/homeassistant/components/elkm1/translations/ja.json b/homeassistant/components/elkm1/translations/ja.json new file mode 100644 index 0000000000000..a2dea3c10cfba --- /dev/null +++ b/homeassistant/components/elkm1/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "\u3053\u306e\u30a2\u30c9\u30ec\u30b9\u306eElkM1\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_configured": "\u3053\u306e\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u3092\u6301\u3064ElkM1\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "address": "IP\u30a2\u30c9\u30ec\u30b9\u307e\u305f\u306f\u30c9\u30e1\u30a4\u30f3\u3001\u30b7\u30ea\u30a2\u30eb\u3067\u63a5\u7d9a\u3059\u308b\u5834\u5408\u306f\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3002", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "prefix": "\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)\u306a\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9(ElkM1\u304c1\u3064\u3057\u304b\u306a\u3044\u5834\u5408\u306f\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059)", + "protocol": "\u30d7\u30ed\u30c8\u30b3\u30eb", + "temperature_unit": "ElkM1\u304c\u4f7f\u7528\u3059\u308b\u6e29\u5ea6\u5358\u4f4d\u3002", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30a2\u30c9\u30ec\u30b9\u6587\u5b57\u5217\u306f\u3001 '\u30bb\u30ad\u30e5\u30a2 '\u304a\u3088\u3073 '\u975e\u30bb\u30ad\u30e5\u30a2 '\u306e\u5834\u5408\u306f\u3001'address[:port]'\u306e\u5f62\u5f0f\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u4f8b: '192.168.1.1'\u3002\u30dd\u30fc\u30c8\u306f\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u306f'\u975e\u30bb\u30ad\u30e5\u30a2'\u306e\u5834\u5408\u306f\u30012101 \u3067'\u30bb\u30ad\u30e5\u30a2'\u306e\u5834\u5408\u306f\u30012601 \u3067\u3059\u3002\u30b7\u30ea\u30a2\u30eb \u30d7\u30ed\u30c8\u30b3\u30eb\u306e\u5834\u5408\u3001\u30a2\u30c9\u30ec\u30b9\u306f\u3001'tty[:baud]' \u306e\u5f62\u5f0f\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002\u4f8b: '/dev/ttyS1'\u3002\u30dc\u30fc\u306f\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u306f115200\u3067\u3059\u3002", + "title": "Elk-M1 Control\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/tr.json b/homeassistant/components/elkm1/translations/tr.json index 9259220985bb1..3b62ad5e07991 100644 --- a/homeassistant/components/elkm1/translations/tr.json +++ b/homeassistant/components/elkm1/translations/tr.json @@ -12,9 +12,15 @@ "step": { "user": { "data": { + "address": "Seri yoluyla ba\u011flan\u0131l\u0131yorsa IP adresi veya etki alan\u0131 veya seri ba\u011flant\u0131 noktas\u0131.", "password": "Parola", + "prefix": "Benzersiz bir \u00f6nek (yaln\u0131zca bir ElkM1'iniz varsa bo\u015f b\u0131rak\u0131n).", + "protocol": "Protokol", + "temperature_unit": "ElkM1'in kulland\u0131\u011f\u0131 s\u0131cakl\u0131k birimi.", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Adres dizesi, 'g\u00fcvenli' ve 'g\u00fcvenli olmayan' i\u00e7in 'adres[:port]' bi\u00e7iminde olmal\u0131d\u0131r. \u00d6rnek: '192.168.1.1'. Ba\u011flant\u0131 noktas\u0131 iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 'g\u00fcvenli olmayan' i\u00e7in 2101 ve 'g\u00fcvenli' i\u00e7in 2601'dir. Seri protokol i\u00e7in adres 'tty[:baud]' bi\u00e7iminde olmal\u0131d\u0131r. \u00d6rnek: '/dev/ttyS1'. Baud iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 115200'd\u00fcr.", + "title": "Elk-M1 Kontrol\u00fcne Ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py new file mode 100644 index 0000000000000..8b136cd2acdea --- /dev/null +++ b/homeassistant/components/elmax/__init__.py @@ -0,0 +1,56 @@ +"""The elmax-cloud integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from .common import ElmaxCoordinator +from .const import ( + CONF_ELMAX_PANEL_ID, + CONF_ELMAX_PANEL_PIN, + CONF_ELMAX_PASSWORD, + CONF_ELMAX_USERNAME, + DOMAIN, + ELMAX_PLATFORMS, + POLLING_SECONDS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up elmax-cloud from a config entry.""" + # Create the API client object and attempt a login, so that we immediately know + # if there is something wrong with user credentials + coordinator = ElmaxCoordinator( + hass=hass, + logger=_LOGGER, + username=entry.data[CONF_ELMAX_USERNAME], + password=entry.data[CONF_ELMAX_PASSWORD], + panel_id=entry.data[CONF_ELMAX_PANEL_ID], + panel_pin=entry.data[CONF_ELMAX_PANEL_PIN], + name=f"Elmax Cloud {entry.entry_id}", + update_interval=timedelta(seconds=POLLING_SECONDS), + ) + + # Issue a first refresh, so that we trigger a re-auth flow if necessary + await coordinator.async_config_entry_first_refresh() + + # Store a global reference to the coordinator for later use + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + # Perform platform initialization. + hass.config_entries.async_setup_platforms(entry, ELMAX_PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py new file mode 100644 index 0000000000000..43d1cbff150ea --- /dev/null +++ b/homeassistant/components/elmax/common.py @@ -0,0 +1,230 @@ +"""Elmax integration common classes and utilities.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from logging import Logger +from typing import Any + +import async_timeout +from elmax_api.exceptions import ( + ElmaxApiError, + ElmaxBadLoginError, + ElmaxBadPinError, + ElmaxNetworkError, +) +from elmax_api.http import Elmax +from elmax_api.model.endpoint import DeviceEndpoint +from elmax_api.model.panel import PanelEntry, PanelStatus + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_TIMEOUT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ElmaxCoordinator(DataUpdateCoordinator): + """Coordinator helper to handle Elmax API polling.""" + + def __init__( + self, + hass: HomeAssistantType, + logger: Logger, + username: str, + password: str, + panel_id: str, + panel_pin: str, + name: str, + update_interval: timedelta, + ) -> None: + """Instantiate the object.""" + self._client = Elmax(username=username, password=password) + self._panel_id = panel_id + self._panel_pin = panel_pin + self._panel_entry = None + self._state_by_endpoint = None + super().__init__( + hass=hass, logger=logger, name=name, update_interval=update_interval + ) + + @property + def panel_entry(self) -> PanelEntry | None: + """Return the panel entry.""" + return self._panel_entry + + @property + def panel_status(self) -> PanelStatus | None: + """Return the last fetched panel status.""" + return self.data + + def get_endpoint_state(self, endpoint_id: str) -> DeviceEndpoint | None: + """Return the last fetched status for the given endpoint-id.""" + if self._state_by_endpoint is not None: + return self._state_by_endpoint.get(endpoint_id) + return None + + @property + def http_client(self): + """Return the current http client being used by this instance.""" + return self._client + + async def _async_update_data(self): + try: + async with async_timeout.timeout(DEFAULT_TIMEOUT): + # Retrieve the panel online status first + panels = await self._client.list_control_panels() + panels = list(filter(lambda x: x.hash == self._panel_id, panels)) + + # If the panel is no more available within the given. Raise config error as the user must + # reconfigure it in order to make it work again + if len(panels) < 1: + _LOGGER.error( + "Panel ID %s is no more linked to this user account", + self._panel_id, + ) + raise ConfigEntryAuthFailed() + + panel = panels[0] + self._panel_entry = panel + + # If the panel is online, proceed with fetching its state + # and return it right away + if panel.online: + status = await self._client.get_panel_status( + control_panel_id=panel.hash, pin=self._panel_pin + ) # type: PanelStatus + + # Store a dictionary for fast endpoint state access + self._state_by_endpoint = { + k.endpoint_id: k for k in status.all_endpoints + } + return status + + # Otherwise, return None. Listeners will know that this means the device is offline + return None + + except ElmaxBadPinError as err: + _LOGGER.error("Control panel pin was refused") + raise ConfigEntryAuthFailed from err + except ElmaxBadLoginError as err: + _LOGGER.error("Refused username/password") + raise ConfigEntryAuthFailed from err + except ElmaxApiError as err: + raise HomeAssistantError( + f"Error communicating with ELMAX API: {err}" + ) from err + except ElmaxNetworkError as err: + raise HomeAssistantError( + "Network error occurred while contacting ELMAX cloud" + ) from err + except Exception as err: + _LOGGER.exception("Unexpected exception") + raise HomeAssistantError("An unexpected error occurred") from err + + +class ElmaxEntity(Entity): + """Wrapper for Elmax entities.""" + + def __init__( + self, + panel: PanelEntry, + elmax_device: DeviceEndpoint, + panel_version: str, + coordinator: ElmaxCoordinator, + ) -> None: + """Construct the object.""" + self._panel = panel + self._device = elmax_device + self._panel_version = panel_version + self._coordinator = coordinator + self._transitory_state = None + + @property + def transitory_state(self) -> Any | None: + """Return the transitory state for this entity.""" + return self._transitory_state + + @transitory_state.setter + def transitory_state(self, value: Any) -> None: + """Set the transitory state value.""" + self._transitory_state = value + + @property + def panel_id(self) -> str: + """Retrieve the panel id.""" + return self._panel.hash + + @property + def unique_id(self) -> str | None: + """Provide a unique id for this entity.""" + return self._device.endpoint_id + + @property + def name(self) -> str | None: + """Return the entity name.""" + return self._device.name + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra attributes.""" + return { + "index": self._device.index, + "visible": self._device.visible, + } + + @property + def device_info(self): + """Return device specific attributes.""" + return { + "identifiers": {(DOMAIN, self._panel.hash)}, + "name": self._panel.get_name_by_user( + self._coordinator.http_client.get_authenticated_username() + ), + "manufacturer": "Elmax", + "model": self._panel_version, + "sw_version": self._panel_version, + } + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._panel.online + + def _http_data_changed(self) -> None: + # Whenever new HTTP data is received from the coordinator we extract the stat of this + # device and store it locally for later use + device_state = self._coordinator.get_endpoint_state(self._device.endpoint_id) + if self._device is None or device_state.__dict__ != self._device.__dict__: + # If HTTP data has changed, we need to schedule a forced refresh + self._device = device_state + self.async_schedule_update_ha_state(force_refresh=True) + + # Reset the transitory state as we did receive a fresh state + self._transitory_state = None + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass. + + To be extended by integrations. + """ + self._coordinator.async_add_listener(self._http_data_changed) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass. + + To be extended by integrations. + """ + self._coordinator.async_remove_listener(self._http_data_changed) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py new file mode 100644 index 0000000000000..5cd2169c6952a --- /dev/null +++ b/homeassistant/components/elmax/config_flow.py @@ -0,0 +1,250 @@ +"""Config flow for elmax-cloud integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError +from elmax_api.http import Elmax +from elmax_api.model.panel import PanelEntry +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import ( + CONF_ELMAX_PANEL_ID, + CONF_ELMAX_PANEL_NAME, + CONF_ELMAX_PANEL_PIN, + CONF_ELMAX_PASSWORD, + CONF_ELMAX_USERNAME, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +LOGIN_FORM_SCHEMA = vol.Schema( + { + vol.Required(CONF_ELMAX_USERNAME): str, + vol.Required(CONF_ELMAX_PASSWORD): str, + } +) + + +def _store_panel_by_name( + panel: PanelEntry, username: str, panel_names: dict[str, str] +) -> None: + original_panel_name = panel.get_name_by_user(username=username) + panel_id = panel.hash + collisions_count = 0 + panel_name = original_panel_name + while panel_name in panel_names: + # Handle same-name collision. + collisions_count += 1 + panel_name = f"{original_panel_name} ({collisions_count})" + panel_names[panel_name] = panel_id + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for elmax-cloud.""" + + VERSION = 1 + + def __init__(self): + """Initialize.""" + self._client: Elmax = None + self._username: str = None + self._password: str = None + self._panels_schema = None + self._panel_names = None + self._reauth_username = None + self._reauth_panelid = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + # When invokes without parameters, show the login form. + if user_input is None: + return self.async_show_form(step_id="user", data_schema=LOGIN_FORM_SCHEMA) + + errors: dict[str, str] = {} + username = user_input[CONF_ELMAX_USERNAME] + password = user_input[CONF_ELMAX_PASSWORD] + + # Otherwise, it means we are handling now the "submission" of the user form. + # In this case, let's try to log in to the Elmax cloud and retrieve the available panels. + try: + client = Elmax(username=username, password=password) + await client.login() + + # If the login succeeded, retrieve the list of available panels and filter the online ones + online_panels = [x for x in await client.list_control_panels() if x.online] + + # If no online panel was found, we display an error in the next UI. + panels = list(online_panels) + if len(panels) < 1: + raise NoOnlinePanelsError() + + # Show the panel selection. + # We want the user to choose the panel using the associated name, we set up a mapping + # dictionary to handle that case. + panel_names: dict[str, str] = {} + username = client.get_authenticated_username() + for panel in panels: + _store_panel_by_name( + panel=panel, username=username, panel_names=panel_names + ) + + self._client = client + self._panel_names = panel_names + schema = vol.Schema( + { + vol.Required(CONF_ELMAX_PANEL_NAME): vol.In( + self._panel_names.keys() + ), + vol.Required(CONF_ELMAX_PANEL_PIN, default="000000"): str, + } + ) + self._panels_schema = schema + self._username = username + self._password = password + return self.async_show_form( + step_id="panels", data_schema=schema, errors=errors + ) + + except ElmaxBadLoginError: + _LOGGER.error("Wrong credentials or failed login") + errors["base"] = "bad_auth" + except NoOnlinePanelsError: + _LOGGER.warning("No online device panel was found") + errors["base"] = "no_panel_online" + except ElmaxNetworkError: + _LOGGER.exception("A network error occurred") + errors["base"] = "network_error" + + # If an error occurred, show back the login form. + return self.async_show_form( + step_id="user", data_schema=LOGIN_FORM_SCHEMA, errors=errors + ) + + async def async_step_panels(self, user_input: dict[str, Any]) -> FlowResult: + """Handle Panel selection step.""" + errors = {} + panel_name = user_input[CONF_ELMAX_PANEL_NAME] + panel_pin = user_input[CONF_ELMAX_PANEL_PIN] + + # Lookup the panel id from the panel name. + panel_id = self._panel_names[panel_name] + + # Make sure this is the only elmax integration for this specific panel id. + await self.async_set_unique_id(panel_id) + self._abort_if_unique_id_configured() + + # Try to list all the devices using the given PIN. + try: + await self._client.get_panel_status( + control_panel_id=panel_id, pin=panel_pin + ) + return self.async_create_entry( + title=f"Elmax {panel_name}", + data={ + CONF_ELMAX_PANEL_ID: panel_id, + CONF_ELMAX_PANEL_PIN: panel_pin, + CONF_ELMAX_USERNAME: self._username, + CONF_ELMAX_PASSWORD: self._password, + }, + ) + except ElmaxBadPinError: + errors["base"] = "invalid_pin" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error occurred") + errors["base"] = "unknown_error" + + return self.async_show_form( + step_id="panels", data_schema=self._panels_schema, errors=errors + ) + + async def async_step_reauth(self, user_input=None): + """Perform reauth upon an API authentication error.""" + self._reauth_username = user_input.get(CONF_ELMAX_USERNAME) + self._reauth_panelid = user_input.get(CONF_ELMAX_PANEL_ID) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle reauthorization flow.""" + errors = {} + if user_input is not None: + panel_pin = user_input.get(CONF_ELMAX_PANEL_PIN) + password = user_input.get(CONF_ELMAX_PASSWORD) + entry = await self.async_set_unique_id(self._reauth_panelid) + + # Handle authentication, make sure the panel we are re-authenticating against is listed among results + # and verify its pin is correct. + try: + # Test login. + client = Elmax(username=self._reauth_username, password=password) + await client.login() + + # Make sure the panel we are authenticating to is still available. + panels = [ + p + for p in await client.list_control_panels() + if p.hash == self._reauth_panelid + ] + if len(panels) < 1: + raise NoOnlinePanelsError() + + # Verify the pin is still valid.from + await client.get_panel_status( + control_panel_id=self._reauth_panelid, pin=panel_pin + ) + + # If it is, proceed with configuration update. + self.hass.config_entries.async_update_entry( + entry, + data={ + CONF_ELMAX_PANEL_ID: self._reauth_panelid, + CONF_ELMAX_PANEL_PIN: panel_pin, + CONF_ELMAX_USERNAME: self._reauth_username, + CONF_ELMAX_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + self._reauth_username = None + self._reauth_panelid = None + return self.async_abort(reason="reauth_successful") + + except ElmaxBadLoginError: + _LOGGER.error( + "Wrong credentials or failed login while re-authenticating" + ) + errors["base"] = "bad_auth" + except NoOnlinePanelsError: + _LOGGER.warning( + "Panel ID %s is no longer associated to this user", + self._reauth_panelid, + ) + errors["base"] = "reauth_panel_disappeared" + except ElmaxBadPinError: + errors["base"] = "invalid_pin" + + # We want the user to re-authenticate only for the given panel id using the same login. + # We pin them to the UI, so the user realizes she must log in with the appropriate credentials + # for the that specific panel. + schema = vol.Schema( + { + vol.Required(CONF_ELMAX_USERNAME): self._reauth_username, + vol.Required(CONF_ELMAX_PASSWORD): str, + vol.Required(CONF_ELMAX_PANEL_ID): self._reauth_panelid, + vol.Required(CONF_ELMAX_PANEL_PIN): str, + } + ) + return self.async_show_form( + step_id="reauth_confirm", data_schema=schema, errors=errors + ) + + +class NoOnlinePanelsError(HomeAssistantError): + """Error occurring when no online panel was found.""" diff --git a/homeassistant/components/elmax/const.py b/homeassistant/components/elmax/const.py new file mode 100644 index 0000000000000..21864e98f1a52 --- /dev/null +++ b/homeassistant/components/elmax/const.py @@ -0,0 +1,17 @@ +"""Constants for the elmax-cloud integration.""" +from homeassistant.const import Platform + +DOMAIN = "elmax" +CONF_ELMAX_USERNAME = "username" +CONF_ELMAX_PASSWORD = "password" +CONF_ELMAX_PANEL_ID = "panel_id" +CONF_ELMAX_PANEL_PIN = "panel_pin" +CONF_ELMAX_PANEL_NAME = "panel_name" + +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_ENDPOINT_ID = "endpoint_id" + +ELMAX_PLATFORMS = [Platform.SWITCH] + +POLLING_SECONDS = 30 +DEFAULT_TIMEOUT = 10.0 diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json new file mode 100644 index 0000000000000..b89ca55ce3db6 --- /dev/null +++ b/homeassistant/components/elmax/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "elmax", + "name": "Elmax", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/elmax", + "requirements": ["elmax_api==0.0.2"], + "codeowners": [ + "@albertogeniola" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json new file mode 100644 index 0000000000000..505622aa6ae53 --- /dev/null +++ b/homeassistant/components/elmax/strings.json @@ -0,0 +1,34 @@ +{ + "title": "Elmax Cloud Setup", + "config": { + "step": { + "user": { + "title": "Account Login", + "description": "Please login to the Elmax cloud using your credentials", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, + "panels": { + "title": "Panel selection", + "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", + "data": { + "panel_name": "Panel Name", + "panel_id": "Panel ID", + "panel_pin": "PIN Code" + } + } + }, + "error": { + "no_panel_online": "No online Elmax control panel was found.", + "bad_auth": "Invalid authentication", + "network_error": "A network error occurred", + "invalid_pin": "The provided pin is invalid", + "unknown_error": "An unexpected error occurred" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py new file mode 100644 index 0000000000000..11c0c406576a8 --- /dev/null +++ b/homeassistant/components/elmax/switch.py @@ -0,0 +1,84 @@ +"""Elmax switch platform.""" +from typing import Any + +from elmax_api.model.command import SwitchCommand +from elmax_api.model.panel import PanelStatus + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import HomeAssistantType + +from . import ElmaxCoordinator +from .common import ElmaxEntity +from .const import DOMAIN + + +class ElmaxSwitch(ElmaxEntity, SwitchEntity): + """Implement the Elmax switch entity.""" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.transitory_state is not None: + return self.transitory_state + return self._device.opened + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + client = self._coordinator.http_client + await client.execute_command( + endpoint_id=self._device.endpoint_id, command=SwitchCommand.TURN_ON + ) + self.transitory_state = True + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + client = self._coordinator.http_client + await client.execute_command( + endpoint_id=self._device.endpoint_id, command=SwitchCommand.TURN_OFF + ) + self.transitory_state = False + await self.async_update_ha_state() + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return False + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Elmax switch platform.""" + coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + known_devices = set() + + def _discover_new_devices(): + panel_status = coordinator.panel_status # type: PanelStatus + # In case the panel is offline, its status will be None. In that case, simply do nothing + if panel_status is None: + return + + # Otherwise, add all the entities we found + entities = [] + for actuator in panel_status.actuators: + entity = ElmaxSwitch( + panel=coordinator.panel_entry, + elmax_device=actuator, + panel_version=panel_status.release, + coordinator=coordinator, + ) + if entity.unique_id not in known_devices: + entities.append(entity) + async_add_entities(entities, True) + known_devices.update([entity.unique_id for entity in entities]) + + # Register a listener for the discovery of new devices + coordinator.async_add_listener(_discover_new_devices) + + # Immediately run a discovery, so we don't need to wait for the next update + _discover_new_devices() diff --git a/homeassistant/components/elmax/translations/bg.json b/homeassistant/components/elmax/translations/bg.json new file mode 100644 index 0000000000000..643587133a7c8 --- /dev/null +++ b/homeassistant/components/elmax/translations/bg.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "bad_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "invalid_pin": "\u041f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d\u0438\u044f\u0442 \u041f\u0418\u041d \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d", + "network_error": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043c\u0440\u0435\u0436\u043e\u0432\u0430 \u0433\u0440\u0435\u0448\u043a\u0430", + "unknown_error": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "panels": { + "data": { + "panel_id": "ID \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0430", + "panel_name": "\u0418\u043c\u0435 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0430", + "panel_pin": "\u041f\u0418\u041d \u043a\u043e\u0434" + } + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/ca.json b/homeassistant/components/elmax/translations/ca.json new file mode 100644 index 0000000000000..c6be03cdac8f1 --- /dev/null +++ b/homeassistant/components/elmax/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "bad_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_pin": "El pin proporcionat no \u00e9s v\u00e0lid", + "network_error": "S'ha produ\u00eft un error de xarxa", + "no_panel_online": "No s'ha trobat cap panell de control d'Elmax en l\u00ednia.", + "unknown_error": "S'ha produ\u00eft un error desconegut" + }, + "step": { + "panels": { + "data": { + "panel_id": "ID del panell", + "panel_name": "Nom del panell", + "panel_pin": "Codi PIN" + }, + "description": "Selecciona quin panell vols controlar amb aquesta integraci\u00f3. Tingues en compte que el panell ha d'estar ON per poder ser configurat.", + "title": "Selecci\u00f3 del panell" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Inicia sessi\u00f3 a Elmax Cloud amb les teves credencials", + "title": "Inici de sessi\u00f3" + } + } + }, + "title": "Configuraci\u00f3 d'Elmax Cloud" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/cs.json b/homeassistant/components/elmax/translations/cs.json new file mode 100644 index 0000000000000..1fa9b6a9196bf --- /dev/null +++ b/homeassistant/components/elmax/translations/cs.json @@ -0,0 +1,31 @@ +{ + "config": { + "error": { + "bad_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "invalid_pin": "Poskytnut\u00fd k\u00f3d PIN je neplatn\u00fd", + "network_error": "Do\u0161lo k chyb\u011b s\u00edt\u011b", + "no_panel_online": "Nebyl nalezen \u017e\u00e1dn\u00fd online ovl\u00e1dac\u00ed panel Elmax.", + "unknown_error": "Vyskytla se neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "panels": { + "data": { + "panel_id": "ID panelu", + "panel_name": "N\u00e1zev panelu", + "panel_pin": "PIN k\u00f3d" + }, + "description": "Vyberte, kter\u00fd panel chcete touto integrac\u00ed ovl\u00e1dat. Vezm\u011bte pros\u00edm na v\u011bdom\u00ed, \u017ee panel mus\u00ed b\u00fdt zapnut\u00fd, aby mohl b\u00fdt nakonfigurov\u00e1n.", + "title": "V\u00fdb\u011br panelu" + }, + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "description": "P\u0159ihlaste se do cloudu Elmax pomoc\u00ed sv\u00fdch p\u0159ihla\u0161ovac\u00edch \u00fadaj\u016f", + "title": "P\u0159ihl\u00e1\u0161en\u00ed k \u00fa\u010dtu" + } + } + }, + "title": "Nastaven\u00ed Elmax Cloud" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/de.json b/homeassistant/components/elmax/translations/de.json new file mode 100644 index 0000000000000..aa66cae4b190a --- /dev/null +++ b/homeassistant/components/elmax/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "bad_auth": "Ung\u00fcltige Authentifizierung", + "invalid_pin": "Die angegebene Pin ist ung\u00fcltig", + "network_error": "Ein Netzwerkfehler ist aufgetreten", + "no_panel_online": "Es wurde kein Elmax-Bedienfeld gefunden, das online ist.", + "unknown_error": "Ein unerwarteter Fehler ist aufgetreten" + }, + "step": { + "panels": { + "data": { + "panel_id": "Panel-ID", + "panel_name": "Panel-Name", + "panel_pin": "PIN-Code" + }, + "description": "W\u00e4hle die Zentrale aus, die du mit dieser Integration steuern m\u00f6chtest. Bitte beachte, dass die Zentrale eingeschaltet sein muss, damit sie konfiguriert werden kann.", + "title": "Panelauswahl" + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte melde dich mit deinen Zugangsdaten bei der Elmax-Cloud an", + "title": "Konto-Anmeldung" + } + } + }, + "title": "Elmax Cloud-Einrichtung" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/en.json b/homeassistant/components/elmax/translations/en.json new file mode 100644 index 0000000000000..b3de51d64fcb6 --- /dev/null +++ b/homeassistant/components/elmax/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "bad_auth": "Invalid authentication", + "invalid_pin": "The provided pin is invalid", + "network_error": "A network error occurred", + "no_panel_online": "No online Elmax control panel was found.", + "unknown_error": "An unexpected error occurred" + }, + "step": { + "panels": { + "data": { + "panel_id": "Panel ID", + "panel_name": "Panel Name", + "panel_pin": "PIN Code" + }, + "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", + "title": "Panel selection" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Please login to the Elmax cloud using your credentials", + "title": "Account Login" + } + } + }, + "title": "Elmax Cloud Setup" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/et.json b/homeassistant/components/elmax/translations/et.json new file mode 100644 index 0000000000000..333cdc8d11f51 --- /dev/null +++ b/homeassistant/components/elmax/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "bad_auth": "Vigane autentimine", + "invalid_pin": "Sisestatud pin on kehtetu", + "network_error": "Ilmnes v\u00f5rgut\u00f5rge", + "no_panel_online": "V\u00f5rgus olevat Elmaxi juhtpaneeli ei leitud.", + "unknown_error": "Ilmnes ootamatu t\u00f5rge" + }, + "step": { + "panels": { + "data": { + "panel_id": "Paneeli ID", + "panel_name": "Paneeli nimi", + "panel_pin": "PIN kood" + }, + "description": "Vali millist paneeli soovid selle sidumisega juhtida. Pane t\u00e4hele, et paneel peab seadistamiseks olema SEES.", + "title": "Paneeli valik" + }, + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Logi oma mandaate kasutades Elmaxi pilve sisse", + "title": "Kontole sisselogimine" + } + } + }, + "title": "Elmaxi pilve seadistamine" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/fr.json b/homeassistant/components/elmax/translations/fr.json new file mode 100644 index 0000000000000..3c19c6975dd36 --- /dev/null +++ b/homeassistant/components/elmax/translations/fr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "bad_auth": "Authentification invalide", + "invalid_pin": "Le code PIN fourni n\u2019est pas valide", + "network_error": "Une erreur r\u00e9seau s'est produite", + "no_panel_online": "Aucun panneau de contr\u00f4le Elmax en ligne n'a \u00e9t\u00e9 trouv\u00e9.", + "unknown_error": "une erreur inattendue est apparue" + }, + "step": { + "panels": { + "data": { + "panel_id": "Identifiant du panneau", + "panel_name": "Nom du panneau", + "panel_pin": "Code PIN" + }, + "description": "S\u00e9lectionnez le panneau que vous souhaitez contr\u00f4ler avec cette int\u00e9gration. Veuillez noter que le panneau doit \u00eatre allum\u00e9 pour \u00eatre configur\u00e9.", + "title": "S\u00e9lection du panneau" + }, + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Veuillez vous connecter au cloud Elmax en utilisant vos informations d'identification", + "title": "Connexion au compte" + } + } + }, + "title": "Configuration d\u2019Elmax Cloud" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/he.json b/homeassistant/components/elmax/translations/he.json new file mode 100644 index 0000000000000..e428d0009ae49 --- /dev/null +++ b/homeassistant/components/elmax/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/hu.json b/homeassistant/components/elmax/translations/hu.json new file mode 100644 index 0000000000000..620396b0de043 --- /dev/null +++ b/homeassistant/components/elmax/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "bad_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "invalid_pin": "A megadott PIN-k\u00f3d \u00e9rv\u00e9nytelen", + "network_error": "H\u00e1l\u00f3zati hiba t\u00f6rt\u00e9nt", + "no_panel_online": "Nem tal\u00e1lhat\u00f3 online Elmax vez\u00e9rl\u0151panel.", + "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "panels": { + "data": { + "panel_id": "Panel ID", + "panel_name": "Panel neve", + "panel_pin": "PIN-k\u00f3d" + }, + "description": "V\u00e1lassza ki, hogy melyik panelt szeretn\u00e9 vez\u00e9relni ezzel az integr\u00e1ci\u00f3val. A panelnek bekapcsolt \u00e1llapotban kell lennie a konfigur\u00e1l\u00e1shoz.", + "title": "Panel kiv\u00e1laszt\u00e1sa" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rem, jelentkezzen be az Elmax felh\u0151be a hiteles\u00edt\u0151 adataival", + "title": "Fi\u00f3k bejelentkez\u00e9s" + } + } + }, + "title": "Elmax Cloud be\u00e1ll\u00edt\u00e1sa" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/id.json b/homeassistant/components/elmax/translations/id.json new file mode 100644 index 0000000000000..e4670a00e1209 --- /dev/null +++ b/homeassistant/components/elmax/translations/id.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "bad_auth": "Autentikasi tidak valid", + "invalid_pin": "PIN yang diberikan tidak valid", + "network_error": "Terjadi kesalahan jaringan", + "no_panel_online": "Tidak ada panel kontrol Elmax online yang ditemukan.", + "unknown_error": "Terjadi kesalahan tak terduga" + }, + "step": { + "panels": { + "data": { + "panel_id": "ID Panel", + "panel_name": "Nama Panel", + "panel_pin": "Kode PIN" + }, + "description": "Pilih panel mana yang ingin dikontrol dengan integrasi ini. Perhatikan bahwa panel harus AKTIF agar dapat dikonfigurasi.", + "title": "Pemilihan panel" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masuk ke cloud Elmax menggunakan kredensial Anda", + "title": "Akun Masuk" + } + } + }, + "title": "Penyiapan Elmax Cloud" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/it.json b/homeassistant/components/elmax/translations/it.json new file mode 100644 index 0000000000000..ef9d152e5432f --- /dev/null +++ b/homeassistant/components/elmax/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "bad_auth": "Autenticazione non valida", + "invalid_pin": "Il pin fornito non \u00e8 valido", + "network_error": "Si \u00e8 verificato un errore di rete", + "no_panel_online": "Non \u00e8 stato trovato alcun pannello di controllo Elmax in linea.", + "unknown_error": "Si \u00e8 verificato un errore imprevisto" + }, + "step": { + "panels": { + "data": { + "panel_id": "ID pannello", + "panel_name": "Nome del pannello", + "panel_pin": "Codice PIN" + }, + "description": "Seleziona quale pannello vuoi controllare con questa integrazione. Nota che il pannello deve essere acceso per essere configurato.", + "title": "Selezione del pannello" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Esegui l'accesso al cloud di Elmax utilizzando le tue credenziali", + "title": "Accesso all'account" + } + } + }, + "title": "Configurazione di Elmax Cloud" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/ja.json b/homeassistant/components/elmax/translations/ja.json new file mode 100644 index 0000000000000..639f734625408 --- /dev/null +++ b/homeassistant/components/elmax/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "bad_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_pin": "\u63d0\u4f9b\u3055\u308c\u305f\u30d4\u30f3\u304c\u7121\u52b9\u3067\u3059", + "network_error": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f", + "no_panel_online": "\u30aa\u30f3\u30e9\u30a4\u30f3\u306eElmax\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "unknown_error": "\u4e88\u671f\u305b\u306c\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f" + }, + "step": { + "panels": { + "data": { + "panel_id": "\u30d1\u30cd\u30ebID", + "panel_name": "\u30d1\u30cd\u30eb\u540d", + "panel_pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u5236\u5fa1\u3059\u308b\u30d1\u30cd\u30eb\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u3059\u308b\u306b\u306f\u3001\u30d1\u30cd\u30eb\u304c\u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u308b\u5fc5\u8981\u304c\u3042\u308b\u3053\u3068\u306b\u6ce8\u610f\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30d1\u30cd\u30eb\u306e\u9078\u629e" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u3042\u306a\u305f\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u4f7f\u7528\u3057\u3066\u3001Elmax\u30af\u30e9\u30a6\u30c9\u306b\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30a2\u30ab\u30a6\u30f3\u30c8 \u30ed\u30b0\u30a4\u30f3" + } + } + }, + "title": "Elmax Cloud\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/lt.json b/homeassistant/components/elmax/translations/lt.json new file mode 100644 index 0000000000000..d59749831b162 --- /dev/null +++ b/homeassistant/components/elmax/translations/lt.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u012erenginis paruo\u0161tas naudojimui" + }, + "step": { + "panels": { + "data": { + "panel_pin": "PIN kodas" + } + }, + "user": { + "data": { + "password": "Slapta\u017eodis", + "username": "Prisijungimo vardas" + }, + "description": "Prisijunkite prie \"Elmax\" debesies naudodami savo prisijungimo duomenis", + "title": "Paskyros prisijungimas" + } + } + }, + "title": "Elmax Cloud nustatymai" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/nl.json b/homeassistant/components/elmax/translations/nl.json new file mode 100644 index 0000000000000..b0ce43d9351af --- /dev/null +++ b/homeassistant/components/elmax/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "bad_auth": "Ongeldige authenticatie", + "invalid_pin": "De opgegeven pincode is ongeldig", + "network_error": "Er is een netwerkfout opgetreden", + "no_panel_online": "Er is geen online Elmax-controlepaneel gevonden.", + "unknown_error": "Een onbekende fout is opgetreden." + }, + "step": { + "panels": { + "data": { + "panel_id": "Paneel-ID", + "panel_name": "Paneelnaam:", + "panel_pin": "PIN Code" + }, + "description": "Selecteer welk paneel je wilt bedienen met deze integratie. Houd er rekening mee dat het paneel AAN moet staan om te kunnen worden geconfigureerd.", + "title": "Paneelselectie" + }, + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Log in op de Elmax-cloud met uw inloggegevens", + "title": "Account Login" + } + } + }, + "title": "Elmax Cloud Setup" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/no.json b/homeassistant/components/elmax/translations/no.json new file mode 100644 index 0000000000000..c321551602363 --- /dev/null +++ b/homeassistant/components/elmax/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "bad_auth": "Ugyldig godkjenning", + "invalid_pin": "Den angitte PIN-koden er ugyldig", + "network_error": "Det oppstod en nettverksfeil", + "no_panel_online": "Ingen online Elmax kontrollpanel ble funnet.", + "unknown_error": "Uventet feil" + }, + "step": { + "panels": { + "data": { + "panel_id": "Panel-ID", + "panel_name": "Navn p\u00e5 panel", + "panel_pin": "PIN kode" + }, + "description": "Velg hvilket panel du vil kontrollere med denne integrasjonen. V\u00e6r oppmerksom p\u00e5 at panelet m\u00e5 v\u00e6re P\u00c5 for \u00e5 kunne konfigureres.", + "title": "Valg av panel" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Logg p\u00e5 Elmax-skyen ved \u00e5 bruke legitimasjonen din", + "title": "P\u00e5logging til konto" + } + } + }, + "title": "Elmax Cloud Setup" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/pl.json b/homeassistant/components/elmax/translations/pl.json new file mode 100644 index 0000000000000..8396e1e51a64c --- /dev/null +++ b/homeassistant/components/elmax/translations/pl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "bad_auth": "Niepoprawne uwierzytelnienie", + "invalid_pin": "Podany kod PIN jest nieprawid\u0142owy", + "network_error": "Wyst\u0105pi\u0142 b\u0142\u0105d sieci.", + "no_panel_online": "Nie znaleziono panelu sterowania online Elmax.", + "unknown_error": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "panels": { + "data": { + "panel_id": "Identyfikator panelu", + "panel_name": "Nazwa panelu", + "panel_pin": "Kod PIN" + }, + "description": "Wybierz panel, kt\u00f3rym chcesz sterowa\u0107 za pomoc\u0105 tej integracji. Nale\u017cy pami\u0119ta\u0107, \u017ce panel musi by\u0107 w\u0142\u0105czony, aby mo\u017cna by\u0142o go skonfigurowa\u0107.", + "title": "Wyb\u00f3r panelu" + }, + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Prosz\u0119 zalogowa\u0107 si\u0119 do chmury Elmax za pomoc\u0105 swoich danych uwierzytelniaj\u0105cych", + "title": "Logowanie do konta" + } + } + }, + "title": "Konfiguracja chmury Elmax" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/ru.json b/homeassistant/components/elmax/translations/ru.json new file mode 100644 index 0000000000000..70cd14bac9cf9 --- /dev/null +++ b/homeassistant/components/elmax/translations/ru.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "bad_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_pin": "\u041f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u043d\u044b\u0439 PIN-\u043a\u043e\u0434 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", + "network_error": "\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u0432 \u0441\u0435\u0442\u0438.", + "no_panel_online": "\u041f\u0430\u043d\u0435\u043b\u044c \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f Elmax \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430.", + "unknown_error": "\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "panels": { + "data": { + "panel_id": "ID \u043f\u0430\u043d\u0435\u043b\u0438", + "panel_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0430\u043d\u0435\u043b\u0438", + "panel_pin": "PIN-\u043a\u043e\u0434" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0430\u043d\u0435\u043b\u044c, \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438. \u041e\u0431\u0440\u0430\u0442\u0438\u0442\u0435 \u0432\u043d\u0438\u043c\u0430\u043d\u0438\u0435, \u0447\u0442\u043e \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0430\u043d\u0435\u043b\u044c \u0434\u043e\u043b\u0436\u043d\u0430 \u0431\u044b\u0442\u044c \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430.", + "title": "\u0412\u044b\u0431\u043e\u0440 \u043f\u0430\u043d\u0435\u043b\u0438" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 Elmax Cloud, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u0441\u0432\u043e\u0438 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "title": "\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f" + } + } + }, + "title": "Elmax Cloud" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/tr.json b/homeassistant/components/elmax/translations/tr.json new file mode 100644 index 0000000000000..a6f8a434c8c6f --- /dev/null +++ b/homeassistant/components/elmax/translations/tr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "bad_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_pin": "Sa\u011flanan pin ge\u00e7ersiz", + "network_error": "Bir a\u011f hatas\u0131 olu\u015ftu", + "no_panel_online": "\u00c7evrimi\u00e7i Elmax kontrol paneli bulunamad\u0131.", + "unknown_error": "Beklenmeyen bir hata olu\u015ftu" + }, + "step": { + "panels": { + "data": { + "panel_id": "Panel Kimli\u011fi", + "panel_name": "Panel Ad\u0131", + "panel_pin": "PIN Kodu" + }, + "description": "Bu entegrasyon ile hangi paneli kontrol etmek istedi\u011finizi se\u00e7in. L\u00fctfen konfig\u00fcre edilebilmesi i\u00e7in panelin A\u00c7IK olmas\u0131 gerekti\u011fini unutmay\u0131n.", + "title": "Panel se\u00e7imi" + }, + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen kimlik bilgilerinizi kullanarak Elmax bulutuna giri\u015f yap\u0131n", + "title": "Hesap Giri\u015fi" + } + } + }, + "title": "Elmax Bulut Kurulumu" +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/zh-Hans.json b/homeassistant/components/elmax/translations/zh-Hans.json new file mode 100644 index 0000000000000..0d0c87c6a5bcc --- /dev/null +++ b/homeassistant/components/elmax/translations/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "panels": { + "data": { + "panel_pin": "PIN \u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "title": "\u8d26\u6237\u767b\u5f55" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/zh-Hant.json b/homeassistant/components/elmax/translations/zh-Hant.json new file mode 100644 index 0000000000000..4012a5b55ad47 --- /dev/null +++ b/homeassistant/components/elmax/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "bad_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_pin": "\u6240\u63d0\u4f9b\u7684 PIN \u7121\u6548\u3002", + "network_error": "\u767c\u751f\u7db2\u8def\u932f\u8aa4", + "no_panel_online": "\u627e\u4e0d\u5230 Elmax \u63a7\u5236\u9762\u677f\u3002", + "unknown_error": "\u767c\u751f\u672a\u77e5\u932f\u8aa4" + }, + "step": { + "panels": { + "data": { + "panel_id": "\u9762\u677f ID", + "panel_name": "\u9762\u677f\u540d\u7a31", + "panel_pin": "PIN \u78bc" + }, + "description": "\u9078\u64c7\u6574\u5408\u6240\u8981\u4f7f\u7528\u7684\u9762\u677f\u3002\u8acb\u6ce8\u610f\u3001\u9762\u677f\u5fc5\u9808\u70ba\u958b\u555f\u72c0\u614b\u65b9\u80fd\u9032\u884c\u8a2d\u5b9a\u3002", + "title": "\u9078\u64c7\u9762\u677f" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u4f7f\u7528\u6191\u8b49\u4ee5\u767b\u5165 Elmax \u96f2\u670d\u52d9", + "title": "\u767b\u5165\u5e33\u865f" + } + } + }, + "title": "Elmax \u96f2\u670d\u52d9\u8a2d\u5b9a" +} \ No newline at end of file diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 7c1295b0e5827..00c05702db706 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -2,7 +2,7 @@ "domain": "emby", "name": "Emby", "documentation": "https://www.home-assistant.io/integrations/emby", - "requirements": ["pyemby==1.7"], + "requirements": ["pyemby==1.8"], "codeowners": ["@mezz64"], "iot_class": "local_push" } diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 5656a1f14868e..c562cf400b6e1 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -86,7 +86,7 @@ def device_update_callback(data): """Handle devices which are added to Emby.""" new_devices = [] active_devices = [] - for dev_id in emby.devices: + for dev_id, dev in emby.devices.items(): active_devices.append(dev_id) if ( dev_id not in active_emby_devices @@ -96,9 +96,7 @@ def device_update_callback(data): active_emby_devices[dev_id] = new new_devices.append(new) - elif ( - dev_id in inactive_emby_devices and emby.devices[dev_id].state != "Off" - ): + elif dev_id in inactive_emby_devices and dev.state != "Off": add = inactive_emby_devices.pop(dev_id) active_emby_devices[dev_id] = add _LOGGER.debug("Showing %s, item: %s", dev_id, add) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index bfc86db387ef3..2db0f0373c988 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -1,11 +1,17 @@ """Support for monitoring emoncms feeds.""" from datetime import timedelta +from http import HTTPStatus import logging import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import ( CONF_API_KEY, CONF_ID, @@ -13,7 +19,6 @@ CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_VALUE_TEMPLATE, - HTTP_OK, POWER_WATT, STATE_UNKNOWN, ) @@ -102,8 +107,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if sensor_names is not None: name = sensor_names.get(int(elem["id"]), None) - unit = elem.get("unit") - if unit: + if unit := elem.get("unit"): unit_of_measurement = unit else: unit_of_measurement = config_unit @@ -149,6 +153,13 @@ def __init__( self._sensorid = sensorid self._elem = elem + if unit_of_measurement == "kWh": + self._attr_device_class = SensorDeviceClass.ENERGY + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + elif unit_of_measurement == "W": + self._attr_device_class = SensorDeviceClass.POWER + self._attr_state_class = SensorStateClass.MEASUREMENT + if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( elem["value"], STATE_UNKNOWN @@ -162,12 +173,12 @@ def name(self): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -243,7 +254,7 @@ def update(self): _LOGGER.error(exception) return else: - if req.status_code == HTTP_OK: + if req.status_code == HTTPStatus.OK: self.data = req.json() else: _LOGGER.error( diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 85b48c557554a..5cb639de67c72 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -1,5 +1,6 @@ """Support for sending data to Emoncms.""" from datetime import timedelta +from http import HTTPStatus import logging import requests @@ -10,7 +11,6 @@ CONF_SCAN_INTERVAL, CONF_URL, CONF_WHITELIST, - HTTP_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -59,7 +59,7 @@ def send_data(url, apikey, node, payload): _LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl) else: - if req.status_code != HTTP_OK: + if req.status_code != HTTPStatus.OK: _LOGGER.error( "Error saving data %s to %s (http status code = %d)", payload, diff --git a/homeassistant/components/emonitor/__init__.py b/homeassistant/components/emonitor/__init__.py index 516f38d64c236..3d03c7b8fe629 100644 --- a/homeassistant/components/emonitor/__init__.py +++ b/homeassistant/components/emonitor/__init__.py @@ -5,7 +5,7 @@ from aioemonitor import Emonitor from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,16 +16,16 @@ DEFAULT_UPDATE_RATE = 60 -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SiteSage Emonitor from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) emonitor = Emonitor(entry.data[CONF_HOST], session) - coordinator = DataUpdateCoordinator( + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( hass, _LOGGER, name=entry.title, @@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index cc63e70701319..a77289d469ed1 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -6,8 +6,9 @@ import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac @@ -62,12 +63,12 @@ async def async_step_user(self, user_input=None): errors=errors, ) - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self.discovered_ip = discovery_info[IP_ADDRESS] - await self.async_set_unique_id(format_mac(discovery_info[MAC_ADDRESS])) + self.discovered_ip = discovery_info.ip + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) self._abort_if_unique_id_configured(updates={CONF_HOST: self.discovered_ip}) - name = name_short_mac(short_mac(discovery_info[MAC_ADDRESS])) + name = name_short_mac(short_mac(discovery_info.macaddress)) self.context["title_placeholders"] = {"name": name} try: self.discovered_info = await fetch_mac_and_title( diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index d06e77f74e730..a0b0e3c1fd96a 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -2,7 +2,7 @@ from aioemonitor.monitor import EmonitorChannel -from homeassistant.components.sensor import DEVICE_CLASS_POWER, SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import POWER_WATT from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -37,7 +37,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): """Representation of an Emonitor power sensor entity.""" - def __init__(self, coordinator: DataUpdateCoordinator, channel_number: int): + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = POWER_WATT + + def __init__(self, coordinator: DataUpdateCoordinator, channel_number: int) -> None: """Initialize the channel sensor.""" self.channel_number = channel_number super().__init__(coordinator) @@ -62,16 +65,6 @@ def name(self) -> str: """Name of the sensor.""" return self.channel_data.label - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return POWER_WATT - - @property - def device_class(self) -> str: - """Device class of the sensor.""" - return DEVICE_CLASS_POWER - def _paired_attr(self, attr_name: str) -> float: """Cumulative attributes for channel and paired channel.""" attr_val = getattr(self.channel_data, attr_name) @@ -80,7 +73,7 @@ def _paired_attr(self, attr_name: str) -> float: return attr_val @property - def state(self) -> StateType: + def native_value(self) -> StateType: """State of the sensor.""" return self._paired_attr("inst_power") @@ -101,9 +94,9 @@ def mac_address(self) -> str: @property def device_info(self) -> DeviceInfo: """Return info about the emonitor device.""" - return { - "name": name_short_mac(self.mac_address[-6:]), - "connections": {(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, - "manufacturer": "Powerhouse Dynamics, Inc.", - "sw_version": self.coordinator.data.hardware.firmware_version, - } + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, + manufacturer="Powerhouse Dynamics, Inc.", + name=name_short_mac(self.mac_address[-6:]), + sw_version=self.coordinator.data.hardware.firmware_version, + ) diff --git a/homeassistant/components/emonitor/translations/ca.json b/homeassistant/components/emonitor/translations/ca.json index b6fd1f99c849d..4ab24587c158a 100644 --- a/homeassistant/components/emonitor/translations/ca.json +++ b/homeassistant/components/emonitor/translations/ca.json @@ -7,7 +7,7 @@ "cannot_connect": "Ha fallat la connexi\u00f3", "unknown": "Error inesperat" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Vols configurar {name} ({host})?", diff --git a/homeassistant/components/emonitor/translations/de.json b/homeassistant/components/emonitor/translations/de.json index 6abbe1b2b276a..b1b10756c31b8 100644 --- a/homeassistant/components/emonitor/translations/de.json +++ b/homeassistant/components/emonitor/translations/de.json @@ -7,7 +7,12 @@ "cannot_connect": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler" }, + "flow_title": "{name}", "step": { + "confirm": { + "description": "M\u00f6chtest du {name} ({host}) einrichten?", + "title": "Einrichtung SiteSage Emonitor" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/emonitor/translations/et.json b/homeassistant/components/emonitor/translations/et.json index bea6607a9cad2..d1051aed9e8db 100644 --- a/homeassistant/components/emonitor/translations/et.json +++ b/homeassistant/components/emonitor/translations/et.json @@ -7,7 +7,7 @@ "cannot_connect": "\u00dchendamine nurjus", "unknown": "Tundmatu viga" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Kas soovid seadistada {name}({host})?", diff --git a/homeassistant/components/emonitor/translations/fr.json b/homeassistant/components/emonitor/translations/fr.json index fcfee3bc71083..aaacc8bf14053 100644 --- a/homeassistant/components/emonitor/translations/fr.json +++ b/homeassistant/components/emonitor/translations/fr.json @@ -7,7 +7,7 @@ "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Voulez-vous configurer {name} ( {host} )?", @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Hote" + "host": "H\u00f4te" } } } diff --git a/homeassistant/components/emonitor/translations/he.json b/homeassistant/components/emonitor/translations/he.json new file mode 100644 index 0000000000000..77bd85b18b8e4 --- /dev/null +++ b/homeassistant/components/emonitor/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/hu.json b/homeassistant/components/emonitor/translations/hu.json index 2d7d4218e7de8..1a4fbb292e038 100644 --- a/homeassistant/components/emonitor/translations/hu.json +++ b/homeassistant/components/emonitor/translations/hu.json @@ -7,15 +7,15 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "A SiteSage Emonitor be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/emonitor/translations/id.json b/homeassistant/components/emonitor/translations/id.json index 1365fed7d52dd..c967ad91d05c1 100644 --- a/homeassistant/components/emonitor/translations/id.json +++ b/homeassistant/components/emonitor/translations/id.json @@ -7,7 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/emonitor/translations/it.json b/homeassistant/components/emonitor/translations/it.json index 7a194a301a54b..36115111583c9 100644 --- a/homeassistant/components/emonitor/translations/it.json +++ b/homeassistant/components/emonitor/translations/it.json @@ -7,7 +7,7 @@ "cannot_connect": "Impossibile connettersi", "unknown": "Errore imprevisto" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Vuoi impostare {name} ({host})?", diff --git a/homeassistant/components/emonitor/translations/ja.json b/homeassistant/components/emonitor/translations/ja.json new file mode 100644 index 0000000000000..feeb93f5c8678 --- /dev/null +++ b/homeassistant/components/emonitor/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "{name} ({host})\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b?", + "title": "SiteSage Emonitor\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/nl.json b/homeassistant/components/emonitor/translations/nl.json index 3ca0e625cf665..802514fdadb79 100644 --- a/homeassistant/components/emonitor/translations/nl.json +++ b/homeassistant/components/emonitor/translations/nl.json @@ -7,7 +7,7 @@ "cannot_connect": "Kan geen verbinding maken", "unknown": "Onverwachte fout" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Wilt u {name} ({host}) instellen?", diff --git a/homeassistant/components/emonitor/translations/no.json b/homeassistant/components/emonitor/translations/no.json index 866602d854b30..5b559af8f1794 100644 --- a/homeassistant/components/emonitor/translations/no.json +++ b/homeassistant/components/emonitor/translations/no.json @@ -7,7 +7,7 @@ "cannot_connect": "Tilkobling mislyktes", "unknown": "Uventet feil" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Vil du konfigurere {name} ({host})?", diff --git a/homeassistant/components/emonitor/translations/pl.json b/homeassistant/components/emonitor/translations/pl.json index a5b250c3f4d77..c742b620ea903 100644 --- a/homeassistant/components/emonitor/translations/pl.json +++ b/homeassistant/components/emonitor/translations/pl.json @@ -7,7 +7,7 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Czy chcesz skonfigurowa\u0107 {name} ({host})?", diff --git a/homeassistant/components/emonitor/translations/ru.json b/homeassistant/components/emonitor/translations/ru.json index e9ae6b12e86ae..682b4cdde8a4a 100644 --- a/homeassistant/components/emonitor/translations/ru.json +++ b/homeassistant/components/emonitor/translations/ru.json @@ -7,7 +7,7 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} ({host})?", diff --git a/homeassistant/components/emonitor/translations/tr.json b/homeassistant/components/emonitor/translations/tr.json new file mode 100644 index 0000000000000..72d9a2fcc6be5 --- /dev/null +++ b/homeassistant/components/emonitor/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?", + "title": "SiteSage Emonitor Kurulumu" + }, + "user": { + "data": { + "host": "Sunucu" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/zh-Hant.json b/homeassistant/components/emonitor/translations/zh-Hant.json index 1a7dc36fc5af6..94fda8f8d584c 100644 --- a/homeassistant/components/emonitor/translations/zh-Hant.json +++ b/homeassistant/components/emonitor/translations/zh-Hant.json @@ -7,7 +7,7 @@ "cannot_connect": "\u9023\u7dda\u5931\u6557", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} ({host})\uff1f", diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 3864a2651f8d4..4dec35a80e868 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -1,20 +1,18 @@ """Support for local control of entities by emulating a Philips Hue bridge.""" -from contextlib import suppress import logging from aiohttp import web import voluptuous as vol -from homeassistant import util +from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( CONF_ENTITIES, CONF_TYPE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv -from homeassistant.util.json import load_json, save_json from .hue_api import ( HueAllGroupsStateView, @@ -34,6 +32,9 @@ _LOGGER = logging.getLogger(__name__) NUMBERS_FILE = "emulated_hue_ids.json" +DATA_KEY = "emulated_hue.ids" +DATA_VERSION = "1" +SAVE_DELAY = 60 CONF_ADVERTISE_IP = "advertise_ip" CONF_ADVERTISE_PORT = "advertise_port" @@ -104,7 +105,9 @@ async def async_setup(hass, yaml_config): """Activate the emulated_hue component.""" - config = Config(hass, yaml_config.get(DOMAIN, {})) + local_ip = await async_get_source_ip(hass) + config = Config(hass, yaml_config.get(DOMAIN, {}), local_ip) + await config.async_setup() app = web.Application() app["hass"] = hass @@ -184,11 +187,12 @@ async def start_emulated_hue_bridge(event): class Config: """Hold configuration variables for the emulated hue bridge.""" - def __init__(self, hass, conf): + def __init__(self, hass, conf, local_ip): """Initialize the instance.""" self.hass = hass self.type = conf.get(CONF_TYPE) self.numbers = None + self.store = None self.cached_states = {} self._exposed_cache = {} @@ -201,11 +205,7 @@ def __init__(self, hass, conf): # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) if self.host_ip_addr is None: - self.host_ip_addr = util.get_local_ip() - _LOGGER.info( - "Listen IP address not specified, auto-detected address is %s", - self.host_ip_addr, - ) + self.host_ip_addr = local_ip # Get the port that the Hue bridge will listen on self.listen_port = conf.get(CONF_LISTEN_PORT) @@ -257,14 +257,21 @@ def __init__(self, hass, conf): # for compatibility with older installations. self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE) + async def async_setup(self): + """Set up and migrate to storage.""" + self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) + self.numbers = ( + await storage.async_migrator( + self.hass, self.hass.config.path(NUMBERS_FILE), self.store + ) + or {} + ) + def entity_id_to_number(self, entity_id): """Get a unique number for the entity id.""" if self.type == TYPE_ALEXA: return entity_id - if self.numbers is None: - self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE)) - # Google Home for number, ent_id in self.numbers.items(): if entity_id == ent_id: @@ -274,7 +281,7 @@ def entity_id_to_number(self, entity_id): if self.numbers: number = str(max(int(k) for k in self.numbers) + 1) self.numbers[number] = entity_id - save_json(self.hass.config.path(NUMBERS_FILE), self.numbers) + self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY) return number def number_to_entity_id(self, number): @@ -282,9 +289,6 @@ def number_to_entity_id(self, number): if self.type == TYPE_ALEXA: return number - if self.numbers is None: - self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE)) - # Google Home assert isinstance(number, str) return self.numbers.get(number) @@ -338,10 +342,3 @@ def _is_entity_exposed(self, entity): return True return False - - -def _load_json(filename): - """Load JSON, handling invalid syntax.""" - with suppress(HomeAssistantError): - return load_json(filename) - return {} diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index bbd899b559b10..3b5d1e7831ee5 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,6 +1,7 @@ """Support for a Hue API to control Home Assistant.""" import asyncio import hashlib +from http import HTTPStatus from ipaddress import ip_address import logging import time @@ -55,9 +56,6 @@ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_TURN_OFF, @@ -136,15 +134,15 @@ class HueUsernameView(HomeAssistantView): async def post(self, request): """Handle a POST request.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) if "devicetype" not in data: - return self.json_message("devicetype not specified", HTTP_BAD_REQUEST) + return self.json_message("devicetype not specified", HTTPStatus.BAD_REQUEST) return self.json([{"success": {"username": HUE_API_USERNAME}}]) @@ -164,7 +162,7 @@ def __init__(self, config): def get(self, request, username): """Process a request to make the Brilliant Lightpad work.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json({}) @@ -184,7 +182,7 @@ def __init__(self, config): def put(self, request, username): """Process a request to make the Logitech Pop working.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json( [ @@ -214,7 +212,7 @@ def __init__(self, config): def get(self, request, username): """Process a request to get the list of available lights.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json(create_list_of_entities(self.config, request)) @@ -234,7 +232,7 @@ def __init__(self, config): def get(self, request, username): """Process a request to get the list of available lights.""" if not is_local(ip_address(request.remote)): - return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: return self.json(UNAUTHORIZED_USER) @@ -262,7 +260,7 @@ def __init__(self, config): def get(self, request, username=""): """Process a request to get the configuration.""" if not is_local(ip_address(request.remote)): - return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) json_response = create_config_model(self.config, request) @@ -284,7 +282,7 @@ def __init__(self, config): def get(self, request, username, entity_id): """Process a request to get the state of an individual light.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) hass = request.app["hass"] hass_entity_id = self.config.number_to_entity_id(entity_id) @@ -294,17 +292,15 @@ def get(self, request, username, entity_id): "Unknown entity number: %s not found in emulated_hue_ids.json", entity_id, ) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - entity = hass.states.get(hass_entity_id) - - if entity is None: + if (entity := hass.states.get(hass_entity_id)) is None: _LOGGER.error("Entity not found: %s", hass_entity_id) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) if not self.config.is_entity_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) - return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) json_response = entity_to_json(self.config, entity) @@ -325,7 +321,7 @@ def __init__(self, config): async def put(self, request, username, entity_number): # noqa: C901 """Process a request to set the state of an individual light.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config hass = request.app["hass"] @@ -333,23 +329,21 @@ async def put(self, request, username, entity_number): # noqa: C901 if entity_id is None: _LOGGER.error("Unknown entity number: %s", entity_number) - return self.json_message("Entity not found", HTTP_NOT_FOUND) - - entity = hass.states.get(entity_id) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if entity is None: + if (entity := hass.states.get(entity_id)) is None: _LOGGER.error("Entity not found: %s", entity_id) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) if not config.is_entity_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) - return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) try: request_json = await request.json() except ValueError: _LOGGER.error("Received invalid json") - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) # Get the entity's supported features entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -370,7 +364,7 @@ async def put(self, request, username, entity_number): # noqa: C901 if HUE_API_STATE_ON in request_json: if not isinstance(request_json[HUE_API_STATE_ON], bool): _LOGGER.error("Unable to parse data: %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: parsed[STATE_ON] = entity.state != STATE_OFF @@ -387,7 +381,7 @@ async def put(self, request, username, entity_number): # noqa: C901 parsed[attr] = int(request_json[key]) except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) if HUE_API_STATE_XY in request_json: try: parsed[STATE_XY] = ( @@ -396,7 +390,7 @@ async def put(self, request, username, entity_number): # noqa: C901 ) except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) if HUE_API_STATE_BRI in request_json: if entity.domain == light.DOMAIN: @@ -546,8 +540,7 @@ async def put(self, request, username, entity_number): # noqa: C901 ): domain = entity.domain # Convert 0-100 to a fan speed - brightness = parsed[STATE_BRIGHTNESS] - if brightness == 0: + if (brightness := parsed[STATE_BRIGHTNESS]) == 0: data[ATTR_SPEED] = SPEED_OFF elif 0 < brightness <= 33.3: data[ATTR_SPEED] = SPEED_LOW diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 406451639f20c..e5a9072e51d61 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -3,6 +3,7 @@ "name": "Emulated Hue", "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "requirements": ["aiohttp_cors==0.7.0"], + "dependencies": ["network"], "after_dependencies": ["http"], "codeowners": [], "quality_scale": "internal", diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index b9dc79e25cc49..967edc8d157ca 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -18,6 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.template import Template, is_template_string +from homeassistant.helpers.typing import ConfigType from .const import CONF_POWER, CONF_POWER_ENTITY, DOMAIN @@ -48,10 +49,9 @@ ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated_kasa component.""" - conf = config.get(DOMAIN) - if not conf: + if not (conf := config.get(DOMAIN)): return True entity_configs = conf[CONF_ENTITIES] @@ -82,13 +82,11 @@ async def validate_configs(hass, entity_configs): """Validate that entities exist and ensure templates are ready to use.""" entity_registry = await hass.helpers.entity_registry.async_get_registry() for entity_id, entity_config in entity_configs.items(): - state = hass.states.get(entity_id) - if state is None: + if (state := hass.states.get(entity_id)) is None: _LOGGER.debug("Entity not found: %s", entity_id) continue - entity = entity_registry.async_get(entity_id) - if entity: + if entity := entity_registry.async_get(entity_id): entity_config[CONF_UNIQUE_ID] = get_system_unique_id(entity) else: entity_config[CONF_UNIQUE_ID] = entity_id @@ -121,8 +119,7 @@ def get_system_unique_id(entity: RegistryEntry): def get_plug_devices(hass, entity_configs): """Produce list of plug devices from config entities.""" for entity_id, entity_config in entity_configs.items(): - state = hass.states.get(entity_id) - if state is None: + if (state := hass.states.get(entity_id)) is None: continue name = entity_config.get(CONF_NAME, state.name) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 419a34db98c44..39a0a3da0546a 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -2,7 +2,7 @@ "domain": "emulated_kasa", "name": "Emulated Kasa", "documentation": "https://www.home-assistant.io/integrations/emulated_kasa", - "requirements": ["sense_energy==0.9.0"], + "requirements": ["sense_energy==0.9.3"], "codeowners": ["@kbickar"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 4e5779296440a..3d84bf20f444a 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -1,7 +1,8 @@ """Support for Roku API emulation.""" import voluptuous as vol -from homeassistant import config_entries, util +from homeassistant import config_entries +from homeassistant.components.network import async_get_source_ip from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv @@ -44,9 +45,7 @@ async def async_setup(hass, config): """Set up the emulated roku component.""" - conf = config.get(DOMAIN) - - if conf is None: + if (conf := config.get(DOMAIN)) is None: return True existing_servers = configured_servers(hass) @@ -71,7 +70,7 @@ async def async_setup_entry(hass, config_entry): name = config[CONF_NAME] listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or util.get_local_ip() + host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) advertise_ip = config.get(CONF_ADVERTISE_IP) advertise_port = config.get(CONF_ADVERTISE_PORT) upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py index f1c00a7f8b47c..dd7cd87c96a6a 100644 --- a/homeassistant/components/emulated_roku/config_flow.py +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -26,12 +26,8 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: - name = user_input[CONF_NAME] - - if name in configured_servers(self.hass): - return self.async_abort(reason="already_configured") - - return self.async_create_entry(title=name, data=user_input) + self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) servers_num = len(configured_servers(self.hass)) diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 6ef54d1d1ccfb..36a86137e8725 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emulated_roku", "requirements": ["emulated_roku==0.2.1"], + "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/emulated_roku/translations/bg.json b/homeassistant/components/emulated_roku/translations/bg.json index a1a0fd75c60ca..9bdff95e9b312 100644 --- a/homeassistant/components/emulated_roku/translations/bg.json +++ b/homeassistant/components/emulated_roku/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/emulated_roku/translations/cs.json b/homeassistant/components/emulated_roku/translations/cs.json index c84810814ed8f..0c44bb73ff0d4 100644 --- a/homeassistant/components/emulated_roku/translations/cs.json +++ b/homeassistant/components/emulated_roku/translations/cs.json @@ -6,9 +6,12 @@ "step": { "user": { "data": { + "advertise_port": "Port odesl\u00e1n\u00ed", "host_ip": "IP adresa hostitele", + "listen_port": "Port p\u0159\u00edjmu", "name": "Jm\u00e9no" - } + }, + "title": "Definice konfigurace serveru" } } }, diff --git a/homeassistant/components/emulated_roku/translations/he.json b/homeassistant/components/emulated_roku/translations/he.json new file mode 100644 index 0000000000000..92608aacfa2b1 --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "host_ip": "\u05db\u05ea\u05d5\u05d1\u05ea IP \u05de\u05d0\u05e8\u05d7\u05ea", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/translations/hu.json b/homeassistant/components/emulated_roku/translations/hu.json index bccfe3bdcab0d..53b66f6db192f 100644 --- a/homeassistant/components/emulated_roku/translations/hu.json +++ b/homeassistant/components/emulated_roku/translations/hu.json @@ -6,9 +6,12 @@ "step": { "user": { "data": { - "host_ip": "Hoszt IP c\u00edm", - "listen_port": "Port figyel\u00e9se", - "name": "N\u00e9v" + "advertise_ip": "IP c\u00edm k\u00f6zl\u00e9se", + "advertise_port": "Port k\u00f6zl\u00e9se", + "host_ip": "IP c\u00edm", + "listen_port": "Port", + "name": "N\u00e9v", + "upnp_bind_multicast": "K\u00f6t\u00f6tt multicast (igaz/hamis)" }, "title": "A kiszolg\u00e1l\u00f3 szerver konfigur\u00e1l\u00e1sa" } diff --git a/homeassistant/components/emulated_roku/translations/id.json b/homeassistant/components/emulated_roku/translations/id.json index 9ffcedf5d1979..30aa33240deae 100644 --- a/homeassistant/components/emulated_roku/translations/id.json +++ b/homeassistant/components/emulated_roku/translations/id.json @@ -9,6 +9,7 @@ "advertise_ip": "Umumkan Alamat IP", "advertise_port": "Umumkan Port", "host_ip": "Alamat IP Host", + "listen_port": "Port untuk Mendengarkan", "name": "Nama", "upnp_bind_multicast": "Bind multicast (True/False)" }, diff --git a/homeassistant/components/emulated_roku/translations/it.json b/homeassistant/components/emulated_roku/translations/it.json index 6d734e7b1bacf..69699b8f21006 100644 --- a/homeassistant/components/emulated_roku/translations/it.json +++ b/homeassistant/components/emulated_roku/translations/it.json @@ -17,5 +17,5 @@ } } }, - "title": "Emulated Roku" + "title": "Roku emulato" } \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/translations/ja.json b/homeassistant/components/emulated_roku/translations/ja.json new file mode 100644 index 0000000000000..302eeb8e6c7c5 --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP\u30a2\u30c9\u30ec\u30b9\u3092\u30a2\u30c9\u30d0\u30bf\u30a4\u30ba\u3059\u308b", + "advertise_port": "\u30a2\u30c9\u30d0\u30bf\u30a4\u30ba \u30dd\u30fc\u30c8", + "host_ip": "\u30db\u30b9\u30c8\u306eIP\u30a2\u30c9\u30ec\u30b9", + "listen_port": "\u30ea\u30c3\u30b9\u30f3 \u30dd\u30fc\u30c8", + "name": "\u540d\u524d", + "upnp_bind_multicast": "\u30d0\u30a4\u30f3\u30c9 \u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8 (True/False)" + }, + "title": "\u30b5\u30fc\u30d0\u30fc\u69cb\u6210\u306e\u5b9a\u7fa9" + } + } + }, + "title": "Emulated Roku" +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/translations/lt.json b/homeassistant/components/emulated_roku/translations/lt.json new file mode 100644 index 0000000000000..8ae517ecfbe25 --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "Hosto IP adresas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/translations/ru.json b/homeassistant/components/emulated_roku/translations/ru.json index f0094930f839a..47925bb4aec83 100644 --- a/homeassistant/components/emulated_roku/translations/ru.json +++ b/homeassistant/components/emulated_roku/translations/ru.json @@ -17,5 +17,5 @@ } } }, - "title": "\u042d\u043c\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 Roku" + "title": "Emulated Roku" } \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/translations/tr.json b/homeassistant/components/emulated_roku/translations/tr.json index 5307276a71d3a..271d43d876341 100644 --- a/homeassistant/components/emulated_roku/translations/tr.json +++ b/homeassistant/components/emulated_roku/translations/tr.json @@ -2,6 +2,20 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP Adresini Tan\u0131t", + "advertise_port": "Ba\u011flant\u0131 Noktas\u0131n\u0131 Tan\u0131t", + "host_ip": "Ana Bilgisayar IP Adresi", + "listen_port": "Ba\u011flant\u0131 Noktas\u0131n\u0131 Dinle", + "name": "Ad", + "upnp_bind_multicast": "\u00c7ok noktaya yay\u0131n\u0131 ba\u011fla (Do\u011fru/Yanl\u0131\u015f)" + }, + "title": "Sunucu yap\u0131land\u0131rmas\u0131n\u0131 tan\u0131mlay\u0131n" + } } - } + }, + "title": "Emulated Roku" } \ No newline at end of file diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py new file mode 100644 index 0000000000000..30a1bf8e877a6 --- /dev/null +++ b/homeassistant/components/energy/__init__.py @@ -0,0 +1,34 @@ +"""The Energy integration.""" +from __future__ import annotations + +from homeassistant.components import frontend +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from . import websocket_api +from .const import DOMAIN +from .data import async_get_manager + + +async def is_configured(hass: HomeAssistant) -> bool: + """Return a boolean to indicate if energy is configured.""" + manager = await async_get_manager(hass) + if manager.data is None: + return False + return bool(manager.data != manager.default_preferences()) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Energy.""" + websocket_api.async_setup(hass) + frontend.async_register_built_in_panel(hass, DOMAIN, DOMAIN, "mdi:lightning-bolt") + + hass.async_create_task( + discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config) + ) + hass.data[DOMAIN] = { + "cost_sensors": {}, + } + + return True diff --git a/homeassistant/components/energy/const.py b/homeassistant/components/energy/const.py new file mode 100644 index 0000000000000..26093a934337b --- /dev/null +++ b/homeassistant/components/energy/const.py @@ -0,0 +1,3 @@ +"""Constants for the Energy integration.""" + +DOMAIN = "energy" diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py new file mode 100644 index 0000000000000..f8c14ed8b73b2 --- /dev/null +++ b/homeassistant/components/energy/data.py @@ -0,0 +1,307 @@ +"""Energy data.""" +from __future__ import annotations + +import asyncio +from collections import Counter +from collections.abc import Awaitable, Callable +from typing import Literal, Optional, TypedDict, Union, cast + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, singleton, storage + +from .const import DOMAIN + +STORAGE_VERSION = 1 +STORAGE_KEY = DOMAIN + + +@singleton.singleton(f"{DOMAIN}_manager") +async def async_get_manager(hass: HomeAssistant) -> EnergyManager: + """Return an initialized data manager.""" + manager = EnergyManager(hass) + await manager.async_initialize() + return manager + + +class FlowFromGridSourceType(TypedDict): + """Dictionary describing the 'from' stat for the grid source.""" + + # statistic_id of a an energy meter (kWh) + stat_energy_from: str + + # statistic_id of costs ($) incurred from the energy meter + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_cost: str | None + + # Used to generate costs if stat_cost is set to None + entity_energy_from: str | None # entity_id of an energy meter (kWh), entity_id of the energy meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/kWh) + number_energy_price: float | None # Price for energy ($/kWh) + + +class FlowToGridSourceType(TypedDict): + """Dictionary describing the 'to' stat for the grid source.""" + + # kWh meter + stat_energy_to: str + + # statistic_id of compensation ($) received for contributing back + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_compensation: str | None + + # Used to generate costs if stat_compensation is set to None + entity_energy_from: str | None # entity_id of an energy meter (kWh), entity_id of the energy meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/kWh) + number_energy_price: float | None # Price for energy ($/kWh) + + +class GridSourceType(TypedDict): + """Dictionary holding the source of grid energy consumption.""" + + type: Literal["grid"] + + flow_from: list[FlowFromGridSourceType] + flow_to: list[FlowToGridSourceType] + + cost_adjustment_day: float + + +class SolarSourceType(TypedDict): + """Dictionary holding the source of energy production.""" + + type: Literal["solar"] + + stat_energy_from: str + config_entry_solar_forecast: list[str] | None + + +class BatterySourceType(TypedDict): + """Dictionary holding the source of battery storage.""" + + type: Literal["battery"] + + stat_energy_from: str + stat_energy_to: str + + +class GasSourceType(TypedDict): + """Dictionary holding the source of gas storage.""" + + type: Literal["gas"] + + stat_energy_from: str + + # statistic_id of costs ($) incurred from the energy meter + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_cost: str | None + + # Used to generate costs if stat_cost is set to None + entity_energy_from: str | None # entity_id of an gas meter (m³), entity_id of the gas meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/m³) + number_energy_price: float | None # Price for energy ($/m³) + + +SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType, GasSourceType] + + +class DeviceConsumption(TypedDict): + """Dictionary holding the source of individual device consumption.""" + + # This is an ever increasing value + stat_consumption: str + + +class EnergyPreferences(TypedDict): + """Dictionary holding the energy data.""" + + energy_sources: list[SourceType] + device_consumption: list[DeviceConsumption] + + +class EnergyPreferencesUpdate(EnergyPreferences, total=False): + """all types optional.""" + + +def _flow_from_ensure_single_price( + val: FlowFromGridSourceType, +) -> FlowFromGridSourceType: + """Ensure we use a single price source.""" + if ( + val["entity_energy_price"] is not None + and val["number_energy_price"] is not None + ): + raise vol.Invalid("Define either an entity or a fixed number for the price") + + return val + + +FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("stat_energy_from"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_from"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } + ), + _flow_from_ensure_single_price, +) + + +FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("stat_energy_to"): str, + vol.Optional("stat_compensation"): vol.Any(str, None), + vol.Optional("entity_energy_to"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } +) + + +def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]: + """Generate a validator that ensures a value is only used once.""" + + def validate_uniqueness( + val: list[dict], + ) -> list[dict]: + """Ensure that the user doesn't add duplicate values.""" + counts = Counter(flow_from[key] for flow_from in val) + + for value, count in counts.items(): + if count > 1: + raise vol.Invalid(f"Cannot specify {value} more than once") + + return val + + return validate_uniqueness + + +GRID_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "grid", + vol.Required("flow_from"): vol.All( + [FLOW_FROM_GRID_SOURCE_SCHEMA], + _generate_unique_value_validator("stat_energy_from"), + ), + vol.Required("flow_to"): vol.All( + [FLOW_TO_GRID_SOURCE_SCHEMA], + _generate_unique_value_validator("stat_energy_to"), + ), + vol.Required("cost_adjustment_day"): vol.Coerce(float), + } +) +SOLAR_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "solar", + vol.Required("stat_energy_from"): str, + vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), + } +) +BATTERY_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "battery", + vol.Required("stat_energy_from"): str, + vol.Required("stat_energy_to"): str, + } +) +GAS_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "gas", + vol.Required("stat_energy_from"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_from"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } +) + + +def check_type_limits(value: list[SourceType]) -> list[SourceType]: + """Validate that we don't have too many of certain types.""" + types = Counter([val["type"] for val in value]) + + if types.get("grid", 0) > 1: + raise vol.Invalid("You cannot have more than 1 grid source") + + return value + + +ENERGY_SOURCE_SCHEMA = vol.All( + vol.Schema( + [ + cv.key_value_schemas( + "type", + { + "grid": GRID_SOURCE_SCHEMA, + "solar": SOLAR_SOURCE_SCHEMA, + "battery": BATTERY_SOURCE_SCHEMA, + "gas": GAS_SOURCE_SCHEMA, + }, + ) + ] + ), + check_type_limits, +) + +DEVICE_CONSUMPTION_SCHEMA = vol.Schema( + { + vol.Required("stat_consumption"): str, + } +) + + +class EnergyManager: + """Manage the instance energy prefs.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize energy manager.""" + self._hass = hass + self._store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + self.data: EnergyPreferences | None = None + self._update_listeners: list[Callable[[], Awaitable]] = [] + + async def async_initialize(self) -> None: + """Initialize the energy integration.""" + self.data = cast(Optional[EnergyPreferences], await self._store.async_load()) + + @staticmethod + def default_preferences() -> EnergyPreferences: + """Return default preferences.""" + return { + "energy_sources": [], + "device_consumption": [], + } + + async def async_update(self, update: EnergyPreferencesUpdate) -> None: + """Update the preferences.""" + if self.data is None: + data = EnergyManager.default_preferences() + else: + data = self.data.copy() + + for key in ( + "energy_sources", + "device_consumption", + ): + if key in update: + data[key] = update[key] # type: ignore + + self.data = data + self._store.async_delay_save(lambda: cast(dict, self.data), 60) + + if not self._update_listeners: + return + + await asyncio.gather(*(listener() for listener in self._update_listeners)) + + @callback + def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None: + """Listen for data updates.""" + self._update_listeners.append(update_listener) diff --git a/homeassistant/components/energy/manifest.json b/homeassistant/components/energy/manifest.json new file mode 100644 index 0000000000000..5ddc6457a616d --- /dev/null +++ b/homeassistant/components/energy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "energy", + "name": "Energy", + "documentation": "https://www.home-assistant.io/integrations/energy", + "codeowners": ["@home-assistant/core"], + "iot_class": "calculated", + "dependencies": ["websocket_api", "history", "recorder"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py new file mode 100644 index 0000000000000..636c6dc32ae84 --- /dev/null +++ b/homeassistant/components/energy/sensor.py @@ -0,0 +1,421 @@ +"""Helper sensor for calculating utility costs.""" +from __future__ import annotations + +import asyncio +import copy +from dataclasses import dataclass +import logging +from typing import Any, Final, Literal, TypeVar, cast + +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.components.sensor.recorder import reset_detected +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import ( + HomeAssistant, + State, + callback, + split_entity_id, + valid_entity_id, +) +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .data import EnergyManager, async_get_manager + +SUPPORTED_STATE_CLASSES = [ + SensorStateClass.MEASUREMENT, + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, +] +VALID_ENERGY_UNITS = [ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR, ENERGY_MEGA_WATT_HOUR] +VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the energy sensors.""" + sensor_manager = SensorManager(await async_get_manager(hass), async_add_entities) + await sensor_manager.async_start() + + +T = TypeVar("T") + + +@dataclass +class SourceAdapter: + """Adapter to allow sources and their flows to be used as sensors.""" + + source_type: Literal["grid", "gas"] + flow_type: Literal["flow_from", "flow_to", None] + stat_energy_key: Literal["stat_energy_from", "stat_energy_to"] + entity_energy_key: Literal["entity_energy_from", "entity_energy_to"] + total_money_key: Literal["stat_cost", "stat_compensation"] + name_suffix: str + entity_id_suffix: str + + +SOURCE_ADAPTERS: Final = ( + SourceAdapter( + "grid", + "flow_from", + "stat_energy_from", + "entity_energy_from", + "stat_cost", + "Cost", + "cost", + ), + SourceAdapter( + "grid", + "flow_to", + "stat_energy_to", + "entity_energy_to", + "stat_compensation", + "Compensation", + "compensation", + ), + SourceAdapter( + "gas", + None, + "stat_energy_from", + "entity_energy_from", + "stat_cost", + "Cost", + "cost", + ), +) + + +class SensorManager: + """Class to handle creation/removal of sensor data.""" + + def __init__( + self, manager: EnergyManager, async_add_entities: AddEntitiesCallback + ) -> None: + """Initialize sensor manager.""" + self.manager = manager + self.async_add_entities = async_add_entities + self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {} + + async def async_start(self) -> None: + """Start.""" + self.manager.async_listen_updates(self._process_manager_data) + + if self.manager.data: + await self._process_manager_data() + + async def _process_manager_data(self) -> None: + """Process manager data.""" + to_add: list[EnergyCostSensor] = [] + to_remove = dict(self.current_entities) + + async def finish() -> None: + if to_add: + self.async_add_entities(to_add) + await asyncio.gather(*(ent.add_finished.wait() for ent in to_add)) + + for key, entity in to_remove.items(): + self.current_entities.pop(key) + await entity.async_remove() + + if not self.manager.data: + await finish() + return + + for energy_source in self.manager.data["energy_sources"]: + for adapter in SOURCE_ADAPTERS: + if adapter.source_type != energy_source["type"]: + continue + + if adapter.flow_type is None: + self._process_sensor_data( + adapter, + # Opting out of the type complexity because can't get it to work + energy_source, # type: ignore + to_add, + to_remove, + ) + continue + + for flow in energy_source[adapter.flow_type]: # type: ignore + self._process_sensor_data( + adapter, + # Opting out of the type complexity because can't get it to work + flow, # type: ignore + to_add, + to_remove, + ) + + await finish() + + @callback + def _process_sensor_data( + self, + adapter: SourceAdapter, + config: dict, + to_add: list[EnergyCostSensor], + to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], + ) -> None: + """Process sensor data.""" + # No need to create an entity if we already have a cost stat + if config.get(adapter.total_money_key) is not None: + return + + key = (adapter.source_type, adapter.flow_type, config[adapter.stat_energy_key]) + + # Make sure the right data is there + # If the entity existed, we don't pop it from to_remove so it's removed + if ( + config.get(adapter.entity_energy_key) is None + or not valid_entity_id(config[adapter.entity_energy_key]) + or ( + config.get("entity_energy_price") is None + and config.get("number_energy_price") is None + ) + ): + return + + if current_entity := to_remove.pop(key, None): + current_entity.update_config(config) + return + + self.current_entities[key] = EnergyCostSensor( + adapter, + config, + ) + to_add.append(self.current_entities[key]) + + +class EnergyCostSensor(SensorEntity): + """Calculate costs incurred by consuming energy. + + This is intended as a fallback for when no specific cost sensor is available for the + utility. + """ + + _attr_entity_category = EntityCategory.SYSTEM + _wrong_state_class_reported = False + _wrong_unit_reported = False + + def __init__( + self, + adapter: SourceAdapter, + config: dict, + ) -> None: + """Initialize the sensor.""" + super().__init__() + + self._adapter = adapter + self.entity_id = ( + f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" + ) + self._attr_device_class = SensorDeviceClass.MONETARY + self._attr_state_class = SensorStateClass.TOTAL + self._config = config + self._last_energy_sensor_state: State | None = None + # add_finished is set when either of async_added_to_hass or add_to_platform_abort + # is called + self.add_finished = asyncio.Event() + + def _reset(self, energy_state: State) -> None: + """Reset the cost sensor.""" + self._attr_native_value = 0.0 + self._attr_last_reset = dt_util.utcnow() + self._last_energy_sensor_state = energy_state + self.async_write_ha_state() + + @callback + def _update_cost(self) -> None: + """Update incurred costs.""" + energy_state = self.hass.states.get( + cast(str, self._config[self._adapter.entity_energy_key]) + ) + + if energy_state is None: + return + + state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + if state_class not in SUPPORTED_STATE_CLASSES: + if not self._wrong_state_class_reported: + self._wrong_state_class_reported = True + _LOGGER.warning( + "Found unexpected state_class %s for %s", + state_class, + energy_state.entity_id, + ) + return + + # last_reset must be set if the sensor is SensorStateClass.MEASUREMENT + if ( + state_class == SensorStateClass.MEASUREMENT + and ATTR_LAST_RESET not in energy_state.attributes + ): + return + + try: + energy = float(energy_state.state) + except ValueError: + return + + # Determine energy price + if self._config["entity_energy_price"] is not None: + energy_price_state = self.hass.states.get( + self._config["entity_energy_price"] + ) + + if energy_price_state is None: + return + + try: + energy_price = float(energy_price_state.state) + except ValueError: + return + + if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( + f"/{ENERGY_WATT_HOUR}" + ): + energy_price *= 1000.0 + + if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( + f"/{ENERGY_MEGA_WATT_HOUR}" + ): + energy_price /= 1000.0 + + else: + energy_price_state = None + energy_price = cast(float, self._config["number_energy_price"]) + + if self._last_energy_sensor_state is None: + # Initialize as it's the first time all required entities are in place. + self._reset(energy_state) + return + + energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if self._adapter.source_type == "grid": + if energy_unit not in VALID_ENERGY_UNITS: + energy_unit = None + + elif self._adapter.source_type == "gas": + if energy_unit not in VALID_ENERGY_UNITS_GAS: + energy_unit = None + + if energy_unit == ENERGY_WATT_HOUR: + energy_price /= 1000 + elif energy_unit == ENERGY_MEGA_WATT_HOUR: + energy_price *= 1000 + + if energy_unit is None: + if not self._wrong_unit_reported: + self._wrong_unit_reported = True + _LOGGER.warning( + "Found unexpected unit %s for %s", + energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + energy_state.entity_id, + ) + return + + if ( + state_class != SensorStateClass.TOTAL_INCREASING + and energy_state.attributes.get(ATTR_LAST_RESET) + != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + ): + # Energy meter was reset, reset cost sensor too + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) + elif state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( + self.hass, + cast(str, self._config[self._adapter.entity_energy_key]), + energy, + float(self._last_energy_sensor_state.state), + self._last_energy_sensor_state, + ): + # Energy meter was reset, reset cost sensor too + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) + # Update with newly incurred cost + old_energy_value = float(self._last_energy_sensor_state.state) + cur_value = cast(float, self._attr_native_value) + self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price + + self._last_energy_sensor_state = energy_state + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + energy_state = self.hass.states.get( + self._config[self._adapter.entity_energy_key] + ) + if energy_state: + name = energy_state.name + else: + name = split_entity_id(self._config[self._adapter.entity_energy_key])[ + 0 + ].replace("_", " ") + + self._attr_name = f"{name} {self._adapter.name_suffix}" + + self._update_cost() + + # Store stat ID in hass.data so frontend can look it up + self.hass.data[DOMAIN]["cost_sensors"][ + self._config[self._adapter.entity_energy_key] + ] = self.entity_id + + @callback + def async_state_changed_listener(*_: Any) -> None: + """Handle child updates.""" + self._update_cost() + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + cast(str, self._config[self._adapter.entity_energy_key]), + async_state_changed_listener, + ) + ) + self.add_finished.set() + + @callback + def add_to_platform_abort(self) -> None: + """Abort adding an entity to a platform.""" + self.add_finished.set() + + async def async_will_remove_from_hass(self) -> None: + """Handle removing from hass.""" + self.hass.data[DOMAIN]["cost_sensors"].pop( + self._config[self._adapter.entity_energy_key] + ) + await super().async_will_remove_from_hass() + + @callback + def update_config(self, config: dict) -> None: + """Update the config.""" + self._config = config + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the units of measurement.""" + return self.hass.config.currency diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json new file mode 100644 index 0000000000000..6cdcd827633fa --- /dev/null +++ b/homeassistant/components/energy/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Energy" +} diff --git a/homeassistant/components/energy/translations/bg.json b/homeassistant/components/energy/translations/bg.json new file mode 100644 index 0000000000000..cada66c2ac28f --- /dev/null +++ b/homeassistant/components/energy/translations/bg.json @@ -0,0 +1,3 @@ +{ + "title": "\u0415\u043d\u0435\u0440\u0433\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/ca.json b/homeassistant/components/energy/translations/ca.json new file mode 100644 index 0000000000000..c8d85790fdd53 --- /dev/null +++ b/homeassistant/components/energy/translations/ca.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/cs.json b/homeassistant/components/energy/translations/cs.json new file mode 100644 index 0000000000000..53457a6944742 --- /dev/null +++ b/homeassistant/components/energy/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/da.json b/homeassistant/components/energy/translations/da.json new file mode 100644 index 0000000000000..168ae4ae87763 --- /dev/null +++ b/homeassistant/components/energy/translations/da.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/de.json b/homeassistant/components/energy/translations/de.json new file mode 100644 index 0000000000000..53457a6944742 --- /dev/null +++ b/homeassistant/components/energy/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/el.json b/homeassistant/components/energy/translations/el.json new file mode 100644 index 0000000000000..cdc7b83c2ee31 --- /dev/null +++ b/homeassistant/components/energy/translations/el.json @@ -0,0 +1,3 @@ +{ + "title": "\u0395\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/en.json b/homeassistant/components/energy/translations/en.json new file mode 100644 index 0000000000000..109e1bd5af837 --- /dev/null +++ b/homeassistant/components/energy/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Energy" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/es-419.json b/homeassistant/components/energy/translations/es-419.json new file mode 100644 index 0000000000000..64c2f5bffa11e --- /dev/null +++ b/homeassistant/components/energy/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Energ\u00eda" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/es.json b/homeassistant/components/energy/translations/es.json new file mode 100644 index 0000000000000..64c2f5bffa11e --- /dev/null +++ b/homeassistant/components/energy/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Energ\u00eda" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/et.json b/homeassistant/components/energy/translations/et.json new file mode 100644 index 0000000000000..c8d85790fdd53 --- /dev/null +++ b/homeassistant/components/energy/translations/et.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/fi.json b/homeassistant/components/energy/translations/fi.json new file mode 100644 index 0000000000000..c8d85790fdd53 --- /dev/null +++ b/homeassistant/components/energy/translations/fi.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/fr.json b/homeassistant/components/energy/translations/fr.json new file mode 100644 index 0000000000000..f947a07baec07 --- /dev/null +++ b/homeassistant/components/energy/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "\u00c9nergie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/he.json b/homeassistant/components/energy/translations/he.json new file mode 100644 index 0000000000000..3c61aad6089f5 --- /dev/null +++ b/homeassistant/components/energy/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/hu.json b/homeassistant/components/energy/translations/hu.json new file mode 100644 index 0000000000000..c8d85790fdd53 --- /dev/null +++ b/homeassistant/components/energy/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/id.json b/homeassistant/components/energy/translations/id.json new file mode 100644 index 0000000000000..168ae4ae87763 --- /dev/null +++ b/homeassistant/components/energy/translations/id.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/it.json b/homeassistant/components/energy/translations/it.json new file mode 100644 index 0000000000000..c8d85790fdd53 --- /dev/null +++ b/homeassistant/components/energy/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/ja.json b/homeassistant/components/energy/translations/ja.json new file mode 100644 index 0000000000000..6dd35f6b4d7f3 --- /dev/null +++ b/homeassistant/components/energy/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u30a8\u30cd\u30eb\u30ae\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/nl.json b/homeassistant/components/energy/translations/nl.json new file mode 100644 index 0000000000000..53457a6944742 --- /dev/null +++ b/homeassistant/components/energy/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/no.json b/homeassistant/components/energy/translations/no.json new file mode 100644 index 0000000000000..168ae4ae87763 --- /dev/null +++ b/homeassistant/components/energy/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/pl.json b/homeassistant/components/energy/translations/pl.json new file mode 100644 index 0000000000000..c8d85790fdd53 --- /dev/null +++ b/homeassistant/components/energy/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/ru.json b/homeassistant/components/energy/translations/ru.json new file mode 100644 index 0000000000000..b351e40716893 --- /dev/null +++ b/homeassistant/components/energy/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "\u042d\u043d\u0435\u0440\u0433\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/tr.json b/homeassistant/components/energy/translations/tr.json new file mode 100644 index 0000000000000..4198959715cbd --- /dev/null +++ b/homeassistant/components/energy/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Enerji" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/zh-Hans.json b/homeassistant/components/energy/translations/zh-Hans.json new file mode 100644 index 0000000000000..bae50fae66ea3 --- /dev/null +++ b/homeassistant/components/energy/translations/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "title": "\u80fd\u6e90" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/zh-Hant.json b/homeassistant/components/energy/translations/zh-Hant.json new file mode 100644 index 0000000000000..bae50fae66ea3 --- /dev/null +++ b/homeassistant/components/energy/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "\u80fd\u6e90" +} \ No newline at end of file diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py new file mode 100644 index 0000000000000..b8df1b19bef70 --- /dev/null +++ b/homeassistant/components/energy/types.py @@ -0,0 +1,27 @@ +"""Types for the energy platform.""" +from __future__ import annotations + +from typing import Awaitable, Callable, TypedDict + +from homeassistant.core import HomeAssistant + + +class SolarForecastType(TypedDict): + """Return value for solar forecast.""" + + wh_hours: dict[str, float | int] + + +GetSolarForecastType = Callable[ + [HomeAssistant, str], Awaitable["SolarForecastType | None"] +] + + +class EnergyPlatform: + """This class represents the methods we expect on the energy platforms.""" + + @staticmethod + async def async_get_solar_forecast( + hass: HomeAssistant, config_entry_id: str + ) -> SolarForecastType | None: + """Get forecast for solar production for specific config entry ID.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py new file mode 100644 index 0000000000000..d9cd9a73aa066 --- /dev/null +++ b/homeassistant/components/energy/validate.py @@ -0,0 +1,505 @@ +"""Validate the energy preferences provide valid data.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +import dataclasses +import functools +from typing import Any + +from homeassistant.components import recorder, sensor +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant, callback, valid_entity_id + +from . import data +from .const import DOMAIN + +ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) +ENERGY_USAGE_UNITS = { + sensor.SensorDeviceClass.ENERGY: (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +} +ENERGY_PRICE_UNITS = tuple( + f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units +) +ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" +ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price" +GAS_USAGE_DEVICE_CLASSES = ( + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.GAS, +) +GAS_USAGE_UNITS = { + sensor.SensorDeviceClass.ENERGY: (ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), + sensor.SensorDeviceClass.GAS: (VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET), +} +GAS_PRICE_UNITS = tuple( + f"/{unit}" for units in GAS_USAGE_UNITS.values() for unit in units +) +GAS_UNIT_ERROR = "entity_unexpected_unit_gas" +GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price" + + +@dataclasses.dataclass +class ValidationIssue: + """Error or warning message.""" + + type: str + identifier: str + value: Any | None = None + + +@dataclasses.dataclass +class EnergyPreferencesValidation: + """Dictionary holding validation information.""" + + energy_sources: list[list[ValidationIssue]] = dataclasses.field( + default_factory=list + ) + device_consumption: list[list[ValidationIssue]] = dataclasses.field( + default_factory=list + ) + + def as_dict(self) -> dict: + """Return dictionary version.""" + return dataclasses.asdict(self) + + +@callback +def _async_validate_usage_stat( + hass: HomeAssistant, + metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], + stat_id: str, + allowed_device_classes: Sequence[str], + allowed_units: Mapping[str, Sequence[str]], + unit_error: str, + result: list[ValidationIssue], +) -> None: + """Validate a statistic.""" + if stat_id not in metadata: + result.append(ValidationIssue("statistics_not_defined", stat_id)) + + has_entity_source = valid_entity_id(stat_id) + + if not has_entity_source: + return + + entity_id = stat_id + + if not recorder.is_entity_recorded(hass, entity_id): + result.append( + ValidationIssue( + "recorder_untracked", + entity_id, + ) + ) + return + + if (state := hass.states.get(entity_id)) is None: + result.append( + ValidationIssue( + "entity_not_defined", + entity_id, + ) + ) + return + + if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + result.append(ValidationIssue("entity_unavailable", entity_id, state.state)) + return + + try: + current_value: float | None = float(state.state) + except ValueError: + result.append( + ValidationIssue("entity_state_non_numeric", entity_id, state.state) + ) + return + + if current_value is not None and current_value < 0: + result.append( + ValidationIssue("entity_negative_state", entity_id, current_value) + ) + + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + if device_class not in allowed_device_classes: + result.append( + ValidationIssue( + "entity_unexpected_device_class", + entity_id, + device_class, + ) + ) + else: + unit = state.attributes.get("unit_of_measurement") + + if device_class and unit not in allowed_units.get(device_class, []): + result.append(ValidationIssue(unit_error, entity_id, unit)) + + state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + + allowed_state_classes = [ + sensor.SensorStateClass.MEASUREMENT, + sensor.SensorStateClass.TOTAL, + sensor.SensorStateClass.TOTAL_INCREASING, + ] + if state_class not in allowed_state_classes: + result.append( + ValidationIssue( + "entity_unexpected_state_class", + entity_id, + state_class, + ) + ) + + if ( + state_class == sensor.SensorStateClass.MEASUREMENT + and sensor.ATTR_LAST_RESET not in state.attributes + ): + result.append( + ValidationIssue("entity_state_class_measurement_no_last_reset", entity_id) + ) + + +@callback +def _async_validate_price_entity( + hass: HomeAssistant, + entity_id: str, + result: list[ValidationIssue], + allowed_units: tuple[str, ...], + unit_error: str, +) -> None: + """Validate that the price entity is correct.""" + if (state := hass.states.get(entity_id)) is None: + result.append( + ValidationIssue( + "entity_not_defined", + entity_id, + ) + ) + return + + try: + float(state.state) + except ValueError: + result.append( + ValidationIssue("entity_state_non_numeric", entity_id, state.state) + ) + return + + unit = state.attributes.get("unit_of_measurement") + + if unit is None or not unit.endswith(allowed_units): + result.append(ValidationIssue(unit_error, entity_id, unit)) + + +@callback +def _async_validate_cost_stat( + hass: HomeAssistant, + metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], + stat_id: str, + result: list[ValidationIssue], +) -> None: + """Validate that the cost stat is correct.""" + if stat_id not in metadata: + result.append(ValidationIssue("statistics_not_defined", stat_id)) + + has_entity = valid_entity_id(stat_id) + + if not has_entity: + return + + if not recorder.is_entity_recorded(hass, stat_id): + result.append(ValidationIssue("recorder_untracked", stat_id)) + + if (state := hass.states.get(stat_id)) is None: + result.append(ValidationIssue("entity_not_defined", stat_id)) + return + + state_class = state.attributes.get("state_class") + + supported_state_classes = [ + sensor.SensorStateClass.MEASUREMENT, + sensor.SensorStateClass.TOTAL, + sensor.SensorStateClass.TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: + result.append( + ValidationIssue("entity_unexpected_state_class", stat_id, state_class) + ) + + if ( + state_class == sensor.SensorStateClass.MEASUREMENT + and sensor.ATTR_LAST_RESET not in state.attributes + ): + result.append( + ValidationIssue("entity_state_class_measurement_no_last_reset", stat_id) + ) + + +@callback +def _async_validate_auto_generated_cost_entity( + hass: HomeAssistant, energy_entity_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the auto generated cost entity is correct.""" + if energy_entity_id not in hass.data[DOMAIN]["cost_sensors"]: + # The cost entity has not been setup + return + + cost_entity_id = hass.data[DOMAIN]["cost_sensors"][energy_entity_id] + if not recorder.is_entity_recorded(hass, cost_entity_id): + result.append(ValidationIssue("recorder_untracked", cost_entity_id)) + + +async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: + """Validate the energy configuration.""" + manager = await data.async_get_manager(hass) + statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {} + validate_calls = [] + wanted_statistics_metadata = set() + + result = EnergyPreferencesValidation() + + if manager.data is None: + return result + + # Create a list of validation checks + for source in manager.data["energy_sources"]: + source_result: list[ValidationIssue] = [] + result.energy_sources.append(source_result) + + if source["type"] == "grid": + for flow in source["flow_from"]: + wanted_statistics_metadata.add(flow["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + flow["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + ) + + if flow.get("stat_cost") is not None: + wanted_statistics_metadata.add(flow["stat_cost"]) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + flow["stat_cost"], + source_result, + ) + ) + elif flow.get("entity_energy_price") is not None: + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, + ) + ) + + if flow.get("entity_energy_from") is not None and ( + flow.get("entity_energy_price") is not None + or flow.get("number_energy_price") is not None + ): + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + flow["entity_energy_from"], + source_result, + ) + ) + + for flow in source["flow_to"]: + wanted_statistics_metadata.add(flow["stat_energy_to"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + flow["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + ) + + if flow.get("stat_compensation") is not None: + wanted_statistics_metadata.add(flow["stat_compensation"]) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + flow["stat_compensation"], + source_result, + ) + ) + elif flow.get("entity_energy_price") is not None: + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, + ) + ) + + if flow.get("entity_energy_to") is not None and ( + flow.get("entity_energy_price") is not None + or flow.get("number_energy_price") is not None + ): + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + flow["entity_energy_to"], + source_result, + ) + ) + + elif source["type"] == "gas": + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + GAS_USAGE_DEVICE_CLASSES, + GAS_USAGE_UNITS, + GAS_UNIT_ERROR, + source_result, + ) + ) + + if source.get("stat_cost") is not None: + wanted_statistics_metadata.add(source["stat_cost"]) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + source["stat_cost"], + source_result, + ) + ) + elif source.get("entity_energy_price") is not None: + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + source["entity_energy_price"], + source_result, + GAS_PRICE_UNITS, + GAS_PRICE_UNIT_ERROR, + ) + ) + + if source.get("entity_energy_from") is not None and ( + source.get("entity_energy_price") is not None + or source.get("number_energy_price") is not None + ): + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + source["entity_energy_from"], + source_result, + ) + ) + + elif source["type"] == "solar": + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + ) + + elif source["type"] == "battery": + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + ) + wanted_statistics_metadata.add(source["stat_energy_to"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + ) + + for device in manager.data["device_consumption"]: + device_result: list[ValidationIssue] = [] + result.device_consumption.append(device_result) + wanted_statistics_metadata.add(device["stat_consumption"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + device["stat_consumption"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + device_result, + ) + ) + + # Fetch the needed statistics metadata + statistics_metadata.update( + await hass.async_add_executor_job( + functools.partial( + recorder.statistics.get_metadata, + hass, + statistic_ids=list(wanted_statistics_metadata), + ) + ) + ) + + # Execute all the validation checks + for call in validate_calls: + call() + + return result diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py new file mode 100644 index 0000000000000..cdc7599b55b94 --- /dev/null +++ b/homeassistant/components/energy/websocket_api.py @@ -0,0 +1,367 @@ +"""The Energy websocket API.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from datetime import datetime, timedelta +import functools +from itertools import chain +from types import ModuleType +from typing import Any, Awaitable, Callable, cast + +import voluptuous as vol + +from homeassistant.components import recorder, websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.singleton import singleton +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .data import ( + DEVICE_CONSUMPTION_SCHEMA, + ENERGY_SOURCE_SCHEMA, + EnergyManager, + EnergyPreferencesUpdate, + async_get_manager, +) +from .types import EnergyPlatform, GetSolarForecastType +from .validate import async_validate + +EnergyWebSocketCommandHandler = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], + None, +] +AsyncEnergyWebSocketCommandHandler = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], + Awaitable[None], +] + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the energy websocket API.""" + websocket_api.async_register_command(hass, ws_get_prefs) + websocket_api.async_register_command(hass, ws_save_prefs) + websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_validate) + websocket_api.async_register_command(hass, ws_solar_forecast) + websocket_api.async_register_command(hass, ws_get_fossil_energy_consumption) + + +@singleton("energy_platforms") +async def async_get_energy_platforms( + hass: HomeAssistant, +) -> dict[str, GetSolarForecastType]: + """Get energy platforms.""" + platforms: dict[str, GetSolarForecastType] = {} + + async def _process_energy_platform( + hass: HomeAssistant, domain: str, platform: ModuleType + ) -> None: + """Process energy platforms.""" + if not hasattr(platform, "async_get_solar_forecast"): + return + + platforms[domain] = cast(EnergyPlatform, platform).async_get_solar_forecast + + await async_process_integration_platforms(hass, DOMAIN, _process_energy_platform) + + return platforms + + +def _ws_with_manager( + func: Any, +) -> websocket_api.WebSocketCommandHandler: + """Decorate a function to pass in a manager.""" + + @websocket_api.async_response + @functools.wraps(func) + async def with_manager( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + manager = await async_get_manager(hass) + + result = func(hass, connection, msg, manager) + + if asyncio.iscoroutine(result): + await result + + return with_manager + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/get_prefs", + } +) +@_ws_with_manager +@callback +def ws_get_prefs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle get prefs command.""" + if manager.data is None: + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "No prefs") + return + + connection.send_result(msg["id"], manager.data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/save_prefs", + vol.Optional("energy_sources"): ENERGY_SOURCE_SCHEMA, + vol.Optional("device_consumption"): [DEVICE_CONSUMPTION_SCHEMA], + } +) +@_ws_with_manager +async def ws_save_prefs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle get prefs command.""" + msg_id = msg.pop("id") + msg.pop("type") + await manager.async_update(cast(EnergyPreferencesUpdate, msg)) + connection.send_result(msg_id, manager.data) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/info", + } +) +@websocket_api.async_response +async def ws_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + forecast_platforms = await async_get_energy_platforms(hass) + connection.send_result( + msg["id"], + { + "cost_sensors": hass.data[DOMAIN]["cost_sensors"], + "solar_forecast_domains": list(forecast_platforms), + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/validate", + } +) +@websocket_api.async_response +async def ws_validate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle validate command.""" + connection.send_result(msg["id"], (await async_validate(hass)).as_dict()) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/solar_forecast", + } +) +@_ws_with_manager +async def ws_solar_forecast( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle solar forecast command.""" + if manager.data is None: + connection.send_result(msg["id"], {}) + return + + config_entries: dict[str, str | None] = {} + + for source in manager.data["energy_sources"]: + if ( + source["type"] != "solar" + or source.get("config_entry_solar_forecast") is None + ): + continue + + # typing is not catching the above guard for config_entry_solar_forecast being none + for config_entry in source["config_entry_solar_forecast"]: # type: ignore[union-attr] + config_entries[config_entry] = None + + if not config_entries: + connection.send_result(msg["id"], {}) + return + + forecasts = {} + + forecast_platforms = await async_get_energy_platforms(hass) + + for config_entry_id in config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry_id) + # Filter out non-existing config entries or unsupported domains + + if config_entry is None or config_entry.domain not in forecast_platforms: + continue + + forecast = await forecast_platforms[config_entry.domain](hass, config_entry_id) + + if forecast is not None: + forecasts[config_entry_id] = forecast + + connection.send_result(msg["id"], forecasts) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/fossil_energy_consumption", + vol.Required("start_time"): str, + vol.Required("end_time"): str, + vol.Required("energy_statistic_ids"): [str], + vol.Required("co2_statistic_id"): str, + vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), + } +) +@websocket_api.async_response +async def ws_get_fossil_energy_consumption( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Calculate amount of fossil based energy.""" + start_time_str = msg["start_time"] + end_time_str = msg["end_time"] + + if start_time := dt_util.parse_datetime(start_time_str): + start_time = dt_util.as_utc(start_time) + else: + connection.send_error(msg["id"], "invalid_start_time", "Invalid start_time") + return + + if end_time := dt_util.parse_datetime(end_time_str): + end_time = dt_util.as_utc(end_time) + else: + connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time") + return + + statistic_ids = list(msg["energy_statistic_ids"]) + statistic_ids.append(msg["co2_statistic_id"]) + + # Fetch energy + CO2 statistics + statistics = await hass.async_add_executor_job( + recorder.statistics.statistics_during_period, + hass, + start_time, + end_time, + statistic_ids, + "hour", + True, + ) + + def _combine_sum_statistics( + stats: dict[str, list[dict[str, Any]]], statistic_ids: list[str] + ) -> dict[datetime, float]: + """Combine multiple statistics, returns a dict indexed by start time.""" + result: defaultdict[datetime, float] = defaultdict(float) + + for statistics_id, stat in stats.items(): + if statistics_id not in statistic_ids: + continue + for period in stat: + if period["sum"] is None: + continue + result[period["start"]] += period["sum"] + + return {key: result[key] for key in sorted(result)} + + def _calculate_deltas(sums: dict[datetime, float]) -> dict[datetime, float]: + prev: float | None = None + result: dict[datetime, float] = {} + for period, sum_ in sums.items(): + if prev is not None: + result[period] = sum_ - prev + prev = sum_ + return result + + def _reduce_deltas( + stat_list: list[dict[str, Any]], + same_period: Callable[[datetime, datetime], bool], + period_start_end: Callable[[datetime], tuple[datetime, datetime]], + period: timedelta, + ) -> list[dict[str, Any]]: + """Reduce hourly deltas to daily or monthly deltas.""" + result: list[dict[str, Any]] = [] + deltas: list[float] = [] + if not stat_list: + return result + prev_stat: dict[str, Any] = stat_list[0] + + # Loop over the hourly deltas + a fake entry to end the period + for statistic in chain( + stat_list, ({"start": stat_list[-1]["start"] + period},) + ): + if not same_period(prev_stat["start"], statistic["start"]): + start, _ = period_start_end(prev_stat["start"]) + # The previous statistic was the last entry of the period + result.append( + { + "start": start.isoformat(), + "delta": sum(deltas), + } + ) + deltas = [] + if statistic.get("delta") is not None: + deltas.append(statistic["delta"]) + prev_stat = statistic + + return result + + merged_energy_statistics = _combine_sum_statistics( + statistics, msg["energy_statistic_ids"] + ) + energy_deltas = _calculate_deltas(merged_energy_statistics) + indexed_co2_statistics = { + period["start"]: period["mean"] + for period in statistics.get(msg["co2_statistic_id"], {}) + } + + # Calculate amount of fossil based energy, assume 100% fossil if missing + fossil_energy = [ + {"start": start, "delta": delta * indexed_co2_statistics.get(start, 100) / 100} + for start, delta in energy_deltas.items() + ] + + if msg["period"] == "hour": + reduced_fossil_energy = [ + {"start": period["start"].isoformat(), "delta": period["delta"]} + for period in fossil_energy + ] + + elif msg["period"] == "day": + reduced_fossil_energy = _reduce_deltas( + fossil_energy, + recorder.statistics.same_day, + recorder.statistics.day_start_end, + timedelta(days=1), + ) + else: + reduced_fossil_energy = _reduce_deltas( + fossil_energy, + recorder.statistics.same_month, + recorder.statistics.month_start_end, + timedelta(days=1), + ) + + result = {period["start"]: period["delta"] for period in reduced_fossil_energy} + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index b8ea753b8d02c..d743b6c33461a 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -73,8 +73,7 @@ def supported_features(self): def turn_on(self, **kwargs): """Turn the light source on or sets a specific dimmer value.""" - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness is not None: + if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: self._brightness = brightness bval = math.floor(self._brightness / 256.0 * 100.0) diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 1814efb9c87bf..657c4ed4ecd2a 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -1,14 +1,19 @@ """Support for EnOcean sensors.""" +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ID, CONF_NAME, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, POWER_WATT, STATE_CLOSED, @@ -32,32 +37,39 @@ SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_WINDOWHANDLE = "windowhandle" -SENSOR_TYPES = { - SENSOR_TYPE_HUMIDITY: { - "name": "Humidity", - "unit": PERCENTAGE, - "icon": "mdi:water-percent", - "class": DEVICE_CLASS_HUMIDITY, - }, - SENSOR_TYPE_POWER: { - "name": "Power", - "unit": POWER_WATT, - "icon": "mdi:power-plug", - "class": DEVICE_CLASS_POWER, - }, - SENSOR_TYPE_TEMPERATURE: { - "name": "Temperature", - "unit": TEMP_CELSIUS, - "icon": "mdi:thermometer", - "class": DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_TYPE_WINDOWHANDLE: { - "name": "WindowHandle", - "unit": None, - "icon": "mdi:window", - "class": None, - }, -} +SENSOR_DESC_TEMPERATURE = SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, +) + +SENSOR_DESC_HUMIDITY = SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, +) + +SENSOR_DESC_POWER = SensorEntityDescription( + key=SENSOR_TYPE_POWER, + name="Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:power-plug", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, +) + +SENSOR_DESC_WINDOWHANDLE = SensorEntityDescription( + key=SENSOR_TYPE_WINDOWHANDLE, + name="WindowHandle", + icon="mdi:window", +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -74,81 +86,59 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up an EnOcean sensor device.""" - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) - sensor_type = config.get(CONF_DEVICE_CLASS) + dev_id = config[CONF_ID] + dev_name = config[CONF_NAME] + sensor_type = config[CONF_DEVICE_CLASS] + entities: list[EnOceanSensor] = [] if sensor_type == SENSOR_TYPE_TEMPERATURE: - temp_min = config.get(CONF_MIN_TEMP) - temp_max = config.get(CONF_MAX_TEMP) - range_from = config.get(CONF_RANGE_FROM) - range_to = config.get(CONF_RANGE_TO) - add_entities( - [ - EnOceanTemperatureSensor( - dev_id, dev_name, temp_min, temp_max, range_from, range_to - ) - ] - ) + temp_min = config[CONF_MIN_TEMP] + temp_max = config[CONF_MAX_TEMP] + range_from = config[CONF_RANGE_FROM] + range_to = config[CONF_RANGE_TO] + entities = [ + EnOceanTemperatureSensor( + dev_id, + dev_name, + SENSOR_DESC_TEMPERATURE, + scale_min=temp_min, + scale_max=temp_max, + range_from=range_from, + range_to=range_to, + ) + ] elif sensor_type == SENSOR_TYPE_HUMIDITY: - add_entities([EnOceanHumiditySensor(dev_id, dev_name)]) + entities = [EnOceanHumiditySensor(dev_id, dev_name, SENSOR_DESC_HUMIDITY)] elif sensor_type == SENSOR_TYPE_POWER: - add_entities([EnOceanPowerSensor(dev_id, dev_name)]) + entities = [EnOceanPowerSensor(dev_id, dev_name, SENSOR_DESC_POWER)] elif sensor_type == SENSOR_TYPE_WINDOWHANDLE: - add_entities([EnOceanWindowHandle(dev_id, dev_name)]) + entities = [EnOceanWindowHandle(dev_id, dev_name, SENSOR_DESC_WINDOWHANDLE)] + + if entities: + add_entities(entities) class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" - def __init__(self, dev_id, dev_name, sensor_type): + def __init__(self, dev_id, dev_name, description: SensorEntityDescription): """Initialize the EnOcean sensor device.""" super().__init__(dev_id, dev_name) - self._sensor_type = sensor_type - self._device_class = SENSOR_TYPES[self._sensor_type]["class"] - self._dev_name = f"{SENSOR_TYPES[self._sensor_type]['name']} {dev_name}" - self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]["unit"] - self._icon = SENSOR_TYPES[self._sensor_type]["icon"] - self._state = None - - @property - def name(self): - """Return the name of the device.""" - return self._dev_name - - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self.entity_description = description + self._attr_name = f"{description.name} {dev_name}" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. await super().async_added_to_hass() - if self._state is not None: + if self._attr_native_value is not None: return - state = await self.async_get_last_state() - if state is not None: - self._state = state.state + if (state := await self.async_get_last_state()) is not None: + self._attr_native_value = state.state def value_changed(self, packet): """Update the internal state of the sensor.""" @@ -161,10 +151,6 @@ class EnOceanPowerSensor(EnOceanSensor): - A5-12-01 (Automated Meter Reading, Electricity) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean power sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_POWER) - def value_changed(self, packet): """Update the internal state of the sensor.""" if packet.rorg != 0xA5: @@ -174,7 +160,7 @@ def value_changed(self, packet): # this packet reports the current value raw_val = packet.parsed["MR"]["raw_value"] divisor = packet.parsed["DIV"]["raw_value"] - self._state = raw_val / (10 ** divisor) + self._attr_native_value = raw_val / (10 ** divisor) self.schedule_update_ha_state() @@ -196,9 +182,19 @@ class EnOceanTemperatureSensor(EnOceanSensor): - A5-10-10 to A5-10-14 """ - def __init__(self, dev_id, dev_name, scale_min, scale_max, range_from, range_to): + def __init__( + self, + dev_id, + dev_name, + description: SensorEntityDescription, + *, + scale_min, + scale_max, + range_from, + range_to, + ): """Initialize the EnOcean temperature sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_TEMPERATURE) + super().__init__(dev_id, dev_name, description) self._scale_min = scale_min self._scale_max = scale_max self.range_from = range_from @@ -213,7 +209,7 @@ def value_changed(self, packet): raw_val = packet.data[3] temperature = temp_scale / temp_range * (raw_val - self.range_from) temperature += self._scale_min - self._state = round(temperature, 1) + self._attr_native_value = round(temperature, 1) self.schedule_update_ha_state() @@ -226,16 +222,12 @@ class EnOceanHumiditySensor(EnOceanSensor): - A5-10-10 to A5-10-14 (Room Operating Panels) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean humidity sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_HUMIDITY) - def value_changed(self, packet): """Update the internal state of the sensor.""" if packet.rorg != 0xA5: return humidity = packet.data[2] * 100 / 250 - self._state = round(humidity, 1) + self._attr_native_value = round(humidity, 1) self.schedule_update_ha_state() @@ -246,20 +238,16 @@ class EnOceanWindowHandle(EnOceanSensor): - F6-10-00 (Mechanical handle / Hoppe AG) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean window handle sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_WINDOWHANDLE) - def value_changed(self, packet): """Update the internal state of the sensor.""" action = (packet.data[1] & 0x70) >> 4 if action == 0x07: - self._state = STATE_CLOSED + self._attr_native_value = STATE_CLOSED if action in (0x04, 0x06): - self._state = STATE_OPEN + self._attr_native_value = STATE_OPEN if action == 0x05: - self._state = "tilt" + self._attr_native_value = "tilt" self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/translations/de.json b/homeassistant/components/enocean/translations/de.json index ea9858f470eb6..63a3cf73ca888 100644 --- a/homeassistant/components/enocean/translations/de.json +++ b/homeassistant/components/enocean/translations/de.json @@ -1,7 +1,25 @@ { "config": { "abort": { - "single_instance_allowed": "Schon konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + "invalid_dongle_path": "Ung\u00fcltiger Dongle-Pfad", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "error": { + "invalid_dongle_path": "Kein g\u00fcltiger Dongle unter diesem Pfad gefunden" + }, + "step": { + "detect": { + "data": { + "path": "USB-Dongle-Pfad" + }, + "title": "W\u00e4hle den Pfad zu deinem ENOcean-Dongle" + }, + "manual": { + "data": { + "path": "USB-Dongle-Pfad" + }, + "title": "Gib den Pfad zu deinem ENOcean-Dongle ein" + } } } } \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/es-419.json b/homeassistant/components/enocean/translations/es-419.json new file mode 100644 index 0000000000000..a0eaca491b2a6 --- /dev/null +++ b/homeassistant/components/enocean/translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Ruta de dongle no v\u00e1lida" + }, + "error": { + "invalid_dongle_path": "No se encontr\u00f3 ning\u00fan dongle v\u00e1lido para esta ruta" + }, + "step": { + "detect": { + "data": { + "path": "Ruta de dongle USB" + }, + "title": "Seleccione la ruta a su ENOcean dongle" + }, + "manual": { + "data": { + "path": "Ruta de dongle USB" + }, + "title": "Ingrese la ruta a su ENOcean dongle" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/he.json b/homeassistant/components/enocean/translations/he.json new file mode 100644 index 0000000000000..d0c3523da94e2 --- /dev/null +++ b/homeassistant/components/enocean/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/hu.json b/homeassistant/components/enocean/translations/hu.json index 065747fb39df5..bfb6cb0499d37 100644 --- a/homeassistant/components/enocean/translations/hu.json +++ b/homeassistant/components/enocean/translations/hu.json @@ -1,7 +1,25 @@ { "config": { "abort": { + "invalid_dongle_path": "\u00c9rv\u00e9nytelen dongle \u00fatvonal", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "error": { + "invalid_dongle_path": "Nem tal\u00e1lhat\u00f3 \u00e9rv\u00e9nyes dongle ehhez az \u00fatvonalhoz" + }, + "step": { + "detect": { + "data": { + "path": "USB dongle el\u00e9r\u00e9si \u00fatja" + }, + "title": "V\u00e1lassza ki az ENOcean-dongle el\u00e9r\u00e9si \u00fatvonal\u00e1t." + }, + "manual": { + "data": { + "path": "USB dongle el\u00e9r\u00e9si \u00fatja" + }, + "title": "Adja meg az ENOcean dongle el\u00e9r\u00e9si \u00fatvonal\u00e1t" + } } } } \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/ja.json b/homeassistant/components/enocean/translations/ja.json new file mode 100644 index 0000000000000..e0ec74d778fe7 --- /dev/null +++ b/homeassistant/components/enocean/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "\u30c9\u30f3\u30b0\u30eb\u30d1\u30b9\u304c\u7121\u52b9", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_dongle_path": "\u3053\u306e\u30d1\u30b9\u306b\u6709\u52b9\u306a\u30c9\u30f3\u30b0\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "detect": { + "data": { + "path": "USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9" + }, + "title": "ENOcean dongle\u306e\u30d1\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "manual": { + "data": { + "path": "USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9" + }, + "title": "ENOcean dongle\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/tr.json b/homeassistant/components/enocean/translations/tr.json index b4e6be555ff46..b070fddf01264 100644 --- a/homeassistant/components/enocean/translations/tr.json +++ b/homeassistant/components/enocean/translations/tr.json @@ -11,12 +11,14 @@ "detect": { "data": { "path": "USB dongle yolu" - } + }, + "title": "ENOcean dongle yolunu se\u00e7in" }, "manual": { "data": { "path": "USB dongle yolu" - } + }, + "title": "Enocean dongle yolunu girin" } } } diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index dfd6b782408d6..7b3765bd25c94 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -47,9 +47,11 @@ async def async_update_data(): except httpx.HTTPError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - for condition in SENSORS: - if condition != "inverters": - data[condition] = await getattr(envoy_reader, condition)() + for description in SENSORS: + if description.key != "inverters": + data[description.key] = await getattr( + envoy_reader, description.key + )() else: data[ "inverters_production" @@ -73,6 +75,14 @@ async def async_update_data(): envoy_reader.get_inverters = False await coordinator.async_config_entry_first_refresh() + if not entry.unique_id: + try: + serial = await envoy_reader.get_full_serial_number() + except httpx.HTTPError: + pass + else: + hass.config_entries.async_update_entry(entry, unique_id=serial) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { COORDINATOR: coordinator, NAME: name, diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 8a4b4b19e58d7..fa43cb61ffe98 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Enphase Envoy integration.""" from __future__ import annotations +import contextlib import logging from typing import Any @@ -9,13 +10,8 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, - CONF_IP_ADDRESS, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, -) +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -30,7 +26,7 @@ CONF_SERIAL = "serial" -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> EnvoyReader: """Validate the user input allows us to connect.""" envoy_reader = EnvoyReader( data[CONF_HOST], @@ -47,6 +43,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except (RuntimeError, httpx.HTTPError) as err: raise CannotConnect from err + return envoy_reader + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Enphase Envoy.""" @@ -56,9 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize an envoy flow.""" self.ip_address = None - self.name = None self.username = None - self.serial = None self._reauth_entry = None @callback @@ -77,19 +73,6 @@ def _async_generate_schema(self): schema[vol.Optional(CONF_PASSWORD, default="")] = str return vol.Schema(schema) - async def async_step_import(self, import_config): - """Handle a flow import.""" - self.ip_address = import_config[CONF_IP_ADDRESS] - self.username = import_config[CONF_USERNAME] - self.name = import_config[CONF_NAME] - return await self.async_step_user( - { - CONF_HOST: import_config[CONF_IP_ADDRESS], - CONF_USERNAME: import_config[CONF_USERNAME], - CONF_PASSWORD: import_config[CONF_PASSWORD], - } - ) - @callback def _async_current_hosts(self): """Return a set of hosts.""" @@ -99,11 +82,13 @@ def _async_current_hosts(self): if CONF_HOST in entry.data } - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - self.serial = discovery_info["properties"]["serialnum"] - await self.async_set_unique_id(self.serial) - self.ip_address = discovery_info[CONF_HOST] + serial = discovery_info.properties["serialnum"] + await self.async_set_unique_id(serial) + self.ip_address = discovery_info.host self._abort_if_unique_id_configured({CONF_HOST: self.ip_address}) for entry in self._async_current_entries(include_ignore=False): if ( @@ -111,9 +96,9 @@ async def async_step_zeroconf(self, discovery_info): and CONF_HOST in entry.data and entry.data[CONF_HOST] == self.ip_address ): - title = f"{ENVOY} {self.serial}" if entry.title == ENVOY else ENVOY + title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY self.hass.config_entries.async_update_entry( - entry, title=title, unique_id=self.serial + entry, title=title, unique_id=serial ) self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) @@ -129,6 +114,22 @@ async def async_step_reauth(self, user_input): ) return await self.async_step_user() + def _async_envoy_name(self) -> str: + """Return the name of the envoy.""" + if self.unique_id: + return f"{ENVOY} {self.unique_id}" + return ENVOY + + async def _async_set_unique_id_from_envoy(self, envoy_reader: EnvoyReader) -> bool: + """Set the unique id by fetching it from the envoy.""" + serial = None + with contextlib.suppress(httpx.HTTPError): + serial = await envoy_reader.get_full_serial_number() + if serial: + await self.async_set_unique_id(serial) + return True + return False + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -142,7 +143,7 @@ async def async_step_user( ): return self.async_abort(reason="already_configured") try: - await validate_input(self.hass, user_input) + envoy_reader = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -152,21 +153,28 @@ async def async_step_user( errors["base"] = "unknown" else: data = user_input.copy() - if self.serial: - data[CONF_NAME] = f"{ENVOY} {self.serial}" - else: - data[CONF_NAME] = self.name or ENVOY + data[CONF_NAME] = self._async_envoy_name() + if self._reauth_entry: self.hass.config_entries.async_update_entry( self._reauth_entry, data=data, ) return self.async_abort(reason="reauth_successful") + + if not self.unique_id and await self._async_set_unique_id_from_envoy( + envoy_reader + ): + data[CONF_NAME] = self._async_envoy_name() + + if self.unique_id: + self._abort_if_unique_id_configured({CONF_HOST: data[CONF_HOST]}) + return self.async_create_entry(title=data[CONF_NAME], data=data) - if self.serial: + if self.unique_id: self.context["title_placeholders"] = { - CONF_SERIAL: self.serial, + CONF_SERIAL: self.unique_id, CONF_HOST: self.ip_address, } return self.async_show_form( diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 89803d32351c9..747a4886f1559 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -1,30 +1,80 @@ """The enphase_envoy component.""" -from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, Platform DOMAIN = "enphase_envoy" -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] COORDINATOR = "coordinator" NAME = "name" -SENSORS = { - "production": ("Current Energy Production", POWER_WATT), - "daily_production": ("Today's Energy Production", ENERGY_WATT_HOUR), - "seven_days_production": ( - "Last Seven Days Energy Production", - ENERGY_WATT_HOUR, - ), - "lifetime_production": ("Lifetime Energy Production", ENERGY_WATT_HOUR), - "consumption": ("Current Energy Consumption", POWER_WATT), - "daily_consumption": ("Today's Energy Consumption", ENERGY_WATT_HOUR), - "seven_days_consumption": ( - "Last Seven Days Energy Consumption", - ENERGY_WATT_HOUR, - ), - "lifetime_consumption": ("Lifetime Energy Consumption", ENERGY_WATT_HOUR), - "inverters": ("Inverter", POWER_WATT), -} +SENSORS = ( + SensorEntityDescription( + key="production", + name="Current Power Production", + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="daily_production", + name="Today's Energy Production", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="seven_days_production", + name="Last Seven Days Energy Production", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="lifetime_production", + name="Lifetime Energy Production", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="consumption", + name="Current Power Consumption", + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="daily_consumption", + name="Today's Energy Consumption", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="seven_days_consumption", + name="Last Seven Days Energy Consumption", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="lifetime_consumption", + name="Lifetime Energy Consumption", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="inverters", + name="Inverter", + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), +) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 3e31ac5dc63c5..d7ad10ca06223 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -2,8 +2,12 @@ "domain": "enphase_envoy", "name": "Enphase Envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", - "requirements": ["envoy_reader==0.18.4"], - "codeowners": ["@gtdiehl"], + "requirements": [ + "envoy_reader==0.20.1" + ], + "codeowners": [ + "@gtdiehl" + ], "config_flow": true, "zeroconf": [ { diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 050a497f69e4f..1ca99748c51cc 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -1,52 +1,13 @@ """Support for Enphase Envoy solar energy monitor.""" +from __future__ import annotations -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DOMAIN, NAME, SENSORS ICON = "mdi:flash" -CONST_DEFAULT_HOST = "envoy" -_LOGGER = logging.getLogger(__name__) - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_IP_ADDRESS, default=CONST_DEFAULT_HOST): cv.string, - vol.Optional(CONF_USERNAME, default="envoy"): cv.string, - vol.Optional(CONF_PASSWORD, default=""): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(list(SENSORS))] - ), - vol.Optional(CONF_NAME, default=""): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Enphase Envoy sensor.""" - _LOGGER.warning( - "Loading enphase_envoy via platform config is deprecated; The configuration" - " has been migrated to a config entry and can be safely removed" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -56,41 +17,38 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = data[NAME] entities = [] - for condition in SENSORS: - entity_name = "" + for sensor_description in SENSORS: if ( - condition == "inverters" + sensor_description.key == "inverters" and coordinator.data.get("inverters_production") is not None ): for inverter in coordinator.data["inverters_production"]: - entity_name = f"{name} {SENSORS[condition][0]} {inverter}" + entity_name = f"{name} {sensor_description.name} {inverter}" split_name = entity_name.split(" ") serial_number = split_name[-1] entities.append( Envoy( - condition, + sensor_description, entity_name, name, config_entry.unique_id, serial_number, - SENSORS[condition][1], coordinator, ) ) - elif condition != "inverters": - data = coordinator.data.get(condition) + elif sensor_description.key != "inverters": + data = coordinator.data.get(sensor_description.key) if isinstance(data, str) and "not available" in data: continue - entity_name = f"{name} {SENSORS[condition][0]}" + entity_name = f"{name} {sensor_description.name}" entities.append( Envoy( - condition, + sensor_description, entity_name, name, config_entry.unique_id, None, - SENSORS[condition][1], coordinator, ) ) @@ -103,21 +61,19 @@ class Envoy(CoordinatorEntity, SensorEntity): def __init__( self, - sensor_type, + description, name, device_name, device_serial_number, serial_number, - unit, coordinator, ): """Initialize Envoy entity.""" - self._type = sensor_type + self.entity_description = description self._name = name self._serial_number = serial_number self._device_name = device_name self._device_serial_number = device_serial_number - self._unit_of_measurement = unit super().__init__(coordinator) @@ -132,16 +88,16 @@ def unique_id(self): if self._serial_number: return self._serial_number if self._device_serial_number: - return f"{self._device_serial_number}_{self._type}" + return f"{self._device_serial_number}_{self.entity_description.key}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._type != "inverters": - value = self.coordinator.data.get(self._type) + if self.entity_description.key != "inverters": + value = self.coordinator.data.get(self.entity_description.key) elif ( - self._type == "inverters" + self.entity_description.key == "inverters" and self.coordinator.data.get("inverters_production") is not None ): value = self.coordinator.data.get("inverters_production").get( @@ -152,11 +108,6 @@ def state(self): return value - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - @property def icon(self): """Icon to use in the frontend, if any.""" @@ -166,7 +117,7 @@ def icon(self): def extra_state_attributes(self): """Return the state attributes.""" if ( - self._type == "inverters" + self.entity_description.key == "inverters" and self.coordinator.data.get("inverters_production") is not None ): value = self.coordinator.data.get("inverters_production").get( @@ -177,13 +128,13 @@ def extra_state_attributes(self): return None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" if not self._device_serial_number: return None - return { - "identifiers": {(DOMAIN, str(self._device_serial_number))}, - "name": self._device_name, - "model": "Envoy", - "manufacturer": "Enphase", - } + return DeviceInfo( + identifiers={(DOMAIN, str(self._device_serial_number))}, + manufacturer="Enphase", + model="Envoy", + name=self._device_name, + ) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index b42f6bfb50f5b..822ee14fc9e3d 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -3,6 +3,7 @@ "flow_title": "{serial} ({host})", "step": { "user": { + "description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/enphase_envoy/translations/ca.json b/homeassistant/components/enphase_envoy/translations/ca.json index fad9e8f4a18fb..b1f88de9b961f 100644 --- a/homeassistant/components/enphase_envoy/translations/ca.json +++ b/homeassistant/components/enphase_envoy/translations/ca.json @@ -9,14 +9,15 @@ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "unknown": "Error inesperat" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Amfitri\u00f3", "password": "Contrasenya", "username": "Nom d'usuari" - } + }, + "description": "Per als models m\u00e9s nous, introdueix el nom d'usuari `envoy` sense contrasenya. Per als models m\u00e9s antics, introdueix el nom d'usuari `installer` sense contrasenya. Per a la resta de models, introdueix un nom d'usuari i una contrasenya v\u00e0lids." } } } diff --git a/homeassistant/components/enphase_envoy/translations/de.json b/homeassistant/components/enphase_envoy/translations/de.json index b1fb53829ada1..84bb9a04f3414 100644 --- a/homeassistant/components/enphase_envoy/translations/de.json +++ b/homeassistant/components/enphase_envoy/translations/de.json @@ -9,14 +9,15 @@ "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Host", "password": "Passwort", "username": "Benutzername" - } + }, + "description": "Bei neueren Modellen gib den Benutzernamen `envoy` ohne Passwort ein. F\u00fcr \u00e4ltere Modelle gib den Benutzernamen `installer` ohne Passwort ein. F\u00fcr alle anderen Modelle gib einen g\u00fcltigen Benutzernamen und ein Passwort ein." } } } diff --git a/homeassistant/components/enphase_envoy/translations/en.json b/homeassistant/components/enphase_envoy/translations/en.json index 2cdb75a6b53c2..5d4617ed9fac7 100644 --- a/homeassistant/components/enphase_envoy/translations/en.json +++ b/homeassistant/components/enphase_envoy/translations/en.json @@ -16,7 +16,8 @@ "host": "Host", "password": "Password", "username": "Username" - } + }, + "description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password." } } } diff --git a/homeassistant/components/enphase_envoy/translations/es-419.json b/homeassistant/components/enphase_envoy/translations/es-419.json new file mode 100644 index 0000000000000..3dd80c3f60b41 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "{serial} ({host})" + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/et.json b/homeassistant/components/enphase_envoy/translations/et.json index d4a0fb6dfb34e..8c814f8002c0d 100644 --- a/homeassistant/components/enphase_envoy/translations/et.json +++ b/homeassistant/components/enphase_envoy/translations/et.json @@ -9,14 +9,15 @@ "invalid_auth": "Tuvastamise viga", "unknown": "Tundmatu viga" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Host", "password": "Salas\u00f5na", "username": "Kasutajanimi" - } + }, + "description": "Uuemate mudelite puhul sisesta kasutajanimi \"envoy\" ilma salas\u00f5nata. Vanemate mudelite puhul sisesta kasutajanimi \"installer\" ilma salas\u00f5nata. K\u00f5igi teiste mudelite puhul sisesta kehtiv kasutajanimi ja salas\u00f5nal." } } } diff --git a/homeassistant/components/enphase_envoy/translations/fr.json b/homeassistant/components/enphase_envoy/translations/fr.json index be1d5f3bca3df..165c54c67d138 100644 --- a/homeassistant/components/enphase_envoy/translations/fr.json +++ b/homeassistant/components/enphase_envoy/translations/fr.json @@ -1,21 +1,23 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, - "flow_title": "Envoy\u00e9 {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe", "username": "Nom d'utilisateur" - } + }, + "description": "Pour les mod\u00e8les plus r\u00e9cents, saisissez le nom d'utilisateur \u00ab\u00a0envoy\u00a0\u00bb sans mot de passe. Pour les mod\u00e8les plus anciens, entrez le nom d'utilisateur \u00ab\u00a0installer\u00a0\u00bb sans mot de passe. Pour tous les autres mod\u00e8les, entrez un nom d'utilisateur et un mot de passe valides." } } } diff --git a/homeassistant/components/enphase_envoy/translations/he.json b/homeassistant/components/enphase_envoy/translations/he.json new file mode 100644 index 0000000000000..94741f81ff97a --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/he.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{serial} ({host})", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/hu.json b/homeassistant/components/enphase_envoy/translations/hu.json index caef6a32c8646..9da8d49341de1 100644 --- a/homeassistant/components/enphase_envoy/translations/hu.json +++ b/homeassistant/components/enphase_envoy/translations/hu.json @@ -1,21 +1,23 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "\u00dajabb t\u00edpusok eset\u00e9n adja meg az `envoy` felhaszn\u00e1l\u00f3nevet jelsz\u00f3 n\u00e9lk\u00fcl. R\u00e9gebbi t\u00edpusok eset\u00e9n adja meg a `installer` felhaszn\u00e1l\u00f3nevet jelsz\u00f3 n\u00e9lk\u00fcl. Minden m\u00e1s t\u00edpus eset\u00e9ben adjon meg egy \u00e9rv\u00e9nyes felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t." } } } diff --git a/homeassistant/components/enphase_envoy/translations/id.json b/homeassistant/components/enphase_envoy/translations/id.json index 74e3e8a66c74b..71b453b2552ce 100644 --- a/homeassistant/components/enphase_envoy/translations/id.json +++ b/homeassistant/components/enphase_envoy/translations/id.json @@ -1,21 +1,23 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Host", "password": "Kata Sandi", "username": "Nama Pengguna" - } + }, + "description": "Untuk model yang lebih baru, masukkan nama pengguna `envoy` tanpa kata sandi. Untuk model yang lebih lama, masukkan nama pengguna `installer` tanpa kata sandi. Untuk semua model lainnya, masukkan nama pengguna dan kata sandi yang valid." } } } diff --git a/homeassistant/components/enphase_envoy/translations/it.json b/homeassistant/components/enphase_envoy/translations/it.json index 2f0e1edc8450b..98d8520d6ce7e 100644 --- a/homeassistant/components/enphase_envoy/translations/it.json +++ b/homeassistant/components/enphase_envoy/translations/it.json @@ -9,14 +9,15 @@ "invalid_auth": "Autenticazione non valida", "unknown": "Errore imprevisto" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Host", "password": "Password", "username": "Nome utente" - } + }, + "description": "Per i modelli pi\u00f9 recenti, inserisci il nome utente \"envoy\" senza password. Per i modelli pi\u00f9 vecchi, inserisci il nome utente \"installer\" senza password. Per tutti gli altri modelli, inserisci un nome utente e una password validi." } } } diff --git a/homeassistant/components/enphase_envoy/translations/ja.json b/homeassistant/components/enphase_envoy/translations/ja.json new file mode 100644 index 0000000000000..2cfb00ff1fa11 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{serial} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u65b0\u3057\u3044\u30e2\u30c7\u30eb\u306e\u5834\u5408\u306f\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u306a\u3057\u3067\u30e6\u30fc\u30b6\u30fc\u540d `envoy` \u3092\u5165\u529b\u3057\u307e\u3059\u3002\u53e4\u3044\u30e2\u30c7\u30eb\u306e\u5834\u5408\u306f\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u306a\u3057\u3067\u30e6\u30fc\u30b6\u30fc\u540d `installer` \u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4ed6\u306e\u3059\u3079\u3066\u306e\u30e2\u30c7\u30eb\u3067\u306f\u3001\u6709\u52b9\u306a\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/nl.json b/homeassistant/components/enphase_envoy/translations/nl.json index da43476cd813f..26a96d5bde288 100644 --- a/homeassistant/components/enphase_envoy/translations/nl.json +++ b/homeassistant/components/enphase_envoy/translations/nl.json @@ -9,14 +9,15 @@ "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Host", "password": "Wachtwoord", "username": "Gebruikersnaam" - } + }, + "description": "Voer voor nieuwere modellen gebruikersnaam 'envoy' in zonder wachtwoord. Voer voor oudere modellen gebruikersnaam `installer` in zonder wachtwoord. Voer voor alle andere modellen een geldige gebruikersnaam en wachtwoord in." } } } diff --git a/homeassistant/components/enphase_envoy/translations/no.json b/homeassistant/components/enphase_envoy/translations/no.json index aee2b0f711a46..091d76d55ec57 100644 --- a/homeassistant/components/enphase_envoy/translations/no.json +++ b/homeassistant/components/enphase_envoy/translations/no.json @@ -9,14 +9,15 @@ "invalid_auth": "Ugyldig godkjenning", "unknown": "Uventet feil" }, - "flow_title": "Utsending {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Vert", "password": "Passord", "username": "Brukernavn" - } + }, + "description": "For nyere modeller, skriv inn brukernavnet \"envoy\" uten passord. For eldre modeller, skriv inn brukernavnet `installer` uten passord. For alle andre modeller, skriv inn et gyldig brukernavn og passord." } } } diff --git a/homeassistant/components/enphase_envoy/translations/pl.json b/homeassistant/components/enphase_envoy/translations/pl.json index e35e215bffaeb..ed57bf9cf0ab5 100644 --- a/homeassistant/components/enphase_envoy/translations/pl.json +++ b/homeassistant/components/enphase_envoy/translations/pl.json @@ -9,14 +9,15 @@ "invalid_auth": "Niepoprawne uwierzytelnienie", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "Nazwa hosta lub adres IP", "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika" - } + }, + "description": "W przypadku nowszych modeli, wpisz nazw\u0119 u\u017cytkownika \u201eenvoy\u201d bez has\u0142a. W przypadku starszych modeli, wprowad\u017a nazw\u0119 u\u017cytkownika \u201einstaller\u201d bez has\u0142a. W przypadku wszystkich innych modeli wprowad\u017a prawid\u0142ow\u0105 nazw\u0119 u\u017cytkownika i has\u0142o." } } } diff --git a/homeassistant/components/enphase_envoy/translations/ru.json b/homeassistant/components/enphase_envoy/translations/ru.json index b04d0ac509311..fd15561e4cf44 100644 --- a/homeassistant/components/enphase_envoy/translations/ru.json +++ b/homeassistant/components/enphase_envoy/translations/ru.json @@ -9,14 +9,15 @@ "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "\u0425\u043e\u0441\u0442", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" - } + }, + "description": "\u0414\u043b\u044f \u043d\u043e\u0432\u044b\u0445 \u043c\u043e\u0434\u0435\u043b\u0435\u0439 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f `envoy` \u0431\u0435\u0437 \u043f\u0430\u0440\u043e\u043b\u044f. \u0414\u043b\u044f \u0441\u0442\u0430\u0440\u044b\u0445 \u043c\u043e\u0434\u0435\u043b\u0435\u0439 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f `installer` \u0431\u0435\u0437 \u043f\u0430\u0440\u043e\u043b\u044f. \u0414\u043b\u044f \u0432\u0441\u0435\u0445 \u043e\u0441\u0442\u0430\u043b\u044c\u043d\u044b\u0445 \u043c\u043e\u0434\u0435\u043b\u0435\u0439 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c." } } } diff --git a/homeassistant/components/enphase_envoy/translations/tr.json b/homeassistant/components/enphase_envoy/translations/tr.json new file mode 100644 index 0000000000000..c1ab59f37167f --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/tr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{serial} ( {host} )", + "step": { + "user": { + "data": { + "host": "Sunucu", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Daha yeni modeller i\u00e7in, parola olmadan 'envoy' kullan\u0131c\u0131 ad\u0131n\u0131 girin. Daha eski modeller i\u00e7in, parola olmadan 'installer' kullan\u0131c\u0131 ad\u0131n\u0131 girin. Di\u011fer t\u00fcm modeller i\u00e7in ge\u00e7erli bir kullan\u0131c\u0131 ad\u0131 ve \u015fifre girin." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/zh-Hans.json b/homeassistant/components/enphase_envoy/translations/zh-Hans.json new file mode 100644 index 0000000000000..d217ccdc8429d --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/zh-Hant.json b/homeassistant/components/enphase_envoy/translations/zh-Hant.json index 6fd6d4d038aca..4fda54f9fa8d5 100644 --- a/homeassistant/components/enphase_envoy/translations/zh-Hant.json +++ b/homeassistant/components/enphase_envoy/translations/zh-Hant.json @@ -9,14 +9,15 @@ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" - } + }, + "description": "\u5c0d\u65bc\u8f03\u65b0\u7684\u578b\u865f\u3001\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31 `envoy` \u4f46\u4e0d\u9700\u8f38\u5165\u5bc6\u78bc\u3002\u8f03\u820a\u7684\u578b\u865f\uff0c\u5247\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31 `envoy` \u4e0d\u542b\u5bc6\u78bc\u3002\u5176\u4ed6\u6240\u6709\u578b\u865f\uff0c\u8acb\u8f38\u5165\u6709\u6548\u7684\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3002" } } } diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json index ad522be932152..e2a5175211cf0 100644 --- a/homeassistant/components/entur_public_transport/manifest.json +++ b/homeassistant/components/entur_public_transport/manifest.json @@ -2,7 +2,7 @@ "domain": "entur_public_transport", "name": "Entur", "documentation": "https://www.home-assistant.io/integrations/entur_public_transport", - "requirements": ["enturclient==0.2.1"], + "requirements": ["enturclient==0.2.2"], "codeowners": ["@hfurubotten"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index c9c530c6b0871..776d1c1761808 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -1,4 +1,6 @@ """Real-time information about public transport departures in Norway.""" +from __future__ import annotations + from datetime import datetime, timedelta from enturclient import EnturPublicTransportData @@ -150,15 +152,17 @@ def get_stop_info(self, stop_id: str) -> dict: class EnturPublicTransportSensor(SensorEntity): """Implementation of a Entur public transport sensor.""" - def __init__(self, api: EnturProxy, name: str, stop: str, show_on_map: bool): + def __init__( + self, api: EnturProxy, name: str, stop: str, show_on_map: bool + ) -> None: """Initialize the sensor.""" self.api = api self._stop = stop self._show_on_map = show_on_map self._name = name - self._state = None + self._state: int | None = None self._icon = ICONS[DEFAULT_ICON_KEY] - self._attributes = {} + self._attributes: dict[str, str] = {} @property def name(self) -> str: @@ -166,7 +170,7 @@ def name(self) -> str: return self._name @property - def state(self) -> str: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state @@ -178,7 +182,7 @@ def extra_state_attributes(self) -> dict: return self._attributes @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TIME_MINUTES @@ -193,7 +197,7 @@ async def async_update(self) -> None: self._attributes = {} - data = self.api.get_stop_info(self._stop) + data: EnturPublicTransportData = self.api.get_stop_info(self._stop) if data is None: self._state = None return @@ -202,8 +206,7 @@ async def async_update(self) -> None: self._attributes[CONF_LATITUDE] = data.latitude self._attributes[CONF_LONGITUDE] = data.longitude - calls = data.estimated_calls - if not calls: + if not (calls := data.estimated_calls): self._state = None return diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 356e18fe23fd4..9b5ce11861ce4 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -1 +1,81 @@ -"""A component for Environment Canada weather.""" +"""The Environment Canada (EC) component.""" +from datetime import timedelta +import logging +import xml.etree.ElementTree as et + +from env_canada import ECRadar, ECWeather, ec_exc + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN + +DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) +DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) + +PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry): + """Set up EC as config entry.""" + lat = config_entry.data.get(CONF_LATITUDE) + lon = config_entry.data.get(CONF_LONGITUDE) + station = config_entry.data.get(CONF_STATION) + lang = config_entry.data.get(CONF_LANGUAGE, "English") + + coordinators = {} + + weather_data = ECWeather( + station_id=station, + coordinates=(lat, lon), + language=lang.lower(), + ) + coordinators["weather_coordinator"] = ECDataUpdateCoordinator( + hass, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL + ) + await coordinators["weather_coordinator"].async_config_entry_first_refresh() + + radar_data = ECRadar(coordinates=(lat, lon)) + coordinators["radar_coordinator"] = ECDataUpdateCoordinator( + hass, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL + ) + await coordinators["radar_coordinator"].async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = coordinators + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class ECDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching EC data.""" + + def __init__(self, hass, ec_data, name, update_interval): + """Initialize global EC data updater.""" + super().__init__( + hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval + ) + self.ec_data = ec_data + + async def _async_update_data(self): + """Fetch data from EC.""" + try: + await self.ec_data.update() + except (et.ParseError, ec_exc.UnknownStationId) as ex: + raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex + return self.ec_data diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 019dcb1aee5a2..5de1086f98c24 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,95 +1,44 @@ """Support for the Environment Canada radar imagery.""" -import datetime +from __future__ import annotations -from env_canada import ECRadar -import voluptuous as vol +from homeassistant.components.camera import Camera +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from .const import ATTR_OBSERVATION_TIME, DOMAIN -ATTR_UPDATED = "updated" -CONF_ATTRIBUTION = "Data provided by Environment Canada" -CONF_STATION = "station" -CONF_LOOP = "loop" -CONF_PRECIP_TYPE = "precip_type" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["radar_coordinator"] + async_add_entities([ECCamera(coordinator)]) -MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_LOOP, default=True): cv.boolean, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_STATION): cv.matches_regex(r"^C[A-Z]{4}$|^[A-Z]{3}$"), - vol.Inclusive(CONF_LATITUDE, "latlon"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "latlon"): cv.longitude, - vol.Optional(CONF_PRECIP_TYPE): vol.In(["RAIN", "SNOW"]), - } -) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Environment Canada camera.""" - - if config.get(CONF_STATION): - radar_object = ECRadar( - station_id=config[CONF_STATION], precip_type=config.get(CONF_PRECIP_TYPE) - ) - else: - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - radar_object = ECRadar( - coordinates=(lat, lon), precip_type=config.get(CONF_PRECIP_TYPE) - ) - - add_devices( - [ECCamera(radar_object, config.get(CONF_NAME), config[CONF_LOOP])], True - ) - - -class ECCamera(Camera): +class ECCamera(CoordinatorEntity, Camera): """Implementation of an Environment Canada radar camera.""" - def __init__(self, radar_object, camera_name, is_loop): + def __init__(self, coordinator): """Initialize the camera.""" - super().__init__() + super().__init__(coordinator) + Camera.__init__(self) + + self.radar_object = coordinator.ec_data + self._attr_name = f"{coordinator.config_entry.title} Radar" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar" + self._attr_attribution = self.radar_object.metadata["attribution"] + self._attr_entity_registry_enabled_default = False - self.radar_object = radar_object - self.camera_name = camera_name - self.is_loop = is_loop self.content_type = "image/gif" self.image = None - self.timestamp = None + self.observation_time = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" - self.update() - return self.image - - @property - def name(self): - """Return the name of the camera.""" - if self.camera_name is not None: - return self.camera_name - return "Environment Canada Radar" + self.observation_time = self.radar_object.timestamp + return self.radar_object.image @property def extra_state_attributes(self): """Return the state attributes of the device.""" - return {ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_UPDATED: self.timestamp} - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update radar image.""" - if self.is_loop: - self.image = self.radar_object.get_loop() - else: - self.image = self.radar_object.get_latest_frame() - self.timestamp = self.radar_object.timestamp + return {ATTR_OBSERVATION_TIME: self.observation_time} diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py new file mode 100644 index 0000000000000..07b6eac0da035 --- /dev/null +++ b/homeassistant/components/environment_canada/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for Environment Canada integration.""" +import logging +import xml.etree.ElementTree as et + +import aiohttp +from env_canada import ECWeather, ec_exc +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv + +from .const import CONF_LANGUAGE, CONF_STATION, CONF_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(data): + """Validate the user input allows us to connect.""" + lat = data.get(CONF_LATITUDE) + lon = data.get(CONF_LONGITUDE) + station = data.get(CONF_STATION) + lang = data.get(CONF_LANGUAGE).lower() + + if station: + weather_data = ECWeather(station_id=station, language=lang) + else: + weather_data = ECWeather(coordinates=(lat, lon), language=lang) + await weather_data.update() + + if lat is None or lon is None: + lat = weather_data.lat + lon = weather_data.lon + + return { + CONF_TITLE: weather_data.metadata.get("location"), + CONF_STATION: weather_data.station_id, + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + } + + +class EnvironmentCanadaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Environment Canada weather.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(user_input) + except (et.ParseError, vol.MultipleInvalid, ec_exc.UnknownStationId): + errors["base"] = "bad_station_id" + except aiohttp.ClientConnectionError: + errors["base"] = "cannot_connect" + except aiohttp.ClientResponseError as err: + if err.status == 404: + errors["base"] = "bad_station_id" + else: + errors["base"] = "error_response" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + user_input[CONF_STATION] = info[CONF_STATION] + user_input[CONF_LATITUDE] = info[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = info[CONF_LONGITUDE] + + # The combination of station and language are unique for all EC weather reporting + await self.async_set_unique_id( + f"{user_input[CONF_STATION]}-{user_input[CONF_LANGUAGE].lower()}" + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info[CONF_TITLE], data=user_input) + + data_schema = vol.Schema( + { + vol.Optional(CONF_STATION): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Required(CONF_LANGUAGE, default="English"): vol.In( + ["English", "French"] + ), + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py new file mode 100644 index 0000000000000..16f7dc1cf9902 --- /dev/null +++ b/homeassistant/components/environment_canada/const.py @@ -0,0 +1,8 @@ +"""Constants for EC component.""" + +ATTR_OBSERVATION_TIME = "observation_time" +ATTR_STATION = "station" +CONF_LANGUAGE = "language" +CONF_STATION = "station" +CONF_TITLE = "title" +DOMAIN = "environment_canada" diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 62c3e935d69a9..868e62f07c34c 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,8 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.2.5"], - "codeowners": ["@michaeldavie"], + "requirements": ["env_canada==0.5.20"], + "codeowners": ["@gwww", "@michaeldavie"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 0f0fb04fd00cb..630d78469ffcc 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -1,97 +1,64 @@ """Support for the Environment Canada weather service.""" -from datetime import datetime, timedelta import logging import re -from env_canada import ECData import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_LOCATION, - CONF_LATITUDE, - CONF_LONGITUDE, - TEMP_CELSIUS, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import ATTR_LOCATION, TEMP_CELSIUS +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=10) +from .const import ATTR_OBSERVATION_TIME, ATTR_STATION, DOMAIN -ATTR_UPDATED = "updated" -ATTR_STATION = "station" ATTR_TIME = "alert time" -CONF_ATTRIBUTION = "Data provided by Environment Canada" -CONF_STATION = "station" -CONF_LANGUAGE = "language" +_LOGGER = logging.getLogger(__name__) def validate_station(station): """Check that the station ID is well-formed.""" if station is None: - return + return None if not re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station): - raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + raise vol.Invalid('Station ID must be of the form "XX/s0000###"') return station -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_LANGUAGE, default="english"): vol.In(["english", "french"]), - vol.Optional(CONF_STATION): validate_station, - vol.Inclusive(CONF_LATITUDE, "latlon"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "latlon"): cv.longitude, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Environment Canada sensor.""" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] + weather_data = coordinator.ec_data - if config.get(CONF_STATION): - ec_data = ECData( - station_id=config[CONF_STATION], language=config.get(CONF_LANGUAGE) - ) - else: - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - ec_data = ECData(coordinates=(lat, lon), language=config.get(CONF_LANGUAGE)) + sensors = list(weather_data.conditions) + labels = [weather_data.conditions[sensor]["label"] for sensor in sensors] + alerts_list = list(weather_data.alerts) + labels = labels + [weather_data.alerts[sensor]["label"] for sensor in alerts_list] + sensors = sensors + alerts_list - sensor_list = list(ec_data.conditions) + list(ec_data.alerts) - add_entities([ECSensor(sensor_type, ec_data) for sensor_type in sensor_list], True) + async_add_entities( + [ + ECSensor(coordinator, sensor, label) + for sensor, label in zip(sensors, labels) + ], + True, + ) -class ECSensor(SensorEntity): +class ECSensor(CoordinatorEntity, SensorEntity): """Implementation of an Environment Canada sensor.""" - def __init__(self, sensor_type, ec_data): + def __init__(self, coordinator, sensor, label): """Initialize the sensor.""" - self.sensor_type = sensor_type - self.ec_data = ec_data + super().__init__(coordinator) + self.sensor_type = sensor + self.ec_data = coordinator.ec_data - self._unique_id = None - self._name = None - self._state = None + self._attr_attribution = self.ec_data.metadata["attribution"] + self._attr_name = f"{coordinator.config_entry.title} {label}" + self._attr_unique_id = f"{self.ec_data.metadata['location']}-{sensor}" self._attr = None self._unit = None - - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._device_class = None @property def extra_state_attributes(self): @@ -99,56 +66,55 @@ def extra_state_attributes(self): return self._attr @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit - def update(self): - """Update current conditions.""" - self.ec_data.update() - self.ec_data.conditions.update(self.ec_data.alerts) + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class - conditions = self.ec_data.conditions + @property + def native_value(self): + """Update current conditions.""" metadata = self.ec_data.metadata - sensor_data = conditions.get(self.sensor_type) + sensor_data = self.ec_data.conditions.get(self.sensor_type) + if not sensor_data: + sensor_data = self.ec_data.alerts.get(self.sensor_type) - self._unique_id = f"{metadata['location']}-{self.sensor_type}" self._attr = {} - self._name = sensor_data.get("label") value = sensor_data.get("value") if isinstance(value, list): - self._state = " | ".join([str(s.get("title")) for s in value])[:255] + state = " | ".join([str(s.get("title")) for s in value])[:255] self._attr.update( {ATTR_TIME: " | ".join([str(s.get("date")) for s in value])} ) elif self.sensor_type == "tendency": - self._state = str(value).capitalize() - elif value is not None and len(value) > 255: - self._state = value[:255] - _LOGGER.info("Value for %s truncated to 255 characters", self._unique_id) + state = str(value).capitalize() + elif isinstance(value, str) and len(value) > 255: + state = value[:255] + _LOGGER.info( + "Value for %s truncated to 255 characters", self._attr_unique_id + ) else: - self._state = value + state = value - if sensor_data.get("unit") == "C" or self.sensor_type in [ + if sensor_data.get("unit") == "C" or self.sensor_type in ( "wind_chill", "humidex", - ]: + ): self._unit = TEMP_CELSIUS + self._device_class = SensorDeviceClass.TEMPERATURE else: self._unit = sensor_data.get("unit") - timestamp = metadata.get("timestamp") - if timestamp: - updated_utc = datetime.strptime(timestamp, "%Y%m%d%H%M%S").isoformat() - else: - updated_utc = None - self._attr.update( { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_UPDATED: updated_utc, + ATTR_OBSERVATION_TIME: metadata.get("timestamp"), ATTR_LOCATION: metadata.get("location"), ATTR_STATION: metadata.get("station"), } ) + return state diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json new file mode 100644 index 0000000000000..49686cba12325 --- /dev/null +++ b/homeassistant/components/environment_canada/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Environment Canada: weather location and language", + "description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.", + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "station": "Weather station ID", + "language": "Weather information language" + } + } + }, + "error": { + "bad_station_id": "Station ID is invalid, missing, or not found in the station ID database", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "error_response": "Response from Environment Canada in error", + "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/environment_canada/translations/bg.json b/homeassistant/components/environment_canada/translations/bg.json new file mode 100644 index 0000000000000..28c4730e5cdc3 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "too_many_attempts": "\u0412\u0440\u044a\u0437\u043a\u0438\u0442\u0435 \u0441 Environment Canada \u0441\u0430 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438; \u041e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u0441\u043b\u0435\u0434 60 \u0441\u0435\u043a\u0443\u043d\u0434\u0438", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "language": "\u0415\u0437\u0438\u043a \u043d\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0442\u0430 \u0437\u0430 \u0432\u0440\u0435\u043c\u0435\u0442\u043e", + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", + "station": "ID \u043d\u0430 \u043c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u043d\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/ca.json b/homeassistant/components/environment_canada/translations/ca.json new file mode 100644 index 0000000000000..f847b2dc5acf7 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "L'ID d'estaci\u00f3 no \u00e9s v\u00e0lid, no est\u00e0 present o no es troba a la base de dades d'IDs d'estacions", + "cannot_connect": "Ha fallat la connexi\u00f3", + "error_response": "Resposta d'error d'Environment Canada", + "too_many_attempts": "Les connexions a Environment Canada estan limitades; torna-ho a provar d'aqu\u00ed a 60 segons", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "language": "Idioma de la informaci\u00f3 meteorol\u00f2gica", + "latitude": "Latitud", + "longitude": "Longitud", + "station": "ID d'estaci\u00f3 meteorol\u00f2gica" + }, + "description": "Cal especificar un identificador d'estaci\u00f3 o una latitud/longitud. La latitud/longitud que s'utilitza de manera predeterminada s'obt\u00e9 dels valors configurats a la instal\u00b7laci\u00f3 de Home Assistant. Si s'especifiquen coordenades, s'utilitzar\u00e0 l'estaci\u00f3 meteorol\u00f2gica m\u00e9s propera a aquestes coordenades. Si s'utilitza un codi d'estaci\u00f3, ha de ser amb el format: PP/codi, on PP s\u00f3n les dues lletres de prov\u00edncia i el codi \u00e9s l'identificador d'estaci\u00f3. Pots trobar la llista d'IDs d'estaci\u00f3 aqu\u00ed: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. La informaci\u00f3 meteorol\u00f2gica es pot obtenir en angl\u00e8s o franc\u00e8s.", + "title": "Environment Canada: ubicaci\u00f3 meteorol\u00f2gica i idioma" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/cs.json b/homeassistant/components/environment_canada/translations/cs.json new file mode 100644 index 0000000000000..4eb6ccd754ceb --- /dev/null +++ b/homeassistant/components/environment_canada/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "language": "Jazyk informac\u00ed o po\u010das\u00ed", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "station": "ID meteorologick\u00e9 stanice" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/de.json b/homeassistant/components/environment_canada/translations/de.json new file mode 100644 index 0000000000000..c351683007fb1 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Die Stations-ID ist ung\u00fcltig, fehlt oder wurde in der Stations-ID-Datenbank nicht gefunden", + "cannot_connect": "Verbindung fehlgeschlagen", + "error_response": "Fehlerhafte Antwort vom Standort Kanada", + "too_many_attempts": "Verbindungen zum Standort Kanada sind in ihrer Geschwindigkeit begrenzt; versuche es in 60 Sekunden erneut.", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "language": "Sprache der Wetterinformationen", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "station": "ID der Wetterstation" + }, + "description": "Es muss entweder eine Stations-ID oder der Breitengrad/L\u00e4ngengrad angegeben werden. Als Standardwerte f\u00fcr Breitengrad/L\u00e4ngengrad werden die in Ihrer Home Assistant-Installation konfigurierten Werte verwendet. Bei Angabe von Koordinaten wird die den Koordinaten am n\u00e4chsten gelegene Wetterstation verwendet. Wenn ein Stationscode verwendet wird, muss er dem Format entsprechen: PP/Code, wobei PP f\u00fcr die zweistellige Provinz und Code f\u00fcr die Stationskennung steht. Die Liste der Stations-IDs findest du hier: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Die Wetterinformationen k\u00f6nnen entweder in Englisch oder Franz\u00f6sisch abgerufen werden.", + "title": "Standort Kanada: Wetterstandort und Sprache" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/en.json b/homeassistant/components/environment_canada/translations/en.json new file mode 100644 index 0000000000000..94c0b947fa43f --- /dev/null +++ b/homeassistant/components/environment_canada/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Station ID is invalid, missing, or not found in the station ID database", + "cannot_connect": "Failed to connect", + "error_response": "Response from Environment Canada in error", + "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "language": "Weather information language", + "latitude": "Latitude", + "longitude": "Longitude", + "station": "Weather station ID" + }, + "description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.", + "title": "Environment Canada: weather location and language" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/et.json b/homeassistant/components/environment_canada/translations/et.json new file mode 100644 index 0000000000000..af93b06014413 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Jaama ID ei sobi, puudub v\u00f5i seda ei leitud jaamade ID andmebaasist", + "cannot_connect": "\u00dchendamine nurjus", + "error_response": "Kanada keskkonnaameti ekslik vastus", + "too_many_attempts": "\u00dchendus Kanada keskkonnaametiga on piiratud; proovi uuesti 60 sekundi p\u00e4rast", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "language": "Ilmateabe keel", + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad", + "station": "Ilmajaama ID" + }, + "description": "Tuleb m\u00e4\u00e4rata kas jaama ID v\u00f5i laiuskraad/pikkuskraad. Vaikimisi kasutatakse laiuskraadi/pikkuskraadi v\u00e4\u00e4rtusi, mis on konfigureeritud teie Home Assistant'i paigalduses. Koordinaatidele l\u00e4himat ilmajaama kasutatakse koordinaatide m\u00e4\u00e4ramisel. Kui kasutatakse jaama koodi, peab see j\u00e4rgima formaati: PP/kood, kus PP on kahet\u00e4heline provints ja kood on jaama ID. Jaama ID-de nimekiri on leitav siit: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Ilmateavet saab otsida kas inglise v\u00f5i prantsuse keeles.", + "title": "Kanada keskonnaamet: ilmateabe asukoht ja keel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/fr.json b/homeassistant/components/environment_canada/translations/fr.json new file mode 100644 index 0000000000000..d09b1eec09577 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "L'ID de station est invalide, manquant ou introuvable dans la base de donn\u00e9es d'ID de station", + "cannot_connect": "\u00c9chec de connexion", + "error_response": "R\u00e9ponse d'Environnement Canada par erreur", + "too_many_attempts": "Les connexions \u00e0 Environnement Canada sont limit\u00e9es en termes de taux; R\u00e9essayez dans 60 secondes", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "language": "Langue des informations m\u00e9t\u00e9orologiques", + "latitude": "Latitude", + "longitude": "Longitude", + "station": "ID de la station m\u00e9t\u00e9orologique" + }, + "description": "Un ID de station ou une latitude/longitude doit \u00eatre sp\u00e9cifi\u00e9. La latitude/longitude par d\u00e9faut utilis\u00e9e sont les valeurs configur\u00e9es dans votre installation Home Assistant. La station m\u00e9t\u00e9o la plus proche des coordonn\u00e9es sera utilis\u00e9e si vous sp\u00e9cifiez des coordonn\u00e9es. Si un code de station est utilis\u00e9, il doit suivre le format : PP/code, o\u00f9 PP est la province \u00e0 deux lettres et le code est l'ID de la station. La liste des identifiants de station peut \u00eatre trouv\u00e9e ici : https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Les informations m\u00e9t\u00e9orologiques peuvent \u00eatre r\u00e9cup\u00e9r\u00e9es en anglais ou en fran\u00e7ais.", + "title": "Environnement Canada\u00a0: emplacement m\u00e9t\u00e9o et langue" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/he.json b/homeassistant/components/environment_canada/translations/he.json new file mode 100644 index 0000000000000..9cb8e90c9f408 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/hu.json b/homeassistant/components/environment_canada/translations/hu.json new file mode 100644 index 0000000000000..8a920274c1aa6 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Az \u00e1llom\u00e1s azonos\u00edt\u00f3ja \u00e9rv\u00e9nytelen, hi\u00e1nyzik, vagy nem tal\u00e1lhat\u00f3 az \u00e1llom\u00e1s azonos\u00edt\u00f3 adatb\u00e1zisban.", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "error_response": "Az Environment Canada hib\u00e1val v\u00e1laszolt", + "too_many_attempts": "Az Environment Canadahoz a kapcsol\u00f3d\u00e1sok sz\u00e1ma korl\u00e1tozva van; Pr\u00f3b\u00e1lja \u00fajra 60 m\u00e1sodperc m\u00falva", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "language": "Id\u0151j\u00e1r\u00e1si inform\u00e1ci\u00f3k nyelve", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "station": "Id\u0151j\u00e1r\u00e1s \u00e1llom\u00e1s ID-ja" + }, + "description": "Adja meg \u00e1llom\u00e1s ID-t vagy a sz\u00e9less\u00e9gi/hossz\u00fas\u00e1gi fokot. Az alap\u00e9rtelmezett f\u00f6ldrajzi sz\u00e9less\u00e9g/hossz\u00fas\u00e1g a Home Assistant telep\u00edt\u00e9s\u00e9n\u00e9l be\u00e1ll\u00edtott \u00e9rt\u00e9kek. Koordin\u00e1t\u00e1k megad\u00e1sa eset\u00e9n a koordin\u00e1t\u00e1khoz legk\u00f6zelebbi id\u0151j\u00e1r\u00e1si \u00e1llom\u00e1s ker\u00fcl felhaszn\u00e1l\u00e1sra. Ha \u00e1llom\u00e1sk\u00f3dot haszn\u00e1l, annak a k\u00f6vetkez\u0151 form\u00e1tumot kell k\u00f6vetnie: PP/k\u00f3d, ahol PP a k\u00e9tbet\u0171s tartom\u00e1ny, a k\u00f3d pedig az \u00e1llom\u00e1s azonos\u00edt\u00f3ja. Az \u00e1llom\u00e1sazonos\u00edt\u00f3k list\u00e1ja itt tal\u00e1lhat\u00f3: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Az id\u0151j\u00e1r\u00e1si inform\u00e1ci\u00f3k angol vagy francia nyelven k\u00e9rdezhet\u0151k le.", + "title": "Environment Canada: id\u0151j\u00e1r\u00e1s helysz\u00edne \u00e9s nyelv" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/id.json b/homeassistant/components/environment_canada/translations/id.json new file mode 100644 index 0000000000000..df3f087332eef --- /dev/null +++ b/homeassistant/components/environment_canada/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "ID Stasiun tidak valid, tidak ada, atau tidak ditemukan di basis data ID stasiun", + "cannot_connect": "Gagal terhubung", + "error_response": "Kesalahan balasan dari Environment Canada", + "too_many_attempts": "Koneksi ke Environment Canada dibatasi; Coba lagi dalam 60 detik", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "language": "Bahasa informasi cuaca", + "latitude": "Lintang", + "longitude": "Bujur", + "station": "ID stasiun cuaca" + }, + "description": "Salah satu dari ID stasiun atau lintang/bujur harus ditentukan. Lintang/bujur default yang digunakan adalah nilai yang dikonfigurasi dalam instalasi Home Assistant Anda. Stasiun cuaca terdekat dengan koordinat akan digunakan jika koordinat ditentukan. Jika menggunakan kode stasiun, formatnya harus berupa: PP/kode, di mana PP adalah provinsi dua huruf dan kode adalah ID stasiun. Daftar ID stasiun dapat ditemukan di sini: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Informasi cuaca dapat diperoleh dalam bahasa Inggris atau Prancis.", + "title": "Environment Canada: lokasi cuaca dan bahasa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/it.json b/homeassistant/components/environment_canada/translations/it.json new file mode 100644 index 0000000000000..f599eae7fe290 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "L'ID stazione non \u00e8 valido, mancante o non \u00e8 presente nel database degli ID stazione", + "cannot_connect": "Impossibile connettersi", + "error_response": "Risposta di Environment Canada in errore", + "too_many_attempts": "I collegamenti con Environment Canada sono limitati; Riprova tra 60 secondi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "language": "Lingua delle informazioni meteo", + "latitude": "Latitudine", + "longitude": "Logitudine", + "station": "ID stazione meteo" + }, + "description": "\u00c8 necessario specificare un ID stazione o latitudine/longitudine. La latitudine/longitudine predefinita utilizzata sono i valori configurati nell'installazione di Home Assistant. Se si specificano le coordinate, verr\u00e0 utilizzata la stazione meteorologica pi\u00f9 vicina alle coordinate. Se viene utilizzato un codice di stazione, deve seguire il formato: PP/codice, dove PP \u00e8 la provincia in due lettere e codice \u00e8 l'identificativo della stazione. L'elenco degli ID delle stazioni \u00e8 disponibile qui: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Le informazioni meteorologiche possono essere recuperate in inglese o francese.", + "title": "Environment Canada: posizione meteo e lingua" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/ja.json b/homeassistant/components/environment_canada/translations/ja.json new file mode 100644 index 0000000000000..e9057e7a48b0a --- /dev/null +++ b/homeassistant/components/environment_canada/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID\u304c\u7121\u52b9\u3001\u6b20\u843d\u3057\u3066\u3044\u308b\u3001\u307e\u305f\u306f\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID \u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u5185\u3067\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error_response": "\u30ab\u30ca\u30c0\u74b0\u5883\u304b\u3089\u306e\u5fdc\u7b54\u30a8\u30e9\u30fc", + "too_many_attempts": "\u30ab\u30ca\u30c0\u74b0\u5883\u7701\u3078\u306e\u63a5\u7d9a\u306f\u30ec\u30fc\u30c8\u5236\u9650\u3055\u308c\u3066\u3044\u307e\u3059\u300260\u79d2\u5f8c\u306b\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "language": "\u6c17\u8c61\u60c5\u5831\u306e\u8a00\u8a9e", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "station": "\u30a6\u30a7\u30b6\u30fc\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID" + }, + "description": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID\u307e\u305f\u306f\u7def\u5ea6/\u7d4c\u5ea6\u306e\u3044\u305a\u308c\u304b\u3092\u6307\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u4f7f\u7528\u3055\u308c\u308b\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u7def\u5ea6/\u7d4c\u5ea6\u306f\u3001Home Assistant\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3067\u69cb\u6210\u3055\u308c\u305f\u5024\u3067\u3059\u3002\u5ea7\u6a19\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u306f\u3001\u5ea7\u6a19\u306b\u6700\u3082\u8fd1\u3044\u6c17\u8c61\u89b3\u6e2c\u6240\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002\u89b3\u6e2c\u6240\u30b3\u30fc\u30c9\u3092\u4f7f\u7528\u3059\u308b\u5834\u5408\u306f\u3001PP/code\u306e\u5f62\u5f0f\u306b\u5f93\u3046\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u3053\u3053\u3067\u3001PP\u306f2\u6587\u5b57\u306e\u5dde\u3001code\u306f\u89b3\u6e2c\u6240ID\u3067\u3059\u3002\u89b3\u6e2c\u6240ID\u306e\u30ea\u30b9\u30c8\u306f\u3001https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv \u306b\u3042\u308a\u307e\u3059\u3002\u6c17\u8c61\u60c5\u5831\u306f\u82f1\u8a9e\u307e\u305f\u306f\u30d5\u30e9\u30f3\u30b9\u8a9e\u3067\u53d6\u5f97\u3067\u304d\u307e\u3059\u3002", + "title": "\u30ab\u30ca\u30c0\u74b0\u5883\u7701: \u5929\u6c17\u306e\u5834\u6240\u3068\u8a00\u8a9e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/nl.json b/homeassistant/components/environment_canada/translations/nl.json new file mode 100644 index 0000000000000..65d9aa9c90631 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Station-ID is ongeldig, ontbreekt of is niet gevonden in de database met stations-ID's", + "cannot_connect": "Kan geen verbinding maken", + "error_response": "Antwoord van Environment Canada is fout", + "too_many_attempts": "Verbindingen met Environment Canada zijn gelimiteerd; Probeer opnieuw binnen 60 seconden", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "language": "Taal voor weerinformatie", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "station": "Weerstation-ID" + }, + "description": "Er moet een station-ID of een lengte-/breedtegraad worden opgegeven. De standaard gebruikte breedtegraad/lengtegraad zijn de waarden die in uw Home Assistant installatie zijn geconfigureerd. Als u co\u00f6rdinaten opgeeft, wordt het weerstation gebruikt dat zich het dichtst bij de co\u00f6rdinaten bevindt. Als een stationcode wordt gebruikt, moet deze het volgende formaat hebben PP/code, waarbij PP de provincie is met twee letters en code de ID van het station. De lijst van station ID's kan hier worden gevonden: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weerinformatie kan worden opgevraagd in het Engels of Frans.", + "title": "Omgeving Canada: weerlocatie en taal" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/no.json b/homeassistant/components/environment_canada/translations/no.json new file mode 100644 index 0000000000000..8d0fb1f201bc8 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Stasjons -ID er ugyldig, mangler eller finnes ikke i stasjons -ID -databasen", + "cannot_connect": "Tilkobling mislyktes", + "error_response": "Svar fra Environment Canada feilaktig", + "too_many_attempts": "Tilkoblinger til milj\u00f8 Canada er takstbegrenset; Pr\u00f8v igjen om 60 sekunder", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "language": "Spr\u00e5k for v\u00e6rinformasjon", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "station": "Id for v\u00e6rstasjon" + }, + "description": "Enten en stasjons -ID eller breddegrad/lengdegrad m\u00e5 spesifiseres. Standard breddegrad/lengdegrad som brukes er verdiene som er konfigurert i Home Assistant -installasjonen. Den n\u00e6rmeste v\u00e6rstasjonen til koordinatene vil bli brukt hvis du angir koordinater. Hvis en stasjonskode brukes, m\u00e5 den f\u00f8lge formatet: PP/kode, hvor PP er provinsen p\u00e5 to bokstaver og koden er stasjons-ID. Listen over stasjons -ID -er finner du her: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. V\u00e6rinformasjon kan hentes p\u00e5 enten engelsk eller fransk.", + "title": "Milj\u00f8 Canada: v\u00e6rsted og spr\u00e5k" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/pl.json b/homeassistant/components/environment_canada/translations/pl.json new file mode 100644 index 0000000000000..4f4611a80ca19 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Identyfikator stacji jest nieprawid\u0142owy, brakuje go lub nie mo\u017cna go znale\u017a\u0107 w bazie danych identyfikator\u00f3w stacji", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "error_response": "B\u0142\u0119dna odpowied\u017a z Environment Canada", + "too_many_attempts": "Po\u0142\u0105czenia z Environment Canada s\u0105 ograniczone; spr\u00f3buj ponownie za 60 sekund", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "language": "J\u0119zyk informacji pogodowych", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "station": "Identyfikator stacji pogodowej" + }, + "description": "Nale\u017cy poda\u0107 identyfikator stacji lub szeroko\u015b\u0107/d\u0142ugo\u015b\u0107 geograficzn\u0105. Domy\u015blna szeroko\u015b\u0107/d\u0142ugo\u015b\u0107 geograficzna to warto\u015bci skonfigurowane w instalacji Home Assistant. Zostanie u\u017cyta najbli\u017csza stacja pogodowa dla tych wsp\u00f3\u0142rz\u0119dnych. Je\u015bli u\u017cywany jest kod stacji, musi on mie\u0107 format: PP/kod, gdzie PP to dwuliterowa prowincja, a kod to identyfikator stacji. List\u0119 identyfikator\u00f3w stacji mo\u017cna znale\u017a\u0107 tutaj: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Informacje o pogodzie mo\u017cna pobra\u0107 w j\u0119zyku angielskim lub francuskim.", + "title": "Environment Canada: lokalizacja pogody i j\u0119zyk" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/ru.json b/homeassistant/components/environment_canada/translations/ru.json new file mode 100644 index 0000000000000..26c0108ed3a7d --- /dev/null +++ b/homeassistant/components/environment_canada/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d, \u043b\u0438\u0431\u043e \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "error_response": "\u041e\u0442\u0432\u0435\u0442 \u043e\u0442 Environment Canada \u043f\u043e \u043e\u0448\u0438\u0431\u043a\u0435.", + "too_many_attempts": "\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a Environment Canada \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043e. \u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u0447\u0435\u0440\u0435\u0437 60 \u0441\u0435\u043a\u0443\u043d\u0434.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "language": "\u042f\u0437\u044b\u043a, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0433\u043e\u0434\u0435", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "station": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0438" + }, + "description": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u043a\u0430\u0437\u0430\u0442\u044c \u043a\u0430\u043a \u043c\u0438\u043d\u0438\u043c\u0443\u043c \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0438, \u043b\u0438\u0431\u043e \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f. \u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0448\u0438\u0440\u043e\u0442\u044b \u0438 \u0434\u043e\u043b\u0433\u043e\u0442\u044b, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0412\u0430\u0448\u0435\u0433\u043e Home Assistant. \u041f\u0440\u0438 \u0443\u043a\u0430\u0437\u0430\u043d\u0438\u0438 \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0431\u043b\u0438\u0436\u0430\u0439\u0448\u0430\u044f \u043a \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0430\u043c \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u044f. \u0415\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0438, \u043e\u043d \u0434\u043e\u043b\u0436\u0435\u043d \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u043e\u0432\u0430\u0442\u044c \u0444\u043e\u0440\u043c\u0430\u0442\u0443: PP/\u043a\u043e\u0434, \u0433\u0434\u0435 PP \u2014 \u044d\u0442\u043e \u0438\u043d\u0434\u0435\u043a\u0441 \u043f\u0440\u043e\u0432\u0438\u043d\u0446\u0438\u0438, \u0430 \u043a\u043e\u0434 \u2014 \u044d\u0442\u043e \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0438. \u0421\u043f\u0438\u0441\u043e\u043a \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u0432 \u0441\u0442\u0430\u043d\u0446\u0438\u0439 \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0439\u0442\u0438 \u0437\u0434\u0435\u0441\u044c: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv.\n\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e \u043f\u043e\u0433\u043e\u0434\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u0430 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u043e\u043c \u0438\u043b\u0438 \u0444\u0440\u0430\u043d\u0446\u0443\u0437\u0441\u043a\u043e\u043c \u044f\u0437\u044b\u043a\u0430\u0445.", + "title": "Environment Canada: \u043f\u043e\u0433\u043e\u0434\u0430, \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0438 \u044f\u0437\u044b\u043a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/sl.json b/homeassistant/components/environment_canada/translations/sl.json new file mode 100644 index 0000000000000..5bbbcfbf23e95 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/sl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "language": "Jezik vremenskih informacij", + "station": "ID vremenske postaje" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/tr.json b/homeassistant/components/environment_canada/translations/tr.json new file mode 100644 index 0000000000000..a12e6add66976 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "\u0130stasyon Kimli\u011fi ge\u00e7ersiz, eksik veya istasyon kimli\u011fi veritaban\u0131nda bulunamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "error_response": "Environment Canada'dan hatal\u0131 yan\u0131t", + "too_many_attempts": "Environment Kanada'ya ba\u011flant\u0131lar s\u0131n\u0131rl\u0131d\u0131r; 60 saniye sonra tekrar deneyin", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "language": "Hava durumu bilgisi dili", + "latitude": "Enlem", + "longitude": "Boylam", + "station": "Hava istasyonu ID" + }, + "description": "Bir istasyon kimli\u011fi veya enlem/boylam belirtilmelidir. Kullan\u0131lan varsay\u0131lan enlem/boylam, Home Assistant kurulumunuzda yap\u0131land\u0131r\u0131lan de\u011ferlerdir. Koordinatlar belirtilirse, koordinatlara en yak\u0131n meteoroloji istasyonu kullan\u0131lacakt\u0131r. Bir istasyon kodu kullan\u0131l\u0131yorsa, \u015fu bi\u00e7imde olmal\u0131d\u0131r: PP/kod, burada PP iki harfli ildir ve kod istasyon kimli\u011fidir. \u0130stasyon kimliklerinin listesi burada bulunabilir: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Hava durumu bilgileri \u0130ngilizce veya Frans\u0131zca olarak al\u0131nabilir.", + "title": "Environment Canada: hava konumu durumu ve dili" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/zh-Hant.json b/homeassistant/components/environment_canada/translations/zh-Hant.json new file mode 100644 index 0000000000000..59fe99e8ead77 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "\u6c23\u8c61\u7ad9 ID \u7121\u6548\u3001\u907a\u5931\u6216\u8cc7\u6599\u5eab\u4e2d\u627e\u4e0d\u5230\u8a72 ID", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "error_response": "\u4f86\u81ea Environment Canada \u56de\u8986\u932f\u8aa4", + "too_many_attempts": "\u8207 Environment Canada \u9023\u7dda\u6b21\u6578\u70ba\u6709\u9650\u6b21\u6578\uff1b\u8acb\u65bc 60 \u79d2\u5f8c\u518d\u8a66\u4e00\u6b21", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "language": "\u6c23\u8c61\u8cc7\u8a0a\u8a9e\u8a00", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "station": "\u6c23\u8c61\u7ad9 ID" + }, + "description": "\u5fc5\u9808\u6307\u5b9a\u6c23\u8c61\u7ad9 ID \u6216\u7d93\u5ea6/\u7def\u5ea6\u3002\u5c07\u4f7f\u7528 Home Assistant \u5b89\u88dd\u4e2d\u8a2d\u5b9a\u4e4b\u7d93\u5ea6/\u7def\u5ea6\u70ba\u9810\u8a2d\u503c\uff0c\u4e26\u4f7f\u7528\u6700\u9760\u8fd1\u7684\u6c23\u8c61\u7ad9\u8cc7\u6599\u3002\u5047\u5982\u4f7f\u7528\u6c23\u8c61\u7ad9\u4ee3\u78bc\u5247\u5fc5\u9808\u8ddf\u96a8\u4ee5\u4e0b\u683c\u5f0f\uff1aPP/\u4ee3\u78bc\uff0cPP \u70ba\u5169\u4f4d\u5b57\u6bcd\u8868\u793a\u7701/\u5dde\u3001\u800c\u4ee3\u78bc\u5247\u70ba\u6c23\u8c61\u7ad9 ID\u3002\u53ef\u4ee5\u65bc\u6b64\u8655\u627e\u5230\u6c23\u8c61\u7ad9 ID \u5217\u8868\uff1ahttps://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv\u3002\u6c23\u8c61\u8cc7\u8a0a\u5247\u53ef\u8a2d\u5b9a\u70ba\u82f1\u6587\u6216\u6cd5\u6587\u3002", + "title": "Environment Canada\uff1a\u6c23\u8c61\u7ad9\u4f4d\u7f6e\u8207\u8a9e\u8a00" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 9abbc33bc9337..3bd57163abc87 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -1,8 +1,9 @@ """Platform for retrieving meteorological data from Environment Canada.""" +from __future__ import annotations + import datetime import re -from env_canada import ECData import voluptuous as vol from homeassistant.components.weather import ( @@ -23,37 +24,24 @@ ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, - PLATFORM_SCHEMA, WeatherEntity, ) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt -CONF_FORECAST = "forecast" -CONF_ATTRIBUTION = "Data provided by Environment Canada" -CONF_STATION = "station" +from .const import DOMAIN def validate_station(station): """Check that the station ID is well-formed.""" if station is None: - return + return None if not re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station): - raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + raise vol.Invalid('Station ID must be of the form "XX/s0000###"') return station -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_STATION): validate_station, - vol.Inclusive(CONF_LATITUDE, "latlon"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "latlon"): cv.longitude, - vol.Optional(CONF_FORECAST, default="daily"): vol.In(["daily", "hourly"]), - } -) - # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ # docs/current_conditions_icon_code_descriptions_e.csv ICON_CONDITION_MAP = { @@ -72,45 +60,36 @@ def validate_station(station): } -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Environment Canada weather.""" - if config.get(CONF_STATION): - ec_data = ECData(station_id=config[CONF_STATION]) - else: - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - ec_data = ECData(coordinates=(lat, lon)) - - add_devices([ECWeather(ec_data, config)]) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] + async_add_entities([ECWeather(coordinator, False), ECWeather(coordinator, True)]) -class ECWeather(WeatherEntity): +class ECWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" - def __init__(self, ec_data, config): + def __init__(self, coordinator, hourly): """Initialize Environment Canada weather.""" - self.ec_data = ec_data - self.platform_name = config.get(CONF_NAME) - self.forecast_type = config[CONF_FORECAST] - - @property - def attribution(self): - """Return the attribution.""" - return CONF_ATTRIBUTION - - @property - def name(self): - """Return the name of the weather entity.""" - if self.platform_name: - return self.platform_name - return self.ec_data.metadata.get("location") + super().__init__(coordinator) + self.ec_data = coordinator.ec_data + self._attr_attribution = self.ec_data.metadata["attribution"] + self._attr_name = ( + f"{coordinator.config_entry.title}{' Hourly' if hourly else ''}" + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}{'-hourly' if hourly else '-daily'}" + ) + self._hourly = hourly @property def temperature(self): """Return the temperature.""" if self.ec_data.conditions.get("temperature", {}).get("value"): return float(self.ec_data.conditions["temperature"]["value"]) - if self.ec_data.hourly_forecasts[0].get("temperature"): + if self.ec_data.hourly_forecasts and self.ec_data.hourly_forecasts[0].get( + "temperature" + ): return float(self.ec_data.hourly_forecasts[0]["temperature"]) return None @@ -161,7 +140,9 @@ def condition(self): if self.ec_data.conditions.get("icon_code", {}).get("value"): icon_code = self.ec_data.conditions["icon_code"]["value"] - elif self.ec_data.hourly_forecasts[0].get("icon_code"): + elif self.ec_data.hourly_forecasts and self.ec_data.hourly_forecasts[0].get( + "icon_code" + ): icon_code = self.ec_data.hourly_forecasts[0]["icon_code"] if icon_code: @@ -171,19 +152,16 @@ def condition(self): @property def forecast(self): """Return the forecast array.""" - return get_forecast(self.ec_data, self.forecast_type) + return get_forecast(self.ec_data, self._hourly) - def update(self): - """Get the latest data from Environment Canada.""" - self.ec_data.update() - -def get_forecast(ec_data, forecast_type): +def get_forecast(ec_data, hourly): """Build the forecast array.""" forecast_array = [] - if forecast_type == "daily": - half_days = ec_data.daily_forecasts + if not hourly: + if not (half_days := ec_data.daily_forecasts): + return None today = { ATTR_FORECAST_TIME: dt.now().isoformat(), @@ -202,16 +180,17 @@ def get_forecast(ec_data, forecast_type): ATTR_FORECAST_TEMP_LOW: int(half_days[1]["temperature"]), } ) + half_days = half_days[2:] else: today.update( { + ATTR_FORECAST_TEMP: None, ATTR_FORECAST_TEMP_LOW: int(half_days[0]["temperature"]), - ATTR_FORECAST_TEMP: int(half_days[1]["temperature"]), } ) + half_days = half_days[1:] forecast_array.append(today) - half_days = half_days[2:] for day, high, low in zip(range(1, 6), range(0, 9, 2), range(1, 10, 2)): forecast_array.append( @@ -230,20 +209,17 @@ def get_forecast(ec_data, forecast_type): } ) - elif forecast_type == "hourly": - hours = ec_data.hourly_forecasts - for hour in range(0, 24): + else: + for hour in ec_data.hourly_forecasts: forecast_array.append( { - ATTR_FORECAST_TIME: dt.as_local( - datetime.datetime.strptime(hours[hour]["period"], "%Y%m%d%H%M") - ).isoformat(), - ATTR_FORECAST_TEMP: int(hours[hour]["temperature"]), + ATTR_FORECAST_TIME: hour["period"], + ATTR_FORECAST_TEMP: int(hour["temperature"]), ATTR_FORECAST_CONDITION: icon_code_to_condition( - int(hours[hour]["icon_code"]) + int(hour["icon_code"]) ), ATTR_FORECAST_PRECIPITATION_PROBABILITY: int( - hours[hour]["precip_probability"] + hour["precip_probability"] ), } ) diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 137d6aee853fd..ac51d7310ca8e 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -1,17 +1,24 @@ """Support for Enviro pHAT sensors.""" +from __future__ import annotations + from datetime import timedelta import importlib import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_DISPLAY_OPTIONS, CONF_NAME, + ELECTRIC_POTENTIAL_VOLT, PRESSURE_HPA, TEMP_CELSIUS, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -23,30 +30,103 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SENSOR_TYPES = { - "light": ["light", " ", "mdi:weather-sunny"], - "light_red": ["light_red", " ", "mdi:invert-colors"], - "light_green": ["light_green", " ", "mdi:invert-colors"], - "light_blue": ["light_blue", " ", "mdi:invert-colors"], - "accelerometer_x": ["accelerometer_x", "G", "mdi:earth"], - "accelerometer_y": ["accelerometer_y", "G", "mdi:earth"], - "accelerometer_z": ["accelerometer_z", "G", "mdi:earth"], - "magnetometer_x": ["magnetometer_x", " ", "mdi:magnet"], - "magnetometer_y": ["magnetometer_y", " ", "mdi:magnet"], - "magnetometer_z": ["magnetometer_z", " ", "mdi:magnet"], - "temperature": ["temperature", TEMP_CELSIUS, "mdi:thermometer"], - "pressure": ["pressure", PRESSURE_HPA, "mdi:gauge"], - "voltage_0": ["voltage_0", VOLT, "mdi:flash"], - "voltage_1": ["voltage_1", VOLT, "mdi:flash"], - "voltage_2": ["voltage_2", VOLT, "mdi:flash"], - "voltage_3": ["voltage_3", VOLT, "mdi:flash"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="light", + name="light", + icon="mdi:weather-sunny", + ), + SensorEntityDescription( + key="light_red", + name="light_red", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="light_green", + name="light_green", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="light_blue", + name="light_blue", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="accelerometer_x", + name="accelerometer_x", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="accelerometer_y", + name="accelerometer_y", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="accelerometer_z", + name="accelerometer_z", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="magnetometer_x", + name="magnetometer_x", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="magnetometer_y", + name="magnetometer_y", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="magnetometer_z", + name="magnetometer_z", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="pressure", + name="pressure", + native_unit_of_measurement=PRESSURE_HPA, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="voltage_0", + name="voltage_0", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_1", + name="voltage_1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_2", + name="voltage_2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_3", + name="voltage_3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [ - vol.In(SENSOR_TYPES) - ], + vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_KEYS): [vol.In(SENSOR_KEYS)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_USE_LEDS, default=False): cv.boolean, } @@ -63,80 +143,60 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = EnvirophatData(envirophat, config.get(CONF_USE_LEDS)) - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(EnvirophatSensor(data, variable)) - - add_entities(dev, True) + display_options = config[CONF_DISPLAY_OPTIONS] + entities = [ + EnvirophatSensor(data, description) + for description in SENSOR_TYPES + if description.key in display_options + ] + add_entities(entities, True) class EnvirophatSensor(SensorEntity): """Representation of an Enviro pHAT sensor.""" - def __init__(self, data, sensor_types): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = SENSOR_TYPES[sensor_types][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] - self.type = sensor_types - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement def update(self): """Get the latest data and updates the states.""" self.data.update() - if self.type == "light": - self._state = self.data.light - if self.type == "light_red": - self._state = self.data.light_red - if self.type == "light_green": - self._state = self.data.light_green - if self.type == "light_blue": - self._state = self.data.light_blue - if self.type == "accelerometer_x": - self._state = self.data.accelerometer_x - if self.type == "accelerometer_y": - self._state = self.data.accelerometer_y - if self.type == "accelerometer_z": - self._state = self.data.accelerometer_z - if self.type == "magnetometer_x": - self._state = self.data.magnetometer_x - if self.type == "magnetometer_y": - self._state = self.data.magnetometer_y - if self.type == "magnetometer_z": - self._state = self.data.magnetometer_z - if self.type == "temperature": - self._state = self.data.temperature - if self.type == "pressure": - self._state = self.data.pressure - if self.type == "voltage_0": - self._state = self.data.voltage_0 - if self.type == "voltage_1": - self._state = self.data.voltage_1 - if self.type == "voltage_2": - self._state = self.data.voltage_2 - if self.type == "voltage_3": - self._state = self.data.voltage_3 + sensor_type = self.entity_description.key + if sensor_type == "light": + self._attr_native_value = self.data.light + elif sensor_type == "light_red": + self._attr_native_value = self.data.light_red + elif sensor_type == "light_green": + self._attr_native_value = self.data.light_green + elif sensor_type == "light_blue": + self._attr_native_value = self.data.light_blue + elif sensor_type == "accelerometer_x": + self._attr_native_value = self.data.accelerometer_x + elif sensor_type == "accelerometer_y": + self._attr_native_value = self.data.accelerometer_y + elif sensor_type == "accelerometer_z": + self._attr_native_value = self.data.accelerometer_z + elif sensor_type == "magnetometer_x": + self._attr_native_value = self.data.magnetometer_x + elif sensor_type == "magnetometer_y": + self._attr_native_value = self.data.magnetometer_y + elif sensor_type == "magnetometer_z": + self._attr_native_value = self.data.magnetometer_z + elif sensor_type == "temperature": + self._attr_native_value = self.data.temperature + elif sensor_type == "pressure": + self._attr_native_value = self.data.pressure + elif sensor_type == "voltage_0": + self._attr_native_value = self.data.voltage_0 + elif sensor_type == "voltage_1": + self._attr_native_value = self.data.voltage_1 + elif sensor_type == "voltage_2": + self._attr_native_value = self.data.voltage_2 + elif sensor_type == "voltage_3": + self._attr_native_value = self.data.voltage_3 class EnvirophatData: diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 75d4bff3dd183..d5a8b39e8f93d 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -198,8 +198,7 @@ async def handle_custom_function(call): _LOGGER.info("Start envisalink") controller.start() - result = await sync_connect - if not result: + if not await sync_connect: return False # Load sub-components for Envisalink diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index 6fd7f32c6fe66..88aa7fa988cb2 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -61,7 +61,7 @@ def icon(self): return self._icon @property - def state(self): + def native_value(self): """Return the overall state.""" return self._info["status"]["alpha"] diff --git a/homeassistant/components/envisalink/services.yaml b/homeassistant/components/envisalink/services.yaml index e9229ad838da4..b15a3b94e014e 100644 --- a/homeassistant/components/envisalink/services.yaml +++ b/homeassistant/components/envisalink/services.yaml @@ -1,25 +1,45 @@ # Describes the format for available Envisalink services. alarm_keypress: + name: Alarm keypress description: Send custom keypresses to the alarm. fields: entity_id: + name: Entity description: Name of the alarm control panel to trigger. - example: "alarm_control_panel.downstairs" + required: true + selector: + entity: + integration: envisalink + domain: alarm_control_panel keypress: + name: Keypress description: "String to send to the alarm panel (1-6 characters)." + required: true example: "*71" + selector: + text: invoke_custom_function: + name: Invoke custom function description: > Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's "code" parameter for this to work. fields: partition: + name: Partition description: > The alarm panel partition to trigger the PGM output on. Typically this is just "1". + required: true example: "1" + selector: + text: pgm: - description: The PGM number to trigger on the alarm panel. This will be 1-4. - example: "2" + name: PGM + description: The PGM number to trigger on the alarm panel. + required: true + selector: + number: + min: 1 + max: 4 diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 787677a66054e..022c91c96f398 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -161,8 +161,7 @@ def turn_aux_heat_off(self): def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return if self._hot_water: diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py index 1982731b9efad..9710cb8d96a27 100644 --- a/homeassistant/components/epson/__init__.py +++ b/homeassistant/components/epson/__init__.py @@ -7,16 +7,15 @@ STATE_UNAVAILABLE as EPSON_STATE_UNAVAILABLE, ) -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_PLATFORM from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, HTTP from .exceptions import CannotConnect, PoweredOff -PLATFORMS = [MEDIA_PLAYER_PLATFORM] +PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) @@ -41,7 +40,7 @@ async def validate_projector( return epson_proj -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up epson from a config entry.""" projector = await validate_projector( hass=hass, @@ -56,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index 5203cdbe9e08e..b1ac34b10990e 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -25,30 +25,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - for entry in self._async_current_entries(include_ignore=True): - if import_config[CONF_HOST] == entry.data[CONF_HOST]: - return self.async_abort(reason="already_configured") - try: - projector = await validate_projector( - hass=self.hass, - host=import_config[CONF_HOST], - check_power=True, - check_powered_on=False, - ) - except CannotConnect: - _LOGGER.warning("Cannot connect to projector") - return self.async_abort(reason="cannot_connect") - - serial_no = await projector.get_serial_number() - await self.async_set_unique_id(serial_no) - self._abort_if_unique_id_configured() - import_config.pop(CONF_PORT, None) - return self.async_create_entry( - title=import_config.pop(CONF_NAME), data=import_config - ) - async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/epson/const.py b/homeassistant/components/epson/const.py index 9b1ad0a8f5f68..06ef9f25e350f 100644 --- a/homeassistant/components/epson/const.py +++ b/homeassistant/components/epson/const.py @@ -4,5 +4,4 @@ SERVICE_SELECT_CMODE = "select_cmode" ATTR_CMODE = "cmode" -DEFAULT_NAME = "EPSON Projector" HTTP = "http" diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 92a43330d6902..6abeb3b0ba6a2 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -1,4 +1,6 @@ """Support for Epson projector.""" +from __future__ import annotations + import logging from epson_projector.const import ( @@ -26,7 +28,7 @@ ) import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, @@ -36,13 +38,13 @@ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry -from .const import ATTR_CMODE, DEFAULT_NAME, DOMAIN, SERVICE_SELECT_CMODE +from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE _LOGGER = logging.getLogger(__name__) @@ -56,14 +58,6 @@ | SUPPORT_PREVIOUS_TRACK ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, - } -) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Epson projector from a config entry.""" @@ -85,19 +79,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Epson projector.""" - _LOGGER.warning( - "Loading Espon projector via platform setup is deprecated; " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class EpsonProjectorMediaPlayer(MediaPlayerEntity): """Representation of Epson Projector Device.""" @@ -119,8 +100,7 @@ async def set_unique_id(self): _LOGGER.debug("Setting unique_id for projector") if self._unique_id: return False - uid = await self._projector.get_serial_number() - if uid: + if uid := await self._projector.get_serial_number(): self.hass.config_entries.async_update_entry(self._entry, unique_id=uid) registry = async_get_entity_registry(self.hass) old_entity_id = registry.async_get_entity_id( @@ -159,17 +139,17 @@ async def async_update(self): self._state = STATE_OFF @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Get attributes about the device.""" if not self._unique_id: return None - return { - "identifiers": {(DOMAIN, self._unique_id)}, - "manufacturer": "Epson", - "name": "Epson projector", - "model": "Epson", - "via_hub": (DOMAIN, self._unique_id), - } + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="Epson", + model="Epson", + name="Epson projector", + via_device=(DOMAIN, self._unique_id), + ) @property def name(self): diff --git a/homeassistant/components/epson/services.yaml b/homeassistant/components/epson/services.yaml index a463cd355124e..37add1bc20202 100644 --- a/homeassistant/components/epson/services.yaml +++ b/homeassistant/components/epson/services.yaml @@ -1,9 +1,15 @@ select_cmode: + name: Select color mode description: Select Color mode of Epson projector + target: + entity: + integration: epson + domain: media_player fields: - entity_id: - description: Name of projector - example: "media_player.epson_projector" cmode: + name: Color mode description: Name of Cmode + required: true example: "cinema" + selector: + text: diff --git a/homeassistant/components/epson/translations/ca.json b/homeassistant/components/epson/translations/ca.json index 51fbbe1e273d8..eae6b1329d587 100644 --- a/homeassistant/components/epson/translations/ca.json +++ b/homeassistant/components/epson/translations/ca.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "Amfitri\u00f3", - "name": "Nom", - "port": "Port" + "name": "Nom" } } } diff --git a/homeassistant/components/epson/translations/cs.json b/homeassistant/components/epson/translations/cs.json index 31b0e41118c1d..7a27355056b3d 100644 --- a/homeassistant/components/epson/translations/cs.json +++ b/homeassistant/components/epson/translations/cs.json @@ -7,8 +7,7 @@ "user": { "data": { "host": "Hostitel", - "name": "Jm\u00e9no", - "port": "Port" + "name": "Jm\u00e9no" } } } diff --git a/homeassistant/components/epson/translations/de.json b/homeassistant/components/epson/translations/de.json index a91e3831cdb39..2d53861fb7581 100644 --- a/homeassistant/components/epson/translations/de.json +++ b/homeassistant/components/epson/translations/de.json @@ -1,14 +1,14 @@ { "config": { "error": { - "cannot_connect": "Verbindung fehlgeschlagen" + "cannot_connect": "Verbindung fehlgeschlagen", + "powered_off": "Ist der Projektor eingeschaltet? Du musst den Projektor f\u00fcr die Erstkonfiguration einschalten." }, "step": { "user": { "data": { "host": "Host", - "name": "Name", - "port": "Port" + "name": "Name" } } } diff --git a/homeassistant/components/epson/translations/en.json b/homeassistant/components/epson/translations/en.json index 2c477f65de460..931bbcf557e25 100644 --- a/homeassistant/components/epson/translations/en.json +++ b/homeassistant/components/epson/translations/en.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "Host", - "name": "Name", - "port": "Port" + "name": "Name" } } } diff --git a/homeassistant/components/epson/translations/es-419.json b/homeassistant/components/epson/translations/es-419.json new file mode 100644 index 0000000000000..230dada00f794 --- /dev/null +++ b/homeassistant/components/epson/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "powered_off": "\u00bfEst\u00e1 encendido el proyector? Debe encender el proyector para la configuraci\u00f3n inicial." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/es.json b/homeassistant/components/epson/translations/es.json index e1d40ef981b66..972251b17d5b4 100644 --- a/homeassistant/components/epson/translations/es.json +++ b/homeassistant/components/epson/translations/es.json @@ -1,14 +1,14 @@ { "config": { "error": { - "cannot_connect": "No se pudo conectar" + "cannot_connect": "No se pudo conectar", + "powered_off": "\u00bfEst\u00e1 encendido el proyector? Debes encender el proyector para la configuraci\u00f3n inicial." }, "step": { "user": { "data": { "host": "Host", - "name": "Nombre", - "port": "Puerto" + "name": "Nombre" } } } diff --git a/homeassistant/components/epson/translations/et.json b/homeassistant/components/epson/translations/et.json index a0e3ec395f52b..755e5810c25fe 100644 --- a/homeassistant/components/epson/translations/et.json +++ b/homeassistant/components/epson/translations/et.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "Host", - "name": "Nimi", - "port": "Port" + "name": "Nimi" } } } diff --git a/homeassistant/components/epson/translations/fr.json b/homeassistant/components/epson/translations/fr.json index 3bbdd3063f594..c07a305a67798 100644 --- a/homeassistant/components/epson/translations/fr.json +++ b/homeassistant/components/epson/translations/fr.json @@ -1,15 +1,14 @@ { "config": { "error": { - "cannot_connect": "Echec de la connection", + "cannot_connect": "\u00c9chec de connexion", "powered_off": "Le projecteur est-il allum\u00e9? Vous devez allumer le projecteur pour la configuration initiale." }, "step": { "user": { "data": { "host": "H\u00f4te", - "name": "Nom", - "port": "Port" + "name": "Nom" } } } diff --git a/homeassistant/components/epson/translations/he.json b/homeassistant/components/epson/translations/he.json new file mode 100644 index 0000000000000..33660936e12c7 --- /dev/null +++ b/homeassistant/components/epson/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/hu.json b/homeassistant/components/epson/translations/hu.json index f2a380903ecf0..e3aa507b7c16b 100644 --- a/homeassistant/components/epson/translations/hu.json +++ b/homeassistant/components/epson/translations/hu.json @@ -1,14 +1,14 @@ { "config": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "powered_off": "A projektor be van kapcsolva? A kezdeti konfigur\u00e1l\u00e1shoz be kell kapcsolnia a kivet\u00edt\u0151t." }, "step": { "user": { "data": { - "host": "Hoszt", - "name": "N\u00e9v", - "port": "Port" + "host": "C\u00edm", + "name": "N\u00e9v" } } } diff --git a/homeassistant/components/epson/translations/id.json b/homeassistant/components/epson/translations/id.json index ba2d36424f951..6538f89ab14f9 100644 --- a/homeassistant/components/epson/translations/id.json +++ b/homeassistant/components/epson/translations/id.json @@ -1,14 +1,14 @@ { "config": { "error": { - "cannot_connect": "Gagal terhubung" + "cannot_connect": "Gagal terhubung", + "powered_off": "Apakah proyektor dinyalakan? Anda perlu menyalakan proyektor untuk konfigurasi awal." }, "step": { "user": { "data": { "host": "Host", - "name": "Nama", - "port": "Port" + "name": "Nama" } } } diff --git a/homeassistant/components/epson/translations/it.json b/homeassistant/components/epson/translations/it.json index fe72abc8739ee..88a296466e92f 100644 --- a/homeassistant/components/epson/translations/it.json +++ b/homeassistant/components/epson/translations/it.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "Host", - "name": "Nome", - "port": "Porta" + "name": "Nome" } } } diff --git a/homeassistant/components/epson/translations/ja.json b/homeassistant/components/epson/translations/ja.json new file mode 100644 index 0000000000000..e48317bcee3a1 --- /dev/null +++ b/homeassistant/components/epson/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "powered_off": "\u30d7\u30ed\u30b8\u30a7\u30af\u30bf\u30fc\u306e\u96fb\u6e90\u306f\u5165\u3063\u3066\u3044\u307e\u3059\u304b\uff1f\u521d\u671f\u8a2d\u5b9a\u3092\u884c\u3046\u305f\u3081\u306b\u306f\u3001\u30d7\u30ed\u30b8\u30a7\u30af\u30bf\u30fc\u306e\u96fb\u6e90\u3092\u5165\u308c\u3066\u304a\u304f\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/ka.json b/homeassistant/components/epson/translations/ka.json index b339899ea5fa5..ec686412a8f4c 100644 --- a/homeassistant/components/epson/translations/ka.json +++ b/homeassistant/components/epson/translations/ka.json @@ -7,8 +7,7 @@ "user": { "data": { "host": "\u10f0\u10dd\u10e1\u10e2\u10d8", - "name": "\u10e1\u10d0\u10ee\u10d4\u10da\u10d8", - "port": "\u10de\u10dd\u10e0\u10e2\u10d8" + "name": "\u10e1\u10d0\u10ee\u10d4\u10da\u10d8" } } } diff --git a/homeassistant/components/epson/translations/ko.json b/homeassistant/components/epson/translations/ko.json index 1ee9afdcf75ce..15666044f5b5a 100644 --- a/homeassistant/components/epson/translations/ko.json +++ b/homeassistant/components/epson/translations/ko.json @@ -7,8 +7,7 @@ "user": { "data": { "host": "\ud638\uc2a4\ud2b8", - "name": "\uc774\ub984", - "port": "\ud3ec\ud2b8" + "name": "\uc774\ub984" } } } diff --git a/homeassistant/components/epson/translations/lb.json b/homeassistant/components/epson/translations/lb.json index e8d9f52998f05..2a46ad28dd589 100644 --- a/homeassistant/components/epson/translations/lb.json +++ b/homeassistant/components/epson/translations/lb.json @@ -7,8 +7,7 @@ "user": { "data": { "host": "Host", - "name": "Numm", - "port": "Port" + "name": "Numm" } } } diff --git a/homeassistant/components/epson/translations/nl.json b/homeassistant/components/epson/translations/nl.json index d7521c945f2b2..d2ffe84c7be7e 100644 --- a/homeassistant/components/epson/translations/nl.json +++ b/homeassistant/components/epson/translations/nl.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "Host", - "name": "Naam", - "port": "Poort" + "name": "Naam" } } } diff --git a/homeassistant/components/epson/translations/no.json b/homeassistant/components/epson/translations/no.json index fc4bf7dcf36ed..882b12801d44f 100644 --- a/homeassistant/components/epson/translations/no.json +++ b/homeassistant/components/epson/translations/no.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "Vert", - "name": "Navn", - "port": "Port" + "name": "Navn" } } } diff --git a/homeassistant/components/epson/translations/pl.json b/homeassistant/components/epson/translations/pl.json index 7a3ea98a0e923..8534ffd62e367 100644 --- a/homeassistant/components/epson/translations/pl.json +++ b/homeassistant/components/epson/translations/pl.json @@ -1,14 +1,14 @@ { "config": { "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "powered_off": "Czy projektor jest w\u0142\u0105czony? Aby przeprowadzi\u0107 wst\u0119pn\u0105 konfiguracj\u0119, musisz w\u0142\u0105czy\u0107 projektor." }, "step": { "user": { "data": { "host": "Nazwa hosta lub adres IP", - "name": "Nazwa", - "port": "Port" + "name": "Nazwa" } } } diff --git a/homeassistant/components/epson/translations/pt.json b/homeassistant/components/epson/translations/pt.json index 352e98916f1aa..38336a1d5de8a 100644 --- a/homeassistant/components/epson/translations/pt.json +++ b/homeassistant/components/epson/translations/pt.json @@ -7,8 +7,7 @@ "user": { "data": { "host": "Servidor", - "name": "Nome", - "port": "Porta" + "name": "Nome" } } } diff --git a/homeassistant/components/epson/translations/ru.json b/homeassistant/components/epson/translations/ru.json index 47209d311a5ba..e800f033c3ee7 100644 --- a/homeassistant/components/epson/translations/ru.json +++ b/homeassistant/components/epson/translations/ru.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "\u0425\u043e\u0441\u0442", - "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", - "port": "\u041f\u043e\u0440\u0442" + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" } } } diff --git a/homeassistant/components/epson/translations/tr.json b/homeassistant/components/epson/translations/tr.json index 9ffd77fc50f6c..89d4c16be195a 100644 --- a/homeassistant/components/epson/translations/tr.json +++ b/homeassistant/components/epson/translations/tr.json @@ -1,14 +1,14 @@ { "config": { "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "powered_off": "Projekt\u00f6r a\u00e7\u0131k m\u0131? \u0130lk yap\u0131land\u0131rma i\u00e7in projekt\u00f6r\u00fc a\u00e7man\u0131z gerekir." }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "name": "\u0130sim", - "port": "Port" + "host": "Sunucu", + "name": "Ad" } } } diff --git a/homeassistant/components/epson/translations/uk.json b/homeassistant/components/epson/translations/uk.json index 65566a8f4aa5c..e37fdc0f25cc7 100644 --- a/homeassistant/components/epson/translations/uk.json +++ b/homeassistant/components/epson/translations/uk.json @@ -7,8 +7,7 @@ "user": { "data": { "host": "\u0425\u043e\u0441\u0442", - "name": "\u041d\u0430\u0437\u0432\u0430", - "port": "\u041f\u043e\u0440\u0442" + "name": "\u041d\u0430\u0437\u0432\u0430" } } } diff --git a/homeassistant/components/epson/translations/zh-Hans.json b/homeassistant/components/epson/translations/zh-Hans.json new file mode 100644 index 0000000000000..3cb7f97ceb98d --- /dev/null +++ b/homeassistant/components/epson/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "powered_off": "\u6295\u5f71\u4eea\u662f\u5426\u5df2\u7ecf\u6253\u5f00\uff1f\u60a8\u9700\u8981\u6253\u5f00\u6295\u5f71\u4eea\u4ee5\u8fdb\u884c\u521d\u59cb\u914d\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/zh-Hant.json b/homeassistant/components/epson/translations/zh-Hant.json index 25ae09cb4b45e..4831db6c5648b 100644 --- a/homeassistant/components/epson/translations/zh-Hant.json +++ b/homeassistant/components/epson/translations/zh-Hant.json @@ -8,8 +8,7 @@ "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", - "name": "\u540d\u7a31", - "port": "\u901a\u8a0a\u57e0" + "name": "\u540d\u7a31" } } } diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 22f74e1c0b132..285f2fc83e7f6 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -1,22 +1,60 @@ """Support for Epson Workforce Printer.""" +from __future__ import annotations + from datetime import timedelta from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS, PERCENTAGE from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -MONITORED_CONDITIONS = { - "black": ["Ink level Black", PERCENTAGE, "mdi:water"], - "photoblack": ["Ink level Photoblack", PERCENTAGE, "mdi:water"], - "magenta": ["Ink level Magenta", PERCENTAGE, "mdi:water"], - "cyan": ["Ink level Cyan", PERCENTAGE, "mdi:water"], - "yellow": ["Ink level Yellow", PERCENTAGE, "mdi:water"], - "clean": ["Cleaning level", PERCENTAGE, "mdi:water"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="black", + name="Ink level Black", + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="photoblack", + name="Ink level Photoblack", + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="magenta", + name="Ink level Magenta", + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="cyan", + name="Ink level Cyan", + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="yellow", + name="Ink level Yellow", + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="clean", + name="Cleaning level", + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + ), +) +MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -37,8 +75,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): raise PlatformNotReady() sensors = [ - EpsonPrinterCartridge(api, condition) - for condition in config[CONF_MONITORED_CONDITIONS] + EpsonPrinterCartridge(api, description) + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_CONDITIONS] ] add_devices(sensors, True) @@ -47,34 +86,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class EpsonPrinterCartridge(SensorEntity): """Representation of a cartridge sensor.""" - def __init__(self, api, cartridgeidx): + def __init__(self, api, description: SensorEntityDescription): """Initialize a cartridge sensor.""" self._api = api - - self._id = cartridgeidx - self._name = MONITORED_CONDITIONS[self._id][0] - self._unit = MONITORED_CONDITIONS[self._id][1] - self._icon = MONITORED_CONDITIONS[self._id][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit + self.entity_description = description @property - def state(self): + def native_value(self): """Return the state of the device.""" - return self._api.getSensorValue(self._id) + return self._api.getSensorValue(self.entity_description.key) @property def available(self): diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index f803c9c0bd5c3..b7c39ec996cc4 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -24,6 +24,7 @@ TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,7 @@ def __init__(self, _mac, _name): """Initialize the thermostat.""" # We want to avoid name clash with this module. self._name = _name + self._mac = _mac self._thermostat = eq3.Thermostat(_mac) @property @@ -121,8 +123,7 @@ def target_temperature(self): def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return self._thermostat.target_temperature = temperature @@ -183,6 +184,11 @@ def preset_modes(self): """ return list(HA_TO_EQ_PRESET) + @property + def unique_id(self) -> str: + """Return the MAC address of the thermostat.""" + return format_mac(self._mac) + def set_preset_mode(self, preset_mode): """Set new preset mode.""" if preset_mode == PRESET_NONE: diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index e62cb995b989a..4e2e7c02aaa80 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,24 +1,29 @@ """Support for esphome devices.""" from __future__ import annotations -import asyncio +from dataclasses import dataclass, field import functools import logging import math -from typing import Callable +from typing import Any, Callable, Generic, NamedTuple, TypeVar, cast, overload from aioesphomeapi import ( APIClient, APIConnectionError, + APIIntEnum, + APIVersion, DeviceInfo as EsphomeDeviceInfo, + EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, HomeassistantServiceCall, + InvalidEncryptionKeyAPIError, + ReconnectLogic, + RequiresEncryptionAPIError, UserService, UserServiceArgType, ) import voluptuous as vol -from zeroconf import DNSPointer, DNSRecord, RecordUpdateListener, Zeroconf from homeassistant import const from homeassistant.components import zeroconf @@ -30,13 +35,14 @@ CONF_PORT, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.service import async_set_service_schema @@ -47,38 +53,86 @@ from .entry_data import RuntimeEntryData DOMAIN = "esphome" +CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T") STORAGE_VERSION = 1 +@dataclass +class DomainData: + """Define a class that stores global esphome data in hass.data[DOMAIN].""" + + _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) + _stores: dict[str, Store] = field(default_factory=dict) + + def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData: + """Return the runtime entry data associated with this config entry. + + Raises KeyError if the entry isn't loaded yet. + """ + return self._entry_datas[entry.entry_id] + + def set_entry_data(self, entry: ConfigEntry, entry_data: RuntimeEntryData) -> None: + """Set the runtime entry data associated with this config entry.""" + if entry.entry_id in self._entry_datas: + raise ValueError("Entry data for this entry is already set") + self._entry_datas[entry.entry_id] = entry_data + + def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData: + """Pop the runtime entry data instance associated with this config entry.""" + return self._entry_datas.pop(entry.entry_id) + + def is_entry_loaded(self, entry: ConfigEntry) -> bool: + """Check whether the given entry is loaded.""" + return entry.entry_id in self._entry_datas + + def get_or_create_store(self, hass: HomeAssistant, entry: ConfigEntry) -> Store: + """Get or create a Store instance for the given config entry.""" + return self._stores.setdefault( + entry.entry_id, + Store( + hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder + ), + ) + + @classmethod + def get(cls: type[_T], hass: HomeAssistant) -> _T: + """Get the global DomainData instance stored in hass.data.""" + # Don't use setdefault - this is a hot code path + if DOMAIN in hass.data: + return cast(_T, hass.data[DOMAIN]) + ret = hass.data[DOMAIN] = cls() + return ret + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the esphome component.""" - hass.data.setdefault(DOMAIN, {}) - host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] + noise_psk = entry.data.get(CONF_NOISE_PSK) device_id = None zeroconf_instance = await zeroconf.async_get_instance(hass) cli = APIClient( - hass.loop, host, port, password, client_info=f"Home Assistant {const.__version__}", zeroconf_instance=zeroconf_instance, + noise_psk=noise_psk, ) - # Store client in per-config-entry hass.data - store = Store( - hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder - ) - entry_data = hass.data[DOMAIN][entry.entry_id] = RuntimeEntryData( - client=cli, entry_id=entry.entry_id, store=store + domain_data = DomainData.get(hass) + entry_data = RuntimeEntryData( + client=cli, + entry_id=entry.entry_id, + store=domain_data.get_or_create_store(hass, entry), ) + domain_data.set_entry_data(entry, entry_data) async def on_stop(event: Event) -> None: """Cleanup the socket client on HA stop.""" @@ -105,7 +159,8 @@ def async_on_service_call(service: HomeassistantServiceCall) -> None: if service.data_template: try: data_template = { - key: Template(value) for key, value in service.data_template.items() + key: Template(value) # type: ignore[no-untyped-call] + for key, value in service.data_template.items() } template.attach(hass, data_template) service_data.update( @@ -140,40 +195,77 @@ def async_on_service_call(service: HomeassistantServiceCall) -> None: ) ) - async def send_home_assistant_state_event(event: Event) -> None: - """Forward Home Assistant states updates to ESPHome.""" - new_state = event.data.get("new_state") - if new_state is None: - return - entity_id = event.data.get("entity_id") - await cli.send_home_assistant_state(entity_id, new_state.state) - async def _send_home_assistant_state( - entity_id: str, new_state: State | None + entity_id: str, attribute: str | None, state: State | None ) -> None: """Forward Home Assistant states to ESPHome.""" - await cli.send_home_assistant_state(entity_id, new_state.state) + if state is None or (attribute and attribute not in state.attributes): + return + + send_state = state.state + if attribute: + attr_val = state.attributes[attribute] + # ESPHome only handles "on"/"off" for boolean values + if isinstance(attr_val, bool): + send_state = "on" if attr_val else "off" + else: + send_state = attr_val + + await cli.send_home_assistant_state(entity_id, attribute, str(send_state)) @callback - def async_on_state_subscription(entity_id: str) -> None: + def async_on_state_subscription( + entity_id: str, attribute: str | None = None + ) -> None: """Subscribe and forward states for requested entities.""" + + async def send_home_assistant_state_event(event: Event) -> None: + """Forward Home Assistant states updates to ESPHome.""" + + # Only communicate changes to the state or attribute tracked + if event.data.get("new_state") is None or ( + event.data.get("old_state") is not None + and "new_state" in event.data + and ( + ( + not attribute + and event.data["old_state"].state + == event.data["new_state"].state + ) + or ( + attribute + and attribute in event.data["old_state"].attributes + and attribute in event.data["new_state"].attributes + and event.data["old_state"].attributes[attribute] + == event.data["new_state"].attributes[attribute] + ) + ) + ): + return + + await _send_home_assistant_state( + event.data["entity_id"], attribute, event.data.get("new_state") + ) + unsub = async_track_state_change_event( hass, [entity_id], send_home_assistant_state_event ) entry_data.disconnect_callbacks.append(unsub) - new_state = hass.states.get(entity_id) - if new_state is None: - return + # Send initial state - hass.async_create_task(_send_home_assistant_state(entity_id, new_state)) + hass.async_create_task( + _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id)) + ) - async def on_login() -> None: + async def on_connect() -> None: """Subscribe to states and list entities on successful API login.""" nonlocal device_id try: entry_data.device_info = await cli.device_info() + assert cli.api_version is not None + entry_data.api_version = cli.api_version entry_data.available = True - device_id = await _async_setup_device_registry( + device_id = _async_setup_device_registry( hass, entry, entry_data.device_info ) entry_data.async_update_device_state(hass) @@ -191,8 +283,26 @@ async def on_login() -> None: # Re-connection logic will trigger after this await cli.disconnect() + async def on_disconnect() -> None: + """Run disconnect callbacks on API disconnect.""" + for disconnect_cb in entry_data.disconnect_callbacks: + disconnect_cb() + entry_data.disconnect_callbacks = [] + entry_data.available = False + entry_data.async_update_device_state(hass) + + async def on_connect_error(err: Exception) -> None: + """Start reauth flow if appropriate connect error type.""" + if isinstance(err, (RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError)): + entry.async_start_reauth(hass) + reconnect_logic = ReconnectLogic( - hass, cli, entry, host, on_login, zeroconf_instance + client=cli, + on_connect=on_connect, + on_disconnect=on_disconnect, + zeroconf_instance=zeroconf_instance, + name=host, + on_connect_error=on_connect_error, ) async def complete_setup() -> None: @@ -208,329 +318,117 @@ async def complete_setup() -> None: return True -class ReconnectLogic(RecordUpdateListener): - """Reconnectiong logic handler for ESPHome config entries. - - Contains two reconnect strategies: - - Connect with increasing time between connection attempts. - - Listen to zeroconf mDNS records, if any records are found for this device, try reconnecting immediately. - """ - - def __init__( - self, - hass: HomeAssistant, - cli: APIClient, - entry: ConfigEntry, - host: str, - on_login, - zc: Zeroconf, - ): - """Initialize ReconnectingLogic.""" - self._hass = hass - self._cli = cli - self._entry = entry - self._host = host - self._on_login = on_login - self._zc = zc - # Flag to check if the device is connected - self._connected = True - self._connected_lock = asyncio.Lock() - self._zc_lock = asyncio.Lock() - self._zc_listening = False - # Event the different strategies use for issuing a reconnect attempt. - self._reconnect_event = asyncio.Event() - # The task containing the infinite reconnect loop while running - self._loop_task: asyncio.Task | None = None - # How many reconnect attempts have there been already, used for exponential wait time - self._tries = 0 - self._tries_lock = asyncio.Lock() - # Track the wait task to cancel it on HA shutdown - self._wait_task: asyncio.Task | None = None - self._wait_task_lock = asyncio.Lock() - - @property - def _entry_data(self) -> RuntimeEntryData | None: - return self._hass.data[DOMAIN].get(self._entry.entry_id) - - async def _on_disconnect(self): - """Log and issue callbacks when disconnecting.""" - if self._entry_data is None: - return - # This can happen often depending on WiFi signal strength. - # So therefore all these connection warnings are logged - # as infos. The "unavailable" logic will still trigger so the - # user knows if the device is not connected. - _LOGGER.info("Disconnected from ESPHome API for %s", self._host) - - # Run disconnect hooks - for disconnect_cb in self._entry_data.disconnect_callbacks: - disconnect_cb() - self._entry_data.disconnect_callbacks = [] - self._entry_data.available = False - self._entry_data.async_update_device_state(self._hass) - await self._start_zc_listen() - - # Reset tries - async with self._tries_lock: - self._tries = 0 - # Connected needs to be reset before the reconnect event (opposite order of check) - async with self._connected_lock: - self._connected = False - self._reconnect_event.set() - - async def _wait_and_start_reconnect(self): - """Wait for exponentially increasing time to issue next reconnect event.""" - async with self._tries_lock: - tries = self._tries - # If not first re-try, wait and print message - # Cap wait time at 1 minute. This is because while working on the - # device (e.g. soldering stuff), users don't want to have to wait - # a long time for their device to show up in HA again (this was - # mentioned a lot in early feedback) - tries = min(tries, 10) # prevent OverflowError - wait_time = int(round(min(1.8 ** tries, 60.0))) - if tries == 1: - _LOGGER.info("Trying to reconnect to %s in the background", self._host) - _LOGGER.debug("Retrying %s in %d seconds", self._host, wait_time) - await asyncio.sleep(wait_time) - async with self._wait_task_lock: - self._wait_task = None - self._reconnect_event.set() - - async def _try_connect(self): - """Try connecting to the API client.""" - async with self._tries_lock: - tries = self._tries - self._tries += 1 - - try: - await self._cli.connect(on_stop=self._on_disconnect, login=True) - except APIConnectionError as error: - level = logging.WARNING if tries == 0 else logging.DEBUG - _LOGGER.log( - level, - "Can't connect to ESPHome API for %s (%s): %s", - self._entry.unique_id, - self._host, - error, - ) - await self._start_zc_listen() - # Schedule re-connect in event loop in order not to delay HA - # startup. First connect is scheduled in tracked tasks. - async with self._wait_task_lock: - # Allow only one wait task at a time - # can happen if mDNS record received while waiting, then use existing wait task - if self._wait_task is not None: - return - - self._wait_task = self._hass.loop.create_task( - self._wait_and_start_reconnect() - ) - else: - _LOGGER.info("Successfully connected to %s", self._host) - async with self._tries_lock: - self._tries = 0 - async with self._connected_lock: - self._connected = True - await self._stop_zc_listen() - self._hass.async_create_task(self._on_login()) - - async def _reconnect_once(self): - # Wait and clear reconnection event - await self._reconnect_event.wait() - self._reconnect_event.clear() - - # If in connected state, do not try to connect again. - async with self._connected_lock: - if self._connected: - return False - - # Check if the entry got removed or disabled, in which case we shouldn't reconnect - if self._entry.entry_id not in self._hass.data[DOMAIN]: - # When removing/disconnecting manually - return - - device_registry = self._hass.helpers.device_registry.async_get(self._hass) - devices = dr.async_entries_for_config_entry( - device_registry, self._entry.entry_id - ) - for device in devices: - # There is only one device in ESPHome - if device.disabled: - # Don't attempt to connect if it's disabled - return - - await self._try_connect() - - async def _reconnect_loop(self): - while True: - try: - await self._reconnect_once() - except asyncio.CancelledError: # pylint: disable=try-except-raise - raise - except Exception: # pylint: disable=broad-except - _LOGGER.error("Caught exception while reconnecting", exc_info=True) - - async def start(self): - """Start the reconnecting logic background task.""" - # Create reconnection loop outside of HA's tracked tasks in order - # not to delay startup. - self._loop_task = self._hass.loop.create_task(self._reconnect_loop()) - - async with self._connected_lock: - self._connected = False - self._reconnect_event.set() - - async def stop(self): - """Stop the reconnecting logic background task. Does not disconnect the client.""" - if self._loop_task is not None: - self._loop_task.cancel() - self._loop_task = None - async with self._wait_task_lock: - if self._wait_task is not None: - self._wait_task.cancel() - self._wait_task = None - await self._stop_zc_listen() - - async def _start_zc_listen(self): - """Listen for mDNS records. - - This listener allows us to schedule a reconnect as soon as a - received mDNS record indicates the node is up again. - """ - async with self._zc_lock: - if not self._zc_listening: - await self._hass.async_add_executor_job( - self._zc.add_listener, self, None - ) - self._zc_listening = True - - async def _stop_zc_listen(self): - """Stop listening for zeroconf updates.""" - async with self._zc_lock: - if self._zc_listening: - await self._hass.async_add_executor_job(self._zc.remove_listener, self) - self._zc_listening = False - - @callback - def stop_callback(self): - """Stop as an async callback function.""" - self._hass.async_create_task(self.stop()) - - @callback - def _set_reconnect(self): - self._reconnect_event.set() - - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Listen to zeroconf updated mDNS records.""" - if not isinstance(record, DNSPointer): - # We only consider PTR records and match using the alias name - return - if self._entry_data is None or self._entry_data.device_info is None: - # Either the entry was already teared down or we haven't received device info yet - return - filter_alias = f"{self._entry_data.device_info.name}._esphomelib._tcp.local." - if record.alias != filter_alias: - return - - # This is a mDNS record from the device and could mean it just woke up - # Check if already connected, no lock needed for this access - if self._connected: - return - - # Tell reconnection logic to retry connection attempt now (even before reconnect timer finishes) - _LOGGER.debug( - "%s: Triggering reconnect because of received mDNS record %s", - self._host, - record, - ) - self._hass.add_job(self._set_reconnect) - - -async def _async_setup_device_registry( +@callback +def _async_setup_device_registry( hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo -): +) -> str: """Set up device registry feature for a particular config entry.""" sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += f" ({device_info.compilation_time})" - device_registry = await dr.async_get_registry(hass) - entry = device_registry.async_get_or_create( + configuration_url = None + if device_info.webserver_port > 0: + configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + configuration_url=configuration_url, connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, name=device_info.name, manufacturer="espressif", model=device_info.model, sw_version=sw_version, ) - return entry.id + return device_entry.id + + +class ServiceMetadata(NamedTuple): + """Metadata for services.""" + + validator: Any + example: str + selector: dict[str, Any] + description: str | None = None + + +ARG_TYPE_METADATA = { + UserServiceArgType.BOOL: ServiceMetadata( + validator=cv.boolean, + example="False", + selector={"boolean": None}, + ), + UserServiceArgType.INT: ServiceMetadata( + validator=vol.Coerce(int), + example="42", + selector={"number": {CONF_MODE: "box"}}, + ), + UserServiceArgType.FLOAT: ServiceMetadata( + validator=vol.Coerce(float), + example="12.3", + selector={"number": {CONF_MODE: "box", "step": 1e-3}}, + ), + UserServiceArgType.STRING: ServiceMetadata( + validator=cv.string, + example="Example text", + selector={"text": None}, + ), + UserServiceArgType.BOOL_ARRAY: ServiceMetadata( + validator=[cv.boolean], + description="A list of boolean values.", + example="[True, False]", + selector={"object": {}}, + ), + UserServiceArgType.INT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(int)], + description="A list of integer values.", + example="[42, 34]", + selector={"object": {}}, + ), + UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(float)], + description="A list of floating point numbers.", + example="[ 12.3, 34.5 ]", + selector={"object": {}}, + ), + UserServiceArgType.STRING_ARRAY: ServiceMetadata( + validator=[cv.string], + description="A list of strings.", + example="['Example text', 'Another example']", + selector={"object": {}}, + ), +} async def _register_service( hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService -): +) -> None: + if entry_data.device_info is None: + raise ValueError("Device Info needs to be fetched first") service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" schema = {} fields = {} for arg in service.args: - metadata = { - UserServiceArgType.BOOL: { - "validator": cv.boolean, - "example": "False", - "selector": {"boolean": None}, - }, - UserServiceArgType.INT: { - "validator": vol.Coerce(int), - "example": "42", - "selector": {"number": {CONF_MODE: "box"}}, - }, - UserServiceArgType.FLOAT: { - "validator": vol.Coerce(float), - "example": "12.3", - "selector": {"number": {CONF_MODE: "box", "step": 1e-3}}, - }, - UserServiceArgType.STRING: { - "validator": cv.string, - "example": "Example text", - "selector": {"text": None}, - }, - UserServiceArgType.BOOL_ARRAY: { - "validator": [cv.boolean], - "description": "A list of boolean values.", - "example": "[True, False]", - "selector": {"object": {}}, - }, - UserServiceArgType.INT_ARRAY: { - "validator": [vol.Coerce(int)], - "description": "A list of integer values.", - "example": "[42, 34]", - "selector": {"object": {}}, - }, - UserServiceArgType.FLOAT_ARRAY: { - "validator": [vol.Coerce(float)], - "description": "A list of floating point numbers.", - "example": "[ 12.3, 34.5 ]", - "selector": {"object": {}}, - }, - UserServiceArgType.STRING_ARRAY: { - "validator": [cv.string], - "description": "A list of strings.", - "example": "['Example text', 'Another example']", - "selector": {"object": {}}, - }, - }[arg.type_] - schema[vol.Required(arg.name)] = metadata["validator"] + if arg.type not in ARG_TYPE_METADATA: + _LOGGER.error( + "Can't register service %s because %s is of unknown type %s", + service_name, + arg.name, + arg.type, + ) + return + metadata = ARG_TYPE_METADATA[arg.type] + schema[vol.Required(arg.name)] = metadata.validator fields[arg.name] = { "name": arg.name, "required": True, - "description": metadata.get("description"), - "example": metadata["example"], - "selector": metadata["selector"], + "description": metadata.description, + "example": metadata.example, + "selector": metadata.selector, } - async def execute_service(call): - await entry_data.client.execute_service(service, call.data) + async def execute_service(call: ServiceCall) -> None: + await entry_data.client.execute_service(service, call.data) # type: ignore[arg-type] hass.services.async_register( DOMAIN, service_name, execute_service, vol.Schema(schema) @@ -546,15 +444,17 @@ async def execute_service(call): async def _setup_services( hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] -): +) -> None: + if entry_data.device_info is None: + # Can happen if device has never connected or .storage cleared + return old_services = entry_data.services.copy() to_unregister = [] to_register = [] for service in services: if service.key in old_services: # Already exists - matching = old_services.pop(service.key) - if matching != service: + if (matching := old_services.pop(service.key)) != service: # Need to re-register to_unregister.append(matching) to_register.append(service) @@ -579,7 +479,8 @@ async def _cleanup_instance( hass: HomeAssistant, entry: ConfigEntry ) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" - data: RuntimeEntryData = hass.data[DOMAIN].pop(entry.entry_id) + domain_data = DomainData.get(hass) + data = domain_data.pop_entry_data(entry) for disconnect_cb in data.disconnect_callbacks: disconnect_cb() for cleanup_callback in data.cleanup_callbacks: @@ -596,43 +497,55 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove an esphome config entry.""" + await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() + + +_InfoT = TypeVar("_InfoT", bound=EntityInfo) +_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") +_StateT = TypeVar("_StateT", bound=EntityState) + + async def platform_async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities, + async_add_entities: AddEntitiesCallback, *, component_key: str, - info_type, - entity_type, - state_type, + info_type: type[_InfoT], + entity_type: type[_EntityT], + state_type: type[_StateT], ) -> None: """Set up an esphome platform. This method is in charge of receiving, distributing and storing info and state updates. """ - entry_data: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) entry_data.info[component_key] = {} entry_data.old_info[component_key] = {} entry_data.state[component_key] = {} @callback - def async_list_entities(infos: list[EntityInfo]): + def async_list_entities(infos: list[EntityInfo]) -> None: """Update entities of this platform when entities are listed.""" old_infos = entry_data.info[component_key] - new_infos = {} + new_infos: dict[int, EntityInfo] = {} add_entities = [] for info in infos: if not isinstance(info, info_type): # Filter out infos that don't belong to this platform. continue + # cast back to upper type, otherwise mypy gets confused + info = cast(EntityInfo, info) if info.key in old_infos: # Update existing entity old_infos.pop(info.key) else: # Create new entity - entity = entity_type(entry.entry_id, component_key, info.key) + entity = entity_type(entry_data, component_key, info.key) add_entities.append(entity) new_infos[info.key] = info @@ -654,10 +567,13 @@ def async_list_entities(infos: list[EntityInfo]): ) @callback - def async_entity_state(state: EntityState): + def async_entity_state(state: EntityState) -> None: """Notify the appropriate entity of an updated state.""" if not isinstance(state, state_type): return + # cast back to upper type, otherwise mypy gets confused + state = cast(EntityState, state) + entry_data.state[component_key][state.key] = state entry_data.async_update_entity(hass, component_key, state.key) @@ -667,16 +583,21 @@ def async_entity_state(state: EntityState): ) -def esphome_state_property(func): +_PropT = TypeVar("_PropT", bound=Callable[..., Any]) + + +def esphome_state_property(func: _PropT) -> _PropT: """Wrap a state property of an esphome entity. This checks if the state object in the entity is set, and prevents writing NAN values to the Home Assistant state machine. """ - @property - def _wrapper(self): - if self._state is None: + @property # type: ignore[misc] + @functools.wraps(func) + def _wrapper(self): # type: ignore[no-untyped-def] + # pylint: disable=protected-access + if not self._has_state: return None val = func(self) if isinstance(val, float) and math.isnan(val): @@ -685,41 +606,64 @@ def _wrapper(self): return None return val - return _wrapper + return cast(_PropT, _wrapper) -class EsphomeEnumMapper: +_EnumT = TypeVar("_EnumT", bound=APIIntEnum) +_ValT = TypeVar("_ValT") + + +class EsphomeEnumMapper(Generic[_EnumT, _ValT]): """Helper class to convert between hass and esphome enum values.""" - def __init__(self, func: Callable[[], dict[int, str]]): + def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: """Construct a EsphomeEnumMapper.""" - self._func = func + # Add none mapping + augmented_mapping: dict[_EnumT | None, _ValT | None] = mapping # type: ignore[assignment] + augmented_mapping[None] = None + + self._mapping = augmented_mapping + self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} + + @overload + def from_esphome(self, value: _EnumT) -> _ValT: + ... - def from_esphome(self, value: int) -> str: + @overload + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + ... + + def from_esphome(self, value: _EnumT | None) -> _ValT | None: """Convert from an esphome int representation to a hass string.""" - return self._func()[value] + return self._mapping[value] - def from_hass(self, value: str) -> int: + def from_hass(self, value: _ValT) -> _EnumT: """Convert from a hass string to a esphome int representation.""" - inverse = {v: k for k, v in self._func().items()} - return inverse[value] + return self._inverse[value] -def esphome_map_enum(func: Callable[[], dict[int, str]]): - """Map esphome int enum values to hass string constants. +ICON_SCHEMA = vol.Schema(cv.icon) - This class has to be used as a decorator. This ensures the aioesphomeapi - import is only happening at runtime. - """ - return EsphomeEnumMapper(func) + +ENTITY_CATEGORIES: EsphomeEnumMapper[ + EsphomeEntityCategory, EntityCategory | None +] = EsphomeEnumMapper( + { + EsphomeEntityCategory.NONE: None, + EsphomeEntityCategory.CONFIG: EntityCategory.CONFIG, + EsphomeEntityCategory.DIAGNOSTIC: EntityCategory.DIAGNOSTIC, + } +) -class EsphomeBaseEntity(Entity): +class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" - def __init__(self, entry_id: str, component_key: str, key: int): + def __init__( + self, entry_data: RuntimeEntryData, component_key: str, key: int + ) -> None: """Initialize.""" - self._entry_id = entry_id + self._entry_data = entry_data self._component_key = component_key self._key = key @@ -744,6 +688,22 @@ async def async_added_to_hass(self) -> None: ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + ( + f"esphome_{self._entry_id}" + f"_update_{self._component_key}_{self._key}" + ), + self._on_state_update, + ) + ) + + @callback + def _on_state_update(self) -> None: + # Behavior can be changed in child classes + self.async_write_ha_state() + @callback def _on_device_update(self) -> None: """Update the entity state when device info has changed.""" @@ -752,24 +712,29 @@ def _on_device_update(self) -> None: # Only update the HA state when the full state arrives # through the next entity state packet. return - self.async_write_ha_state() + self._on_state_update() @property - def _entry_data(self) -> RuntimeEntryData: - return self.hass.data[DOMAIN][self._entry_id] + def _entry_id(self) -> str: + return self._entry_data.entry_id @property - def _static_info(self) -> EntityInfo: + def _api_version(self) -> APIVersion: + return self._entry_data.api_version + + @property + def _static_info(self) -> _InfoT: # Check if value is in info database. Use a single lookup. info = self._entry_data.info[self._component_key].get(self._key) if info is not None: - return info + return cast(_InfoT, info) # This entity is in the removal project and has been removed from .info # already, look in old_info - return self._entry_data.old_info[self._component_key].get(self._key) + return cast(_InfoT, self._entry_data.old_info[self._component_key][self._key]) @property def _device_info(self) -> EsphomeDeviceInfo: + assert self._entry_data.device_info is not None return self._entry_data.device_info @property @@ -777,11 +742,12 @@ def _client(self) -> APIClient: return self._entry_data.client @property - def _state(self) -> EntityState | None: - try: - return self._entry_data.state[self._component_key][self._key] - except KeyError: - return None + def _state(self) -> _StateT: + return cast(_StateT, self._entry_data.state[self._component_key][self._key]) + + @property + def _has_state(self) -> bool: + return self._key in self._entry_data.state[self._component_key] @property def available(self) -> bool: @@ -805,36 +771,36 @@ def unique_id(self) -> str | None: @property def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - return { - "connections": {(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} - } + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + ) @property def name(self) -> str: """Return the name of the entity.""" return self._static_info.name + @property + def icon(self) -> str | None: + """Return the icon.""" + if not self._static_info.icon: + return None + + return cast(str, ICON_SCHEMA(self._static_info.icon)) + @property def should_poll(self) -> bool: """Disable polling.""" return False + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return not self._static_info.disabled_by_default -class EsphomeEntity(EsphomeBaseEntity): - """Define a generic esphome entity.""" - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - ( - f"esphome_{self._entry_id}" - f"_update_{self._component_key}_{self._key}" - ), - self.async_write_ha_state, - ) - ) + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + if not self._static_info.entity_category: + return None + return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 28cc47691f56b..ffe322b625973 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -3,12 +3,17 @@ from aioesphomeapi import BinarySensorInfo, BinarySensorState -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, platform_async_setup_entry -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up ESPHome binary sensors based on a config entry.""" await platform_async_setup_entry( hass, @@ -21,17 +26,11 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity): +class EsphomeBinarySensor( + EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity +): """A binary sensor implementation for ESPHome.""" - @property - def _static_info(self) -> BinarySensorInfo: - return super()._static_info - - @property - def _state(self) -> BinarySensorState | None: - return super()._state - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" @@ -39,15 +38,17 @@ def is_on(self) -> bool | None: # Status binary sensors indicated connected state. # So in their case what's usually _availability_ is now state return self._entry_data.available - if self._state is None: + if not self._has_state: return None if self._state.missing_state: return None return self._state.state @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" + if self._static_info.device_class not in DEVICE_CLASSES: + return None return self._static_info.device_class @property diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py new file mode 100644 index 0000000000000..5b6f2c153c8ee --- /dev/null +++ b/homeassistant/components/esphome/button.py @@ -0,0 +1,51 @@ +"""Support for ESPHome buttons.""" +from __future__ import annotations + +from contextlib import suppress +from typing import Any + +from aioesphomeapi import ButtonInfo, EntityState + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EsphomeEntity, platform_async_setup_entry + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome buttons based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="button", + info_type=ButtonInfo, + entity_type=EsphomeButton, + state_type=EntityState, + ) + + +class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): + """A button implementation for ESPHome.""" + + @property + def device_class(self) -> ButtonDeviceClass | None: + """Return the class of this entity.""" + with suppress(ValueError): + return ButtonDeviceClass(self._static_info.device_class) + return None + + @callback + def _on_device_update(self) -> None: + """Update the entity state when device info has changed.""" + # This override the EsphomeEntity method as the button entity + # never gets a state update. + self._on_state_update() + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self._client.button_command(self._static_info.key) diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 105d77637a7cf..47010324290f3 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -2,20 +2,22 @@ from __future__ import annotations import asyncio +from typing import Any from aioesphomeapi import CameraInfo, CameraState +from aiohttp import web from homeassistant.components import camera from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeBaseEntity, platform_async_setup_entry +from . import EsphomeEntity, platform_async_setup_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up esphome cameras based on a config entry.""" await platform_async_setup_entry( @@ -29,46 +31,28 @@ async def async_setup_entry( ) -class EsphomeCamera(Camera, EsphomeBaseEntity): +class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): """A camera implementation for ESPHome.""" - def __init__(self, entry_id: str, component_key: str, key: int): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize.""" Camera.__init__(self) - EsphomeBaseEntity.__init__(self, entry_id, component_key, key) + EsphomeEntity.__init__(self, *args, **kwargs) self._image_cond = asyncio.Condition() - @property - def _static_info(self) -> CameraInfo: - return super()._static_info - - @property - def _state(self) -> CameraState | None: - return super()._state - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - ( - f"esphome_{self._entry_id}" - f"_update_{self._component_key}_{self._key}" - ), - self._on_state_update, - ) - ) - - async def _on_state_update(self) -> None: + @callback + def _on_state_update(self) -> None: """Notify listeners of new image when update arrives.""" - self.async_write_ha_state() + super()._on_state_update() + self.hass.async_create_task(self._on_state_update_coro()) + + async def _on_state_update_coro(self) -> None: async with self._image_cond: self._image_cond.notify_all() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" if not self.available: return None @@ -77,7 +61,7 @@ async def async_camera_image(self) -> bytes | None: await self._image_cond.wait() if not self.available: return None - return self._state.image[:] + return self._state.data[:] async def _async_camera_stream_image(self) -> bytes | None: """Return a single camera image in a stream.""" @@ -88,9 +72,11 @@ async def _async_camera_stream_image(self) -> bytes | None: await self._image_cond.wait() if not self.available: return None - return self._state.image[:] + return self._state.data[:] - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse: """Serve an HTTP MJPEG stream from the camera.""" return await camera.async_get_still_stream( request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0 diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 5d21d495ec2cd..31d3e5f232028 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -1,11 +1,14 @@ """Support for ESPHome climate devices.""" from __future__ import annotations +from typing import Any, cast + from aioesphomeapi import ( ClimateAction, ClimateFanMode, ClimateInfo, ClimateMode, + ClimatePreset, ClimateState, ClimateSwingMode, ) @@ -30,14 +33,21 @@ FAN_MIDDLE, FAN_OFF, FAN_ON, + HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + PRESET_ACTIVITY, PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_SWING_MODE, @@ -48,6 +58,7 @@ SWING_OFF, SWING_VERTICAL, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -55,16 +66,20 @@ PRECISION_WHOLE, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( EsphomeEntity, - esphome_map_enum, + EsphomeEnumMapper, esphome_state_property, platform_async_setup_entry, ) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up ESPHome climate devices based on a config entry.""" await platform_async_setup_entry( hass, @@ -77,21 +92,19 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -@esphome_map_enum -def _climate_modes(): - return { +_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode, str] = EsphomeEnumMapper( + { ClimateMode.OFF: HVAC_MODE_OFF, - ClimateMode.AUTO: HVAC_MODE_HEAT_COOL, + ClimateMode.HEAT_COOL: HVAC_MODE_HEAT_COOL, ClimateMode.COOL: HVAC_MODE_COOL, ClimateMode.HEAT: HVAC_MODE_HEAT, ClimateMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, ClimateMode.DRY: HVAC_MODE_DRY, + ClimateMode.AUTO: HVAC_MODE_AUTO, } - - -@esphome_map_enum -def _climate_actions(): - return { +) +_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction, str] = EsphomeEnumMapper( + { ClimateAction.OFF: CURRENT_HVAC_OFF, ClimateAction.COOLING: CURRENT_HVAC_COOL, ClimateAction.HEATING: CURRENT_HVAC_HEAT, @@ -99,11 +112,9 @@ def _climate_actions(): ClimateAction.DRYING: CURRENT_HVAC_DRY, ClimateAction.FAN: CURRENT_HVAC_FAN, } - - -@esphome_map_enum -def _fan_modes(): - return { +) +_FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper( + { ClimateFanMode.ON: FAN_ON, ClimateFanMode.OFF: FAN_OFF, ClimateFanMode.AUTO: FAN_AUTO, @@ -114,28 +125,35 @@ def _fan_modes(): ClimateFanMode.FOCUS: FAN_FOCUS, ClimateFanMode.DIFFUSE: FAN_DIFFUSE, } - - -@esphome_map_enum -def _swing_modes(): - return { +) +_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper( + { ClimateSwingMode.OFF: SWING_OFF, ClimateSwingMode.BOTH: SWING_BOTH, ClimateSwingMode.VERTICAL: SWING_VERTICAL, ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, } +) +_PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper( + { + ClimatePreset.NONE: PRESET_NONE, + ClimatePreset.HOME: PRESET_HOME, + ClimatePreset.AWAY: PRESET_AWAY, + ClimatePreset.BOOST: PRESET_BOOST, + ClimatePreset.COMFORT: PRESET_COMFORT, + ClimatePreset.ECO: PRESET_ECO, + ClimatePreset.SLEEP: PRESET_SLEEP, + ClimatePreset.ACTIVITY: PRESET_ACTIVITY, + } +) -class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): - """A climate implementation for ESPHome.""" +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method - @property - def _static_info(self) -> ClimateInfo: - return super()._static_info - @property - def _state(self) -> ClimateState | None: - return super()._state +class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity): + """A climate implementation for ESPHome.""" @property def precision(self) -> float: @@ -156,28 +174,31 @@ def temperature_unit(self) -> str: def hvac_modes(self) -> list[str]: """Return the list of available operation modes.""" return [ - _climate_modes.from_esphome(mode) + _CLIMATE_MODES.from_esphome(mode) for mode in self._static_info.supported_modes ] @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" return [ - _fan_modes.from_esphome(mode) + _FAN_MODES.from_esphome(mode) for mode in self._static_info.supported_fan_modes - ] + ] + self._static_info.supported_custom_fan_modes @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return preset modes.""" - return [PRESET_AWAY, PRESET_HOME] if self._static_info.supports_away else [] + return [ + _PRESETS.from_esphome(preset) + for preset in self._static_info.supported_presets_compat(self._api_version) + ] + self._static_info.supported_custom_presets @property - def swing_modes(self): + def swing_modes(self) -> list[str]: """Return the list of available swing modes.""" return [ - _swing_modes.from_esphome(mode) + _SWING_MODES.from_esphome(mode) for mode in self._static_info.supported_swing_modes ] @@ -205,21 +226,18 @@ def supported_features(self) -> int: features |= SUPPORT_TARGET_TEMPERATURE_RANGE else: features |= SUPPORT_TARGET_TEMPERATURE - if self._static_info.supports_away: + if self.preset_modes: features |= SUPPORT_PRESET_MODE - if self._static_info.supported_fan_modes: + if self.fan_modes: features |= SUPPORT_FAN_MODE - if self._static_info.supported_swing_modes: + if self.swing_modes: features |= SUPPORT_SWING_MODE return features - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method - @esphome_state_property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> str | None: # type: ignore[override] """Return current operation ie. heat, cool, idle.""" - return _climate_modes.from_esphome(self._state.mode) + return _CLIMATE_MODES.from_esphome(self._state.mode) @esphome_state_property def hvac_action(self) -> str | None: @@ -227,22 +245,26 @@ def hvac_action(self) -> str | None: # HA has no support feature field for hvac_action if not self._static_info.supports_action: return None - return _climate_actions.from_esphome(self._state.action) + return _CLIMATE_ACTIONS.from_esphome(self._state.action) @esphome_state_property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return current fan setting.""" - return _fan_modes.from_esphome(self._state.fan_mode) + return self._state.custom_fan_mode or _FAN_MODES.from_esphome( + self._state.fan_mode + ) @esphome_state_property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return current preset mode.""" - return PRESET_AWAY if self._state.away else PRESET_HOME + return self._state.custom_preset or _PRESETS.from_esphome( + self._state.preset_compat(self._api_version) + ) @esphome_state_property - def swing_mode(self): + def swing_mode(self) -> str | None: """Return current swing mode.""" - return _swing_modes.from_esphome(self._state.swing_mode) + return _SWING_MODES.from_esphome(self._state.swing_mode) @esphome_state_property def current_temperature(self) -> float | None: @@ -264,11 +286,11 @@ def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" return self._state.target_temperature_high - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: float | str) -> None: """Set new target temperature (and operation mode if set).""" - data = {"key": self._static_info.key} + data: dict[str, Any] = {"key": self._static_info.key} if ATTR_HVAC_MODE in kwargs: - data["mode"] = _climate_modes.from_hass(kwargs[ATTR_HVAC_MODE]) + data["mode"] = _CLIMATE_MODES.from_hass(cast(str, kwargs[ATTR_HVAC_MODE])) if ATTR_TEMPERATURE in kwargs: data["target_temperature"] = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_LOW in kwargs: @@ -280,22 +302,29 @@ async def async_set_temperature(self, **kwargs) -> None: async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target operation mode.""" await self._client.climate_command( - key=self._static_info.key, mode=_climate_modes.from_hass(hvac_mode) + key=self._static_info.key, mode=_CLIMATE_MODES.from_hass(hvac_mode) ) - async def async_set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - away = preset_mode == PRESET_AWAY - await self._client.climate_command(key=self._static_info.key, away=away) + kwargs: dict[str, Any] = {"key": self._static_info.key} + if preset_mode in self._static_info.supported_custom_presets: + kwargs["custom_preset"] = preset_mode + else: + kwargs["preset"] = _PRESETS.from_hass(preset_mode) + await self._client.climate_command(**kwargs) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" - await self._client.climate_command( - key=self._static_info.key, fan_mode=_fan_modes.from_hass(fan_mode) - ) + kwargs: dict[str, Any] = {"key": self._static_info.key} + if fan_mode in self._static_info.supported_custom_fan_modes: + kwargs["custom_fan_mode"] = fan_mode + else: + kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) + await self._client.climate_command(**kwargs) async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" await self._client.climate_command( - key=self._static_info.key, swing_mode=_swing_modes.from_hass(swing_mode) + key=self._static_info.key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index e31fa202a39be..b73743ee950de 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -2,18 +2,28 @@ from __future__ import annotations from collections import OrderedDict - -from aioesphomeapi import APIClient, APIConnectionError +from typing import Any + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + DeviceInfo, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, + ResolveAPIError, +) import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.data_entry_flow import FlowResult + +from . import CONF_NOISE_PSK, DOMAIN, DomainData -from . import DOMAIN -from .entry_data import RuntimeEntryData +ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -21,20 +31,21 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" self._host: str | None = None self._port: int | None = None self._password: str | None = None + self._noise_psk: str | None = None + self._device_info: DeviceInfo | None = None - async def async_step_user( - self, user_input: ConfigType | None = None, error: str | None = None - ): # pylint: disable=arguments-differ - """Handle a flow initialized by the user.""" + async def _async_step_user_base( + self, user_input: dict[str, Any] | None = None, error: str | None = None + ) -> FlowResult: if user_input is not None: - return await self._async_authenticate_or_add(user_input) + return await self._async_try_fetch_device_info(user_input) - fields = OrderedDict() + fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int @@ -46,67 +57,112 @@ async def async_step_user( step_id="user", data_schema=vol.Schema(fields), errors=errors ) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return await self._async_step_user_base(user_input=user_input) + + async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle a flow initialized by a reauth event.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + self._password = entry.data[CONF_PASSWORD] + self._noise_psk = entry.data.get(CONF_NOISE_PSK) + self._name = entry.title + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + self._noise_psk = user_input[CONF_NOISE_PSK] + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), + errors=errors, + description_placeholders={"name": self._name}, + ) + @property - def _name(self): + def _name(self) -> str | None: return self.context.get(CONF_NAME) @_name.setter - def _name(self, value): + def _name(self, value: str) -> None: self.context[CONF_NAME] = value self.context["title_placeholders"] = {"name": self._name} - def _set_user_input(self, user_input): + def _set_user_input(self, user_input: dict[str, Any] | None) -> None: if user_input is None: return self._host = user_input[CONF_HOST] self._port = user_input[CONF_PORT] - async def _async_authenticate_or_add(self, user_input): + async def _async_try_fetch_device_info( + self, user_input: dict[str, Any] | None + ) -> FlowResult: self._set_user_input(user_input) - error, device_info = await self.fetch_device_info() + error = await self.fetch_device_info() + if error == ERROR_REQUIRES_ENCRYPTION_KEY: + return await self.async_step_encryption_key() if error is not None: - return await self.async_step_user(error=error) - self._name = device_info.name + return await self._async_step_user_base(error=error) + return await self._async_authenticate_or_add() + async def _async_authenticate_or_add(self) -> FlowResult: # Only show authentication step if device uses password - if device_info.uses_password: + assert self._device_info is not None + if self._device_info.uses_password: return await self.async_step_authenticate() return self._async_get_entry() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: - return await self._async_authenticate_or_add(None) + return await self._async_try_fetch_device_info(None) return self.async_show_form( step_id="discovery_confirm", description_placeholders={"name": self._name} ) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: livingroom.local. - local_name = discovery_info["hostname"][:-1] + local_name = discovery_info.hostname[:-1] node_name = local_name[: -len(".local")] - address = discovery_info["properties"].get("address", local_name) + address = discovery_info.properties.get("address", local_name) # Check if already configured await self.async_set_unique_id(node_name) - self._abort_if_unique_id_configured( - updates={CONF_HOST: discovery_info[CONF_HOST]} - ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) for entry in self._async_current_entries(): already_configured = False - if CONF_HOST in entry.data and entry.data[CONF_HOST] in [ + if CONF_HOST in entry.data and entry.data[CONF_HOST] in ( address, - discovery_info[CONF_HOST], - ]: + discovery_info.host, + ): # Is this address or IP address already configured? already_configured = True - elif entry.entry_id in self.hass.data.get(DOMAIN, {}): + elif DomainData.get(self.hass).is_entry_loaded(entry): # Does a config entry with this name already exist? - data: RuntimeEntryData = self.hass.data[DOMAIN][entry.entry_id] + data = DomainData.get(self.hass).get_entry_data(entry) # Node names are unique in the network if data.device_info is not None: @@ -117,31 +173,69 @@ async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): if not entry.unique_id: self.hass.config_entries.async_update_entry( entry, - data={**entry.data, CONF_HOST: discovery_info[CONF_HOST]}, + data={ + **entry.data, + CONF_HOST: discovery_info.host, + }, unique_id=node_name, ) return self.async_abort(reason="already_configured") - self._host = discovery_info[CONF_HOST] - self._port = discovery_info[CONF_PORT] + self._host = discovery_info.host + self._port = discovery_info.port self._name = node_name return await self.async_step_discovery_confirm() @callback - def _async_get_entry(self): + def _async_get_entry(self) -> FlowResult: + config_data = { + CONF_HOST: self._host, + CONF_PORT: self._port, + # The API uses protobuf, so empty string denotes absence + CONF_PASSWORD: self._password or "", + CONF_NOISE_PSK: self._noise_psk or "", + } + if "entry_id" in self.context: + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self.hass.config_entries.async_update_entry(entry, data=config_data) + # Reload the config entry to notify of updated config + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + + assert self._name is not None return self.async_create_entry( title=self._name, - data={ - CONF_HOST: self._host, - CONF_PORT: self._port, - # The API uses protobuf, so empty string denotes absence - CONF_PASSWORD: self._password or "", - }, + data=config_data, + ) + + async def async_step_encryption_key( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle getting psk for transport encryption.""" + errors = {} + if user_input is not None: + self._noise_psk = user_input[CONF_NOISE_PSK] + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + errors["base"] = error + + return self.async_show_form( + step_id="encryption_key", + data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), + errors=errors, + description_placeholders={"name": self._name}, ) - async def async_step_authenticate(self, user_input=None, error=None): + async def async_step_authenticate( + self, user_input: dict[str, Any] | None = None, error: str | None = None + ) -> FlowResult: """Handle getting password for authentication.""" if user_input is not None: self._password = user_input[CONF_PASSWORD] @@ -161,44 +255,57 @@ async def async_step_authenticate(self, user_input=None, error=None): errors=errors, ) - async def fetch_device_info(self): + async def fetch_device_info(self) -> str | None: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) + assert self._host is not None + assert self._port is not None cli = APIClient( - self.hass.loop, self._host, self._port, "", zeroconf_instance=zeroconf_instance, + noise_psk=self._noise_psk, ) try: await cli.connect() - device_info = await cli.device_info() - except APIConnectionError as err: - if "resolving" in str(err): - return "resolve_error", None - return "connection_error", None + self._device_info = await cli.device_info() + except RequiresEncryptionAPIError: + return ERROR_REQUIRES_ENCRYPTION_KEY + except InvalidEncryptionKeyAPIError: + return "invalid_psk" + except ResolveAPIError: + return "resolve_error" + except APIConnectionError: + return "connection_error" finally: await cli.disconnect(force=True) - return None, device_info + self._name = self._device_info.name + + return None - async def try_login(self): + async def try_login(self) -> str | None: """Try logging in to device and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) + assert self._host is not None + assert self._port is not None cli = APIClient( - self.hass.loop, self._host, self._port, self._password, zeroconf_instance=zeroconf_instance, + noise_psk=self._noise_psk, ) try: await cli.connect(login=True) + except InvalidAuthAPIError: + return "invalid_auth" except APIConnectionError: + return "connection_error" + finally: await cli.disconnect(force=True) - return "invalid_auth" return None diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 3f4bd29198cfd..0e23050646fb7 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -1,11 +1,14 @@ """Support for ESPHome covers.""" from __future__ import annotations +from typing import Any + from aioesphomeapi import CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, + DEVICE_CLASSES, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT, SUPPORT_OPEN, @@ -17,12 +20,13 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome covers based on a config entry.""" await platform_async_setup_entry( @@ -36,12 +40,12 @@ async def async_setup_entry( ) -class EsphomeCover(EsphomeEntity, CoverEntity): - """A cover implementation for ESPHome.""" +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method - @property - def _static_info(self) -> CoverInfo: - return super()._static_info + +class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): + """A cover implementation for ESPHome.""" @property def supported_features(self) -> int: @@ -54,8 +58,10 @@ def supported_features(self) -> int: return flags @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" + if self._static_info.device_class not in DEVICE_CLASSES: + return None return self._static_info.device_class @property @@ -63,18 +69,11 @@ def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._static_info.assumed_state - @property - def _state(self) -> CoverState | None: - return super()._state - - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method - @esphome_state_property def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" # Check closed state with api version due to a protocol change - return self._state.is_closed(self._client.api_version) + return self._state.is_closed(self._api_version) @esphome_state_property def is_opening(self) -> bool: @@ -94,39 +93,39 @@ def current_cover_position(self) -> int | None: return round(self._state.position * 100.0) @esphome_state_property - def current_cover_tilt_position(self) -> float | None: + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. 0 is closed, 100 is open.""" if not self._static_info.supports_tilt: return None - return self._state.tilt * 100.0 + return round(self._state.tilt * 100.0) - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._client.cover_command(key=self._static_info.key, position=1.0) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._client.cover_command(key=self._static_info.key, position=0.0) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._client.cover_command(key=self._static_info.key, stop=True) - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: int) -> None: """Move the cover to a specific position.""" await self._client.cover_command( key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 ) - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" await self._client.cover_command(key=self._static_info.key, tilt=1.0) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" await self._client.cover_command(key=self._static_info.key, tilt=0.0) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: int) -> None: """Move the cover tilt to a specific position.""" await self._client.cover_command( key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100 diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index c5d36e3a68d56..e7bbc27141cb5 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,10 +2,14 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, + APIClient, + APIVersion, BinarySensorInfo, CameraInfo, ClimateInfo, @@ -15,62 +19,64 @@ EntityState, FanInfo, LightInfo, + NumberInfo, + SelectInfo, SensorInfo, SwitchInfo, TextSensorInfo, UserService, ) -import attr +from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -if TYPE_CHECKING: - from . import APIClient - SAVE_DELAY = 120 # Mapping from ESPHome info type to HA platform -INFO_TYPE_TO_PLATFORM = { +INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { BinarySensorInfo: "binary_sensor", + ButtonInfo: "button", CameraInfo: "camera", ClimateInfo: "climate", CoverInfo: "cover", FanInfo: "fan", LightInfo: "light", + NumberInfo: "number", + SelectInfo: "select", SensorInfo: "sensor", SwitchInfo: "switch", TextSensorInfo: "sensor", } -@attr.s +@dataclass class RuntimeEntryData: """Store runtime data for esphome config entries.""" - _storage_contents: dict | None = None - - entry_id: str = attr.ib() - client: APIClient = attr.ib() - store: Store = attr.ib() - state: dict[str, dict[str, Any]] = attr.ib(factory=dict) - info: dict[str, dict[str, Any]] = attr.ib(factory=dict) + entry_id: str + client: APIClient + store: Store + state: dict[str, dict[int, EntityState]] = field(default_factory=dict) + info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) # A second list of EntityInfo objects # This is necessary for when an entity is being removed. HA requires # some static info to be accessible during removal (unique_id, maybe others) # If an entity can't find anything in the info array, it will look for info here. - old_info: dict[str, dict[str, Any]] = attr.ib(factory=dict) - - services: dict[int, UserService] = attr.ib(factory=dict) - available: bool = attr.ib(default=False) - device_info: DeviceInfo | None = attr.ib(default=None) - cleanup_callbacks: list[Callable[[], None]] = attr.ib(factory=list) - disconnect_callbacks: list[Callable[[], None]] = attr.ib(factory=list) - loaded_platforms: set[str] = attr.ib(factory=set) - platform_load_lock: asyncio.Lock = attr.ib(factory=asyncio.Lock) + old_info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) + + services: dict[int, UserService] = field(default_factory=dict) + available: bool = False + device_info: DeviceInfo | None = None + api_version: APIVersion = field(default_factory=APIVersion) + cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) + disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + loaded_platforms: set[str] = field(default_factory=set) + platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + _storage_contents: dict[str, Any] | None = None @callback def async_update_entity( @@ -90,7 +96,7 @@ def async_remove_entity( async def _ensure_platforms_loaded( self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[str] - ): + ) -> None: async with self.platform_load_lock: needed = platforms - self.loaded_platforms tasks = [] @@ -133,21 +139,20 @@ def async_update_device_state(self, hass: HomeAssistant) -> None: async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserService]]: """Load the retained data from store and return de-serialized data.""" - restored = await self.store.async_load() - if restored is None: + if (restored := await self.store.async_load()) is None: return [], [] + restored = cast("dict[str, Any]", restored) self._storage_contents = restored.copy() - self.device_info = _attr_obj_from_dict( - DeviceInfo, **restored.pop("device_info") - ) + self.device_info = DeviceInfo.from_dict(restored.pop("device_info")) + self.api_version = APIVersion.from_dict(restored.pop("api_version", {})) infos = [] for comp_type, restored_infos in restored.items(): if comp_type not in COMPONENT_TYPE_TO_INFO: continue for info in restored_infos: cls = COMPONENT_TYPE_TO_INFO[comp_type] - infos.append(_attr_obj_from_dict(cls, **info)) + infos.append(cls.from_dict(info)) services = [] for service in restored.get("services", []): services.append(UserService.from_dict(service)) @@ -155,22 +160,24 @@ async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserServic async def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" - store_data = {"device_info": attr.asdict(self.device_info), "services": []} + if self.device_info is None: + raise ValueError("device_info is not set yet") + store_data: dict[str, Any] = { + "device_info": self.device_info.to_dict(), + "services": [], + "api_version": self.api_version.to_dict(), + } for comp_type, infos in self.info.items(): - store_data[comp_type] = [attr.asdict(info) for info in infos.values()] + store_data[comp_type] = [info.to_dict() for info in infos.values()] for service in self.services.values(): store_data["services"].append(service.to_dict()) if store_data == self._storage_contents: return - def _memorized_storage(): + def _memorized_storage() -> dict[str, Any]: self._storage_contents = store_data return store_data self.store.async_delay_save(_memorized_storage, SAVE_DELAY) - - -def _attr_obj_from_dict(cls, **kwargs): - return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) if key in kwargs}) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 5272cdef5f127..6abce0914cbbd 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +from typing import Any from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState @@ -15,6 +16,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -24,7 +26,7 @@ from . import ( EsphomeEntity, - esphome_map_enum, + EsphomeEnumMapper, esphome_state_property, platform_async_setup_entry, ) @@ -33,7 +35,7 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome fans based on a config entry.""" await platform_async_setup_entry( @@ -47,37 +49,33 @@ async def async_setup_entry( ) -@esphome_map_enum -def _fan_directions(): - return { +_FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection, str] = EsphomeEnumMapper( + { FanDirection.FORWARD: DIRECTION_FORWARD, FanDirection.REVERSE: DIRECTION_REVERSE, } +) -class EsphomeFan(EsphomeEntity, FanEntity): - """A fan implementation for ESPHome.""" +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method - @property - def _static_info(self) -> FanInfo: - return super()._static_info - @property - def _state(self) -> FanState | None: - return super()._state +class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): + """A fan implementation for ESPHome.""" @property def _supports_speed_levels(self) -> bool: - api_version = self._client.api_version + api_version = self._api_version return api_version.major == 1 and api_version.minor > 3 - async def async_set_percentage(self, percentage: int) -> None: + async def async_set_percentage(self, percentage: int | None) -> None: """Set the speed percentage of the fan.""" if percentage == 0: await self.async_turn_off() return - data = {"key": self._static_info.key, "state": True} + data: dict[str, Any] = {"key": self._static_info.key, "state": True} if percentage is not None: if self._supports_speed_levels: data["speed_level"] = math.ceil( @@ -97,12 +95,12 @@ async def async_turn_on( speed: str | None = None, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self._client.fan_command(key=self._static_info.key, state=False) @@ -112,17 +110,14 @@ async def async_oscillate(self, oscillating: bool) -> None: key=self._static_info.key, oscillating=oscillating ) - async def async_set_direction(self, direction: str): + async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" await self._client.fan_command( - key=self._static_info.key, direction=_fan_directions.from_hass(direction) + key=self._static_info.key, direction=_FAN_DIRECTIONS.from_hass(direction) ) - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method - @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool | None: # type: ignore[override] """Return true if the entity is on.""" return self._state.state @@ -134,7 +129,7 @@ def percentage(self) -> int | None: if not self._supports_speed_levels: return ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._state.speed + ORDERED_NAMED_FAN_SPEEDS, self._state.speed # type: ignore[misc] ) return ranged_value_to_percentage( @@ -149,18 +144,18 @@ def speed_count(self) -> int: return self._static_info.supported_speed_levels @esphome_state_property - def oscillating(self) -> None: + def oscillating(self) -> bool | None: """Return the oscillation state.""" if not self._static_info.supports_oscillation: return None return self._state.oscillating @esphome_state_property - def current_direction(self) -> None: + def current_direction(self) -> str | None: """Return the current fan direction.""" if not self._static_info.supports_direction: return None - return _fan_directions.from_esphome(self._state.direction) + return _FAN_DIRECTIONS.from_esphome(self._state.direction) @property def supported_features(self) -> int: diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index d1f567c3c8efa..eb5e258f079e2 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,30 +1,38 @@ """Support for ESPHome lights.""" from __future__ import annotations -from aioesphomeapi import LightInfo, LightState +from typing import Any, cast + +from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, - ATTR_WHITE_VALUE, + ATTR_WHITE, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, + COLOR_MODE_UNKNOWN, + COLOR_MODE_WHITE, FLASH_LONG, FLASH_SHORT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, - SUPPORT_WHITE_VALUE, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -32,7 +40,7 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome lights based on a config entry.""" await platform_async_setup_entry( @@ -46,49 +54,218 @@ async def async_setup_entry( ) -class EsphomeLight(EsphomeEntity, LightEntity): - """A switch implementation for ESPHome.""" +_COLOR_MODE_MAPPING = { + COLOR_MODE_ONOFF: [ + LightColorCapability.ON_OFF, + ], + COLOR_MODE_BRIGHTNESS: [ + LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + # for compatibility with older clients (2021.8.x) + LightColorCapability.BRIGHTNESS, + ], + COLOR_MODE_COLOR_TEMP: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_RGB: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB, + ], + COLOR_MODE_RGBW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE, + ], + COLOR_MODE_RGBWW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_WHITE: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ], +} - @property - def _static_info(self) -> LightInfo: - return super()._static_info - @property - def _state(self) -> LightState | None: - return super()._state +def _color_mode_to_ha(mode: int) -> str: + """Convert an esphome color mode to a HA color mode constant. + + Choses the color mode that best matches the feature-set. + """ + candidates = [] + for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): + for caps in cap_lists: + if caps == mode: + # exact match + return ha_mode + if (mode & caps) == caps: + # all requirements met + candidates.append((ha_mode, caps)) + + if not candidates: + return COLOR_MODE_UNKNOWN + + # choose the color mode with the most bits set + candidates.sort(key=lambda key: bin(key[1]).count("1")) + return candidates[-1][0] + + +def _filter_color_modes( + supported: list[int], features: LightColorCapability +) -> list[int]: + """Filter the given supported color modes, excluding all values that don't have the requested features.""" + return [mode for mode in supported if mode & features] + + +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): + """A light implementation for ESPHome.""" - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method + @property + def _supports_color_mode(self) -> bool: + """Return whether the client supports the new color mode system natively.""" + return self._api_version >= APIVersion(1, 6) @esphome_state_property - def is_on(self) -> bool | None: - """Return true if the switch is on.""" + def is_on(self) -> bool | None: # type: ignore[override] + """Return true if the light is on.""" return self._state.state - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - data = {"key": self._static_info.key, "state": True} - if ATTR_HS_COLOR in kwargs: - hue, sat = kwargs[ATTR_HS_COLOR] - red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100) - data["rgb"] = (red / 255, green / 255, blue / 255) - if ATTR_FLASH in kwargs: - data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] - if ATTR_TRANSITION in kwargs: - data["transition_length"] = kwargs[ATTR_TRANSITION] - if ATTR_BRIGHTNESS in kwargs: - data["brightness"] = kwargs[ATTR_BRIGHTNESS] / 255 - if ATTR_COLOR_TEMP in kwargs: - data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] - if ATTR_EFFECT in kwargs: - data["effect"] = kwargs[ATTR_EFFECT] - if ATTR_WHITE_VALUE in kwargs: - data["white"] = kwargs[ATTR_WHITE_VALUE] / 255 + data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # The list of color modes that would fit this service call + color_modes = self._native_supported_color_modes + try_keep_current_mode = True + + # rgb/brightness input is in range 0-255, but esphome uses 0-1 + + if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: + data["brightness"] = brightness_ha / 255 + color_modes = _filter_color_modes( + color_modes, LightColorCapability.BRIGHTNESS + ) + + if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: + rgb = tuple(x / 255 for x in rgb_ha) + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + data["color_brightness"] = color_bri + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + try_keep_current_mode = False + + if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: + # pylint: disable=invalid-name + *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment] + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + data["white"] = w + data["color_brightness"] = color_bri + color_modes = _filter_color_modes( + color_modes, LightColorCapability.RGB | LightColorCapability.WHITE + ) + try_keep_current_mode = False + + if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: + # pylint: disable=invalid-name + *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment] + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE): + # Device supports setting cwww values directly + data["cold_white"] = cw + data["warm_white"] = ww + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + else: + # need to convert cw+ww part to white+color_temp + white = data["white"] = max(cw, ww) + if white != 0: + min_ct = self.min_mireds + max_ct = self.max_mireds + ct_ratio = ww / (cw + ww) + data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE, + ) + try_keep_current_mode = False + + data["color_brightness"] = color_bri + + if (flash := kwargs.get(ATTR_FLASH)) is not None: + data["flash_length"] = FLASH_LENGTHS[flash] + + if (transition := kwargs.get(ATTR_TRANSITION)) is not None: + data["transition_length"] = transition + + if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: + data["color_temperature"] = color_temp + if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ) + else: + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + try_keep_current_mode = False + + if (effect := kwargs.get(ATTR_EFFECT)) is not None: + data["effect"] = effect + + if (white_ha := kwargs.get(ATTR_WHITE)) is not None: + # ESPHome multiplies brightness and white together for final brightness + # HA only sends `white` in turn_on, and reads total brightness through brightness property + data["brightness"] = white_ha / 255 + data["white"] = 1.0 + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + ) + try_keep_current_mode = False + + if self._supports_color_mode and color_modes: + if ( + try_keep_current_mode + and self._state is not None + and self._state.color_mode in color_modes + ): + # if possible, stay with the color mode that is already set + data["color_mode"] = self._state.color_mode + else: + # otherwise try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] + await self._client.light_command(**data) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - data = {"key": self._static_info.key, "state": False} + data: dict[str, Any] = {"key": self._static_info.key, "state": False} if ATTR_FLASH in kwargs: data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: @@ -101,55 +278,115 @@ def brightness(self) -> int | None: return round(self._state.brightness * 255) @esphome_state_property - def hs_color(self) -> tuple[float, float] | None: - """Return the hue and saturation color value [float, float].""" - return color_util.color_RGB_to_hs( - self._state.red * 255, self._state.green * 255, self._state.blue * 255 + def color_mode(self) -> str | None: + """Return the color mode of the light.""" + if not self._supports_color_mode: + if not (supported := self.supported_color_modes): + return None + return next(iter(supported)) + + return _color_mode_to_ha(self._state.color_mode) + + @esphome_state_property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color value [int, int, int].""" + if not self._supports_color_mode: + return ( + round(self._state.red * 255), + round(self._state.green * 255), + round(self._state.blue * 255), + ) + + return ( + round(self._state.red * self._state.color_brightness * 255), + round(self._state.green * self._state.color_brightness * 255), + round(self._state.blue * self._state.color_brightness * 255), ) @esphome_state_property - def color_temp(self) -> float | None: - """Return the CT color value in mireds.""" - return self._state.color_temperature + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the rgbw color value [int, int, int, int].""" + white = round(self._state.white * 255) + rgb = cast("tuple[int, int, int]", self.rgb_color) + return (*rgb, white) @esphome_state_property - def white_value(self) -> int | None: - """Return the white value of this light between 0..255.""" - return round(self._state.white * 255) + def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + """Return the rgbww color value [int, int, int, int, int].""" + rgb = cast("tuple[int, int, int]", self.rgb_color) + if not _filter_color_modes( + self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE + ): + # Try to reverse white + color temp to cwww + min_ct = self._static_info.min_mireds + max_ct = self._static_info.max_mireds + color_temp = min(max(self._state.color_temperature, min_ct), max_ct) + white = self._state.white + + ww_frac = (color_temp - min_ct) / (max_ct - min_ct) + cw_frac = 1 - ww_frac + + return ( + *rgb, + round(white * cw_frac / max(cw_frac, ww_frac) * 255), + round(white * ww_frac / max(cw_frac, ww_frac) * 255), + ) + return ( + *rgb, + round(self._state.cold_white * 255), + round(self._state.warm_white * 255), + ) + + @esphome_state_property + def color_temp(self) -> float | None: # type: ignore[override] + """Return the CT color value in mireds.""" + return self._state.color_temperature @esphome_state_property def effect(self) -> str | None: """Return the current effect.""" return self._state.effect + @property + def _native_supported_color_modes(self) -> list[int]: + return self._static_info.supported_color_modes_compat(self._api_version) + @property def supported_features(self) -> int: """Flag supported features.""" flags = SUPPORT_FLASH - if self._static_info.supports_brightness: - flags |= SUPPORT_BRIGHTNESS + + # All color modes except UNKNOWN,ON_OFF support transition + modes = self._native_supported_color_modes + if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION - if self._static_info.supports_rgb: - flags |= SUPPORT_COLOR - if self._static_info.supports_white_value: - flags |= SUPPORT_WHITE_VALUE - if self._static_info.supports_color_temperature: - flags |= SUPPORT_COLOR_TEMP if self._static_info.effects: flags |= SUPPORT_EFFECT return flags + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + if COLOR_MODE_ONOFF in supported and len(supported) > 1: + supported.remove(COLOR_MODE_ONOFF) + if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1: + supported.remove(COLOR_MODE_BRIGHTNESS) + if COLOR_MODE_WHITE in supported and len(supported) == 1: + supported.remove(COLOR_MODE_WHITE) + return supported + @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._static_info.effects @property - def min_mireds(self) -> float: + def min_mireds(self) -> float: # type: ignore[override] """Return the coldest color_temp that this light supports.""" return self._static_info.min_mireds @property - def max_mireds(self) -> float: + def max_mireds(self) -> float: # type: ignore[override] """Return the warmest color_temp that this light supports.""" return self._static_info.max_mireds diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 2f60c84a828ef..527fc6bf7685b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,9 +3,9 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==2.6.6"], + "requirements": ["aioesphomeapi==10.6.0"], "zeroconf": ["_esphomelib._tcp.local."], - "codeowners": ["@OttoWinter"], + "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], "iot_class": "local_push" } diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py new file mode 100644 index 0000000000000..be27779437d93 --- /dev/null +++ b/homeassistant/components/esphome/number.py @@ -0,0 +1,92 @@ +"""Support for esphome numbers.""" +from __future__ import annotations + +import math + +from aioesphomeapi import NumberInfo, NumberMode as EsphomeNumberMode, NumberState + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + EsphomeEntity, + EsphomeEnumMapper, + esphome_state_property, + platform_async_setup_entry, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome numbers based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="number", + info_type=NumberInfo, + entity_type=EsphomeNumber, + state_type=NumberState, + ) + + +NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapper( + { + EsphomeNumberMode.AUTO: NumberMode.AUTO, + EsphomeNumberMode.BOX: NumberMode.BOX, + EsphomeNumberMode.SLIDER: NumberMode.SLIDER, + } +) + + +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): + """A number implementation for esphome.""" + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return super()._static_info.min_value + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return super()._static_info.max_value + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + return super()._static_info.step + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + return super()._static_info.unit_of_measurement + + @property + def mode(self) -> NumberMode: + """Return the mode of the entity.""" + if self._static_info.mode: + return NUMBER_MODES.from_esphome(self._static_info.mode) + return NumberMode.AUTO + + @esphome_state_property + def value(self) -> float | None: + """Return the state of the entity.""" + if math.isnan(self._state.state): + return None + if self._state.missing_state: + return None + return self._state.state + + async def async_set_value(self, value: float) -> None: + """Update the current value.""" + await self._client.number_command(self._static_info.key, value) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py new file mode 100644 index 0000000000000..f3bfcb982ea58 --- /dev/null +++ b/homeassistant/components/esphome/select.py @@ -0,0 +1,52 @@ +"""Support for esphome selects.""" +from __future__ import annotations + +from aioesphomeapi import SelectInfo, SelectState + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome selects based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="select", + info_type=SelectInfo, + entity_type=EsphomeSelect, + state_type=SelectState, + ) + + +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): + """A select implementation for esphome.""" + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self._static_info.options + + @esphome_state_property + def current_option(self) -> str | None: + """Return the state of the entity.""" + if self._state.missing_state: + return None + return self._state.state + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self._client.select_command(self._static_info.key, option) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 12319be8c400b..45a73a5e5af06 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,28 +1,39 @@ """Support for esphome sensors.""" from __future__ import annotations +from datetime import datetime import math -from aioesphomeapi import SensorInfo, SensorState, TextSensorInfo, TextSensorState -import voluptuous as vol +from aioesphomeapi import ( + SensorInfo, + SensorState, + SensorStateClass as EsphomeSensorStateClass, + TextSensorInfo, + TextSensorState, +) +from aioesphomeapi.model import LastResetType from homeassistant.components.sensor import ( - DEVICE_CLASS_TIMESTAMP, DEVICE_CLASSES, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry - -ICON_SCHEMA = vol.Schema(cv.icon) +from . import ( + EsphomeEntity, + EsphomeEnumMapper, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up esphome sensors based on a config entry.""" await platform_async_setup_entry( @@ -49,23 +60,19 @@ async def async_setup_entry( # pylint: disable=invalid-overridden-method -class EsphomeSensor(EsphomeEntity, SensorEntity): - """A sensor implementation for esphome.""" - - @property - def _static_info(self) -> SensorInfo: - return super()._static_info +_STATE_CLASSES: EsphomeEnumMapper[ + EsphomeSensorStateClass, SensorStateClass | None +] = EsphomeEnumMapper( + { + EsphomeSensorStateClass.NONE: None, + EsphomeSensorStateClass.MEASUREMENT: SensorStateClass.MEASUREMENT, + EsphomeSensorStateClass.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, + } +) - @property - def _state(self) -> SensorState | None: - return super()._state - @property - def icon(self) -> str: - """Return the icon.""" - if not self._static_info.icon or self._static_info.device_class: - return None - return ICON_SCHEMA(self._static_info.icon) +class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): + """A sensor implementation for esphome.""" @property def force_update(self) -> bool: @@ -73,49 +80,51 @@ def force_update(self) -> bool: return self._static_info.force_update @esphome_state_property - def state(self) -> str | None: + def native_value(self) -> datetime | str | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None if self._state.missing_state: return None - if self.device_class == DEVICE_CLASS_TIMESTAMP: - return dt.utc_from_timestamp(self._state.state).isoformat() + if self.device_class == SensorDeviceClass.TIMESTAMP: + return dt.utc_from_timestamp(self._state.state) return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if not self._static_info.unit_of_measurement: return None return self._static_info.unit_of_measurement @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if self._static_info.device_class not in DEVICE_CLASSES: return None return self._static_info.device_class - -class EsphomeTextSensor(EsphomeEntity, SensorEntity): - """A text sensor implementation for ESPHome.""" - - @property - def _static_info(self) -> TextSensorInfo: - return super()._static_info - - @property - def _state(self) -> TextSensorState | None: - return super()._state - @property - def icon(self) -> str: - """Return the icon.""" - return self._static_info.icon + def state_class(self) -> SensorStateClass | None: + """Return the state class of this entity.""" + if not self._static_info.state_class: + return None + state_class = self._static_info.state_class + reset_type = self._static_info.last_reset_type + if ( + state_class == EsphomeSensorStateClass.MEASUREMENT + and reset_type == LastResetType.AUTO + ): + # Legacy, last_reset_type auto was the equivalent to the TOTAL_INCREASING state class + return SensorStateClass.TOTAL_INCREASING + return _STATE_CLASSES.from_esphome(self._static_info.state_class) + + +class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): + """A text sensor implementation for ESPHome.""" @esphome_state_property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if self._state.missing_state: return None diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 6d1c9a91e3df1..62814f2723b32 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,12 +2,14 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips", "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration" }, "step": { "user": { @@ -23,6 +25,18 @@ }, "description": "Please enter the password you set in your configuration for {name}." }, + "encryption_key": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "Please enter the encryption key you set in your configuration for {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key." + }, "discovery_confirm": { "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 341068b05ad38..a8f0febf5b0c0 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -1,17 +1,20 @@ """Support for ESPHome switches.""" from __future__ import annotations +from typing import Any + from aioesphomeapi import SwitchInfo, SwitchState from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome switches based on a config entry.""" await platform_async_setup_entry( @@ -25,38 +28,27 @@ async def async_setup_entry( ) -class EsphomeSwitch(EsphomeEntity, SwitchEntity): - """A switch implementation for ESPHome.""" +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method - @property - def _static_info(self) -> SwitchInfo: - return super()._static_info - @property - def _state(self) -> SwitchState | None: - return super()._state - - @property - def icon(self) -> str: - """Return the icon.""" - return self._static_info.icon +class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): + """A switch implementation for ESPHome.""" @property def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._static_info.assumed_state - # https://github.com/PyCQA/pylint/issues/3150 for @esphome_state_property - # pylint: disable=invalid-overridden-method @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool | None: # type: ignore[override] """Return true if the switch is on.""" return self._state.state - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._client.switch_command(self._static_info.key, True) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._client.switch_command(self._static_info.key, False) diff --git a/homeassistant/components/esphome/translations/bg.json b/homeassistant/components/esphome/translations/bg.json index 1a92f62cbb600..699a993403f62 100644 --- a/homeassistant/components/esphome/translations/bg.json +++ b/homeassistant/components/esphome/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "ESP \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + "already_configured": "ESP \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 ESP. \u041c\u043e\u043b\u044f, \u0443\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u0432\u0430\u0448\u0438\u044f\u0442 YAML \u0444\u0430\u0439\u043b \u0441\u044a\u0434\u044a\u0440\u0436\u0430 \u0440\u0435\u0434 \"api:\".", @@ -19,6 +20,17 @@ "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u0435 ESPHome \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e ` {name} ` \u043a\u044a\u043c Home Assistant?", "title": "\u041e\u0442\u043a\u0440\u0438\u0442\u043e \u0435 ESPHome \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" }, + "encryption_key": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0437\u0430 \u043a\u0440\u0438\u043f\u0442\u0438\u0440\u0430\u043d\u0435" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043a\u043b\u044e\u0447\u0430 \u0437\u0430 \u043a\u0440\u0438\u043f\u0442\u0438\u0440\u0430\u043d\u0435, \u043a\u043e\u0439\u0442\u043e \u0441\u0442\u0435 \u0437\u0430\u0434\u0430\u043b\u0438 \u0432\u044a\u0432 \u0432\u0430\u0448\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0437\u0430 {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0437\u0430 \u043a\u0440\u0438\u043f\u0442\u0438\u0440\u0430\u043d\u0435" + } + }, "user": { "data": { "host": "\u0410\u0434\u0440\u0435\u0441", diff --git a/homeassistant/components/esphome/translations/ca.json b/homeassistant/components/esphome/translations/ca.json index 25374cbbd00f5..4c990994e47f1 100644 --- a/homeassistant/components/esphome/translations/ca.json +++ b/homeassistant/components/esphome/translations/ca.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs" + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "connection_error": "No s'ha pogut connectar amb ESP. Verifica que l'arxiu YAML cont\u00e9 la l\u00ednia 'api:'.", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_psk": "La clau de xifratge de transport \u00e9s inv\u00e0lida. Assegura't que coincideix amb la de la configuraci\u00f3", "resolve_error": "No s'ha pogut trobar l'adre\u00e7a de l'ESP. Si l'error persisteix, configura una adre\u00e7a IP est\u00e0tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Vols afegir el node `{name}` d'ESPHome a Home Assistant?", "title": "Node d'ESPHome descobert" }, + "encryption_key": { + "data": { + "noise_psk": "Clau de xifrat" + }, + "description": "Introdueix la clau de xifrat de {name} establerta a la configuraci\u00f3." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Clau de xifrat" + }, + "description": "El dispositiu ESPHome {name} ha activat el xifratge de transport o ha canviat la clau de xifrat. Introdueix la clau actualitzada." + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/esphome/translations/cs.json b/homeassistant/components/esphome/translations/cs.json index 9a451a8537f79..fc4a7d5bf8c45 100644 --- a/homeassistant/components/esphome/translations/cs.json +++ b/homeassistant/components/esphome/translations/cs.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", - "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1" + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "connection_error": "Nelze se p\u0159ipojit k ESP. Zkontrolujte, zda va\u0161e YAML konfigurace obsahuje \u0159\u00e1dek 'api:'.", diff --git a/homeassistant/components/esphome/translations/de.json b/homeassistant/components/esphome/translations/de.json index fdaea452c4560..6229c09a03e9a 100644 --- a/homeassistant/components/esphome/translations/de.json +++ b/homeassistant/components/esphome/translations/de.json @@ -1,15 +1,17 @@ { "config": { "abort": { - "already_configured": "ESP ist bereits konfiguriert", - "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achte darauf, dass deine YAML-Datei eine Zeile 'api:' enth\u00e4lt.", "invalid_auth": "Ung\u00fcltige Authentifizierung", + "invalid_psk": "Der Transportverschl\u00fcsselungsschl\u00fcssel ist ung\u00fcltig. Bitte stelle sicher, dass es mit deiner Konfiguration \u00fcbereinstimmt", "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, lege eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?", "title": "Gefundener ESPHome-Knoten" }, + "encryption_key": { + "data": { + "noise_psk": "Verschl\u00fcsselungsschl\u00fcssel" + }, + "description": "Bitte gib den Verschl\u00fcsselungsschl\u00fcssel ein, den du in deiner Konfiguration f\u00fcr {name} festgelegt hast." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Verschl\u00fcsselungsschl\u00fcssel" + }, + "description": "Das ESPHome-Ger\u00e4t {name} hat die Transportverschl\u00fcsselung aktiviert oder den Verschl\u00fcsselungscode ge\u00e4ndert. Bitte gib den aktualisierten Schl\u00fcssel ein." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/en.json b/homeassistant/components/esphome/translations/en.json index c57c9d1acb002..5ca5c03f8e92b 100644 --- a/homeassistant/components/esphome/translations/en.json +++ b/homeassistant/components/esphome/translations/en.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress" + "already_in_progress": "Configuration flow is already in progress", + "reauth_successful": "Re-authentication was successful" }, "error": { "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", "invalid_auth": "Invalid authentication", + "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration", "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" }, + "encryption_key": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "Please enter the encryption key you set in your configuration for {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/es.json b/homeassistant/components/esphome/translations/es.json index 9c4b3f5240693..f7fd73cd227f9 100644 --- a/homeassistant/components/esphome/translations/es.json +++ b/homeassistant/components/esphome/translations/es.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "ESP ya est\u00e1 configurado", - "already_in_progress": "La configuraci\u00f3n del ESP ya est\u00e1 en marcha" + "already_in_progress": "La configuraci\u00f3n del ESP ya est\u00e1 en marcha", + "reauth_successful": "La re-autenticaci\u00f3n ha funcionado" }, "error": { "connection_error": "No se puede conectar a ESP. Aseg\u00farate de que tu archivo YAML contenga una l\u00ednea 'api:'.", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_psk": "La clave de transporte cifrado no es v\u00e1lida. Por favor, aseg\u00farese de que coincide con la que tiene en su configuraci\u00f3n", "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,6 +23,18 @@ "description": "\u00bfQuieres a\u00f1adir el nodo `{name}` de ESPHome a Home Assistant?", "title": "Nodo ESPHome descubierto" }, + "encryption_key": { + "data": { + "noise_psk": "Clave de cifrado" + }, + "description": "Por favor, introduzca la clave de cifrado que estableci\u00f3 en su configuraci\u00f3n para {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Clave de cifrado" + }, + "description": "El dispositivo ESPHome {name} ha activado el transporte cifrado o ha cambiado la clave de cifrado. Por favor, introduzca la clave actualizada." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/et.json b/homeassistant/components/esphome/translations/et.json index 18f69b7207b83..ea5119b190db3 100644 --- a/homeassistant/components/esphome/translations/et.json +++ b/homeassistant/components/esphome/translations/et.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "already_in_progress": "Seadistamine on juba k\u00e4imas" + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "connection_error": "ESP-ga ei saa \u00fchendust luua. Veendu, et YAML-fail sisaldab rida 'api:'.", "invalid_auth": "Tuvastamise viga", + "invalid_psk": "\u00dclekande kr\u00fcpteerimisv\u00f5ti on kehtetu. Veendu, et see vastab seadetes sisalduvale", "resolve_error": "ESP aadressi ei \u00f5nnestu lahendada. Kui see viga p\u00fcsib, m\u00e4\u00e4ra staatiline IP-aadress: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Kas soovid lisada ESPHome'i s\u00f5lme '{name}' Home Assistant-ile?", "title": "Avastastud ESPHome'i s\u00f5lm" }, + "encryption_key": { + "data": { + "noise_psk": "Kr\u00fcptimisv\u00f5ti" + }, + "description": "Sisesta kr\u00fcptimisv\u00f5ti mille m\u00e4\u00e4rasid oma {name} seadetes." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Kr\u00fcptimisv\u00f5ti" + }, + "description": "ESPHome seade {name} lubas \u00fclekande kr\u00fcptimise v\u00f5i muutis kr\u00fcpteerimisv\u00f5tit. Palun sisesta uuendatud v\u00f5ti." + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/esphome/translations/fr.json b/homeassistant/components/esphome/translations/fr.json index 0b977815f6b02..7125ef9c39561 100644 --- a/homeassistant/components/esphome/translations/fr.json +++ b/homeassistant/components/esphome/translations/fr.json @@ -1,15 +1,17 @@ { "config": { "abort": { - "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "La configuration ESP est d\u00e9j\u00e0 en cours" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", "invalid_auth": "Authentification invalide", + "invalid_psk": "La cl\u00e9 de chiffrement de transport n\u2019est pas valide. Assurez-vous qu\u2019elle correspond \u00e0 ce que vous avez dans votre configuration", "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,9 +23,21 @@ "description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?", "title": "N\u0153ud ESPHome d\u00e9couvert" }, + "encryption_key": { + "data": { + "noise_psk": "Cl\u00e9 de chiffrement" + }, + "description": "Entrez la cl\u00e9 de chiffrement que vous avez d\u00e9finie dans votre configuration pour {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Cl\u00e9 de chiffrement" + }, + "description": "L'appareil ESPHome {name} activ\u00e9 le cryptage de transport ou modifi\u00e9 la cl\u00e9 de cryptage. Veuillez saisir la cl\u00e9 mise \u00e0 jour." + }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "description": "Veuillez saisir les param\u00e8tres de connexion de votre n\u0153ud [ESPHome] (https://esphomelib.com/)." diff --git a/homeassistant/components/esphome/translations/he.json b/homeassistant/components/esphome/translations/he.json index 648d007cc469f..11eaf41ff1a64 100644 --- a/homeassistant/components/esphome/translations/he.json +++ b/homeassistant/components/esphome/translations/he.json @@ -1,9 +1,30 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{name}", "step": { "authenticate": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "description": "\u05d0\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d4\u05d2\u05d3\u05e8\u05ea \u05d1\u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc\u05da \u05e2\u05d1\u05d5\u05e8 {name}." + }, + "encryption_key": { + "data": { + "noise_psk": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05e6\u05e4\u05e0\u05d4" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" } } } diff --git a/homeassistant/components/esphome/translations/hu.json b/homeassistant/components/esphome/translations/hu.json index 6c4586fbd558b..17af0e57d26e2 100644 --- a/homeassistant/components/esphome/translations/hu.json +++ b/homeassistant/components/esphome/translations/hu.json @@ -2,31 +2,45 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van." + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { - "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rlek gy\u0151z\u0151dj meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.", + "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rem, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy a YAML konfigur\u00e1ci\u00f3 tartalmaz egy \"api:\" sort.", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rlek, \u00e1ll\u00edts be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + "invalid_psk": "Az adat\u00e1tviteli titkos\u00edt\u00e1si kulcs \u00e9rv\u00e9nytelen. K\u00e9rj\u00fck, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy megegyezik a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151vel.", + "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rem, \u00e1ll\u00edtson be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { "password": "Jelsz\u00f3" }, - "description": "K\u00e9rlek, add meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t." + "description": "K\u00e9rem, adja meg a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151, {name} jelszav\u00e1t." }, "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` ESPHome csom\u00f3pontot a Home Assistant-hoz?", - "title": "Felfedezett ESPHome csom\u00f3pont" + "description": "Szeretn\u00e9 hozz\u00e1adni `{name}` ESPHome csom\u00f3pontot Home Assistanthoz?", + "title": "ESPHome csom\u00f3pont felfedezve" + }, + "encryption_key": { + "data": { + "noise_psk": "Titkos\u00edt\u00e1si kulcs" + }, + "description": "K\u00e9rj\u00fck, adja meg a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151, {name} titkos\u00edt\u00e1si kulcs\u00e1t." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Titkos\u00edt\u00e1si kulcs" + }, + "description": "{name} ESPHome v\u00e9gpont aktiv\u00e1lta az adat\u00e1tviteli titkos\u00edt\u00e1st vagy megv\u00e1ltoztatta a titkos\u00edt\u00e1si kulcsot. K\u00e9rj\u00fck, adja meg az aktu\u00e1lis kulcsot." }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, - "description": "K\u00e9rlek, add meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontod kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." + "description": "K\u00e9rem, adja meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontj\u00e1nak kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." } } } diff --git a/homeassistant/components/esphome/translations/id.json b/homeassistant/components/esphome/translations/id.json index a39a19e12db3b..e099405bf0d0e 100644 --- a/homeassistant/components/esphome/translations/id.json +++ b/homeassistant/components/esphome/translations/id.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "already_in_progress": "Alur konfigurasi sedang berlangsung" + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "connection_error": "Tidak dapat terhubung ke ESP. Pastikan file YAML Anda mengandung baris 'api:'.", "invalid_auth": "Autentikasi tidak valid", + "invalid_psk": "Kunci enkripsi transport tidak valid. Pastikan kuncinya sesuai dengan yang ada pada konfigurasi Anda", "resolve_error": "Tidak dapat menemukan alamat ESP. Jika kesalahan ini terus terjadi, atur alamat IP statis: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Ingin menambahkan node ESPHome `{name}` ke Home Assistant?", "title": "Perangkat node ESPHome yang ditemukan" }, + "encryption_key": { + "data": { + "noise_psk": "Kunci enkripsi" + }, + "description": "Masukkan kunci enkripsi yang Anda atur dalam konfigurasi Anda untuk {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Kunci enkripsi" + }, + "description": "Perangkat ESPHome {name} mengaktifkan enkripsi transport atau telah mengubah kunci enkripsi. Masukkan kunci yang diperbarui." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/it.json b/homeassistant/components/esphome/translations/it.json index c21d8da71662e..62c75238378e6 100644 --- a/homeassistant/components/esphome/translations/it.json +++ b/homeassistant/components/esphome/translations/it.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso" + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { - "connection_error": "Impossibile connettersi ad ESP. Assicurati che il tuo file YAML contenga una riga \"api:\".", + "connection_error": "Impossibile connettersi a ESP. Assicurati che il tuo file YAML contenga una riga \"api:\".", "invalid_auth": "Autenticazione non valida", - "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, impostare un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + "invalid_psk": "La chiave di cifratura del trasporto non \u00e8 valida. Assicurati che corrisponda a ci\u00f2 che hai nella tua configurazione", + "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, imposta un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Vuoi aggiungere il nodo ESPHome `{name}` a Home Assistant?", "title": "Trovato nodo ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "Chiave di cifratura" + }, + "description": "Inserisci la chiave di cifratura che hai impostato nella configurazione per {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Chiave di cifratura" + }, + "description": "Il dispositivo ESPHome {name} ha abilitato la cifratura del trasporto o ha modificato la chiave di cifratura. Inserisci la chiave aggiornata." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/ja.json b/homeassistant/components/esphome/translations/ja.json new file mode 100644 index 0000000000000..12c452a013904 --- /dev/null +++ b/homeassistant/components/esphome/translations/ja.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "connection_error": "ESP\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3002YAML\u30d5\u30a1\u30a4\u30eb\u306b 'api:' \u306e\u884c\u304c\u542b\u307e\u308c\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_psk": "\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u6697\u53f7\u5316\u30ad\u30fc\u304c\u7121\u52b9\u3067\u3059\u3002\u8a2d\u5b9a\u3068\u4e00\u81f4\u3057\u3066\u3044\u308b\u304b\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "resolve_error": "ESP\u306e\u30a2\u30c9\u30ec\u30b9\u3092\u89e3\u6c7a\u3067\u304d\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u304c\u89e3\u6c7a\u3057\u306a\u3044\u5834\u5408\u306f\u3001IP\u30a2\u30c9\u30ec\u30b9\u3092\u9759\u7684\u306b\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "{name}", + "step": { + "authenticate": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{name} \u3067\u8a2d\u5b9a\u3057\u305f\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "discovery_confirm": { + "description": "ESPHome\u306e\u30ce\u30fc\u30c9 `{name}` \u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "\u691c\u51fa\u3055\u308c\u305fESPHome\u306e\u30ce\u30fc\u30c9" + }, + "encryption_key": { + "data": { + "noise_psk": "\u6697\u53f7\u5316\u30ad\u30fc" + }, + "description": "{name} \u3067\u8a2d\u5b9a\u3057\u305f\u6697\u53f7\u5316\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u6697\u53f7\u5316\u30ad\u30fc" + }, + "description": "ESPHome\u30c7\u30d0\u30a4\u30b9 {name} \u3001\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u306e\u6697\u53f7\u5316\u3092\u6709\u52b9\u306b\u3057\u305f\u304b\u3001\u6697\u53f7\u5316\u306e\u30ad\u30fc\u304c\u5909\u66f4\u3055\u308c\u307e\u3057\u305f\u3002\u66f4\u65b0\u3055\u308c\u305f\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u3042\u306a\u305f\u306e[ESPHome](https://esphomelib.com/)\u306e\u30ce\u30fc\u30c9\u306e\u63a5\u7d9a\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/nl.json b/homeassistant/components/esphome/translations/nl.json index 1aae006feedf3..7f6f821104c9e 100644 --- a/homeassistant/components/esphome/translations/nl.json +++ b/homeassistant/components/esphome/translations/nl.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "already_in_progress": "De configuratiestroom is al begonnen" + "already_in_progress": "De configuratiestroom is al begonnen", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "connection_error": "Kan geen verbinding maken met ESP. Zorg ervoor dat uw YAML-bestand een regel 'api:' bevat.", "invalid_auth": "Ongeldige authenticatie", + "invalid_psk": "De transportcoderingssleutel is ongeldig. Zorg ervoor dat het overeenkomt met wat u in uw configuratie heeft", "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?", "title": "ESPHome node ontdekt" }, + "encryption_key": { + "data": { + "noise_psk": "Coderingssleutel" + }, + "description": "Voer de coderingssleutel in die u in uw configuratie voor {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Coderingssleutel" + }, + "description": "Het ESPHome-apparaat {name} heeft transportcodering ingeschakeld of de coderingssleutel gewijzigd. Voer de bijgewerkte sleutel in." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/no.json b/homeassistant/components/esphome/translations/no.json index d3501c496ef7e..0d583893570ab 100644 --- a/homeassistant/components/esphome/translations/no.json +++ b/homeassistant/components/esphome/translations/no.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede" + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.", "invalid_auth": "Ugyldig godkjenning", + "invalid_psk": "Transportkrypteringsn\u00f8kkelen er ugyldig. S\u00f8rg for at den samsvarer med det du har i konfigurasjonen", "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, vennligst [sett en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" }, - "flow_title": "", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?", "title": "Oppdaget ESPHome node" }, + "encryption_key": { + "data": { + "noise_psk": "Krypteringsn\u00f8kkel" + }, + "description": "Skriv inn krypteringsn\u00f8kkelen du angav i konfigurasjonen for {name} ." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Krypteringsn\u00f8kkel" + }, + "description": "ESPHome -enheten {name} aktiverte transportkryptering eller endret krypteringsn\u00f8kkelen. Skriv inn den oppdaterte n\u00f8kkelen." + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/esphome/translations/pl.json b/homeassistant/components/esphome/translations/pl.json index 34b8d9bd0e1bb..da6154e9fb64e 100644 --- a/homeassistant/components/esphome/translations/pl.json +++ b/homeassistant/components/esphome/translations/pl.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "already_in_progress": "Konfiguracja jest ju\u017c w toku" + "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 'api:'.", "invalid_auth": "Niepoprawne uwierzytelnienie", + "invalid_psk": "Klucz szyfruj\u0105cy transport jest nieprawid\u0142owy. Upewnij si\u0119, \u017ce pasuje do tego, kt\u00f3ry masz w swojej konfiguracji.", "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "Czy chcesz doda\u0107 w\u0119ze\u0142 ESPHome `{name}` do Home Assistanta?", "title": "Znaleziono w\u0119ze\u0142 ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "Klucz szyfruj\u0105cy" + }, + "description": "Wprowad\u017a klucz szyfruj\u0105cy ustawiony w konfiguracji dla {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Klucz szyfruj\u0105cy" + }, + "description": "Urz\u0105dzenie ESPHome {name} w\u0142\u0105czy\u0142o szyfrowanie transportu lub zmieni\u0142o klucz szyfruj\u0105cy. Wprowad\u017a zaktualizowany klucz." + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", diff --git a/homeassistant/components/esphome/translations/ru.json b/homeassistant/components/esphome/translations/ru.json index 4277a057a86d3..8ba4a573cec8f 100644 --- a/homeassistant/components/esphome/translations/ru.json +++ b/homeassistant/components/esphome/translations/ru.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", - "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f." + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_psk": "\u041a\u043b\u044e\u0447 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043e\u043d \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u0432 \u0412\u0430\u0448\u0435\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.", "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips." }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c ESPHome `{name}`?", "title": "ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f" + }, + "description": "\u0414\u043b\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 {name} \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0443\u0440\u043e\u0432\u043d\u044f \u0438\u043b\u0438 \u0438\u0437\u043c\u0435\u043d\u0451\u043d \u043a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044b\u0439 \u043a\u043b\u044e\u0447." + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/esphome/translations/tr.json b/homeassistant/components/esphome/translations/tr.json index 81f85d4980bb0..b2cede2b572e7 100644 --- a/homeassistant/components/esphome/translations/tr.json +++ b/homeassistant/components/esphome/translations/tr.json @@ -2,11 +2,16 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "connection_error": "ESP'ye ba\u011flan\u0131lam\u0131yor. L\u00fctfen YAML dosyan\u0131z\u0131n bir 'api:' sat\u0131r\u0131 i\u00e7erdi\u011finden emin olun.", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_psk": "Aktar\u0131m \u015fifreleme anahtar\u0131 ge\u00e7ersiz. L\u00fctfen yap\u0131land\u0131rman\u0131zda sahip oldu\u011funuzla e\u015fle\u015fti\u011finden emin olun", + "resolve_error": "ESP'nin adresi \u00e7\u00f6z\u00fclemiyor. Bu hata devam ederse, l\u00fctfen statik bir IP adresi ayarlay\u0131n: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -15,13 +20,27 @@ "description": "L\u00fctfen yap\u0131land\u0131rman\u0131zda {name} i\u00e7in belirledi\u011finiz parolay\u0131 girin." }, "discovery_confirm": { + "description": "ESPHome d\u00fc\u011f\u00fcm\u00fcn\u00fc ` {name} ` Home Assistant'a eklemek istiyor musunuz?", "title": "Ke\u015ffedilen ESPHome d\u00fc\u011f\u00fcm\u00fc" }, + "encryption_key": { + "data": { + "noise_psk": "\u015eifreleme anahtar\u0131" + }, + "description": "{name} i\u00e7in yap\u0131land\u0131rman\u0131zda belirledi\u011finiz \u015fifreleme anahtar\u0131n\u0131 girin." + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u015eifreleme anahtar\u0131" + }, + "description": "ESPHome cihaz\u0131 {name} aktar\u0131m \u015fifrelemesini etkinle\u015ftirdi veya \u015fifreleme anahtar\u0131n\u0131 de\u011fi\u015ftirdi. L\u00fctfen g\u00fcncellenmi\u015f anahtar\u0131 girin." + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "port": "Port" - } + }, + "description": "L\u00fctfen [ESPHome](https://esphomelib.com/) d\u00fc\u011f\u00fcm\u00fcn\u00fcz\u00fcn ba\u011flant\u0131 ayarlar\u0131n\u0131 girin." } } } diff --git a/homeassistant/components/esphome/translations/zh-Hans.json b/homeassistant/components/esphome/translations/zh-Hans.json index b1911b90fde3c..d0c54f6afb14c 100644 --- a/homeassistant/components/esphome/translations/zh-Hans.json +++ b/homeassistant/components/esphome/translations/zh-Hans.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", - "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d" + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" }, "error": { "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002", "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", + "invalid_psk": "\u4f20\u8f93\u52a0\u5bc6\u5bc6\u94a5\u65e0\u6548\u3002\u8bf7\u786e\u4fdd\u5b83\u4e0e\u60a8\u7684\u914d\u7f6e\u4e00\u81f4\u3002", "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,9 +23,21 @@ "description": "\u662f\u5426\u8981\u5c06 ESPHome \u8282\u70b9 `{name}` \u6dfb\u52a0\u5230 Home Assistant\uff1f", "title": "\u53d1\u73b0\u4e86 ESPHome \u8282\u70b9" }, + "encryption_key": { + "data": { + "noise_psk": "\u52a0\u5bc6\u5bc6\u94a5" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u8bbe\u5907 {name} \u6240\u8bbe\u7f6e\u7684\u52a0\u5bc6\u5bc6\u94a5\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u52a0\u5bc6\u5bc6\u94a5" + }, + "description": "ESPHome \u8bbe\u5907 {name} \u5df2\u542f\u7528\u6216\u66f4\u6539\u4f20\u8f93\u52a0\u5bc6\u5bc6\u94a5\u3002\u8bf7\u8f93\u5165\u66f4\u65b0\u540e\u7684\u5bc6\u94a5\u4fe1\u606f\u3002" + }, "user": { "data": { - "host": "\u4e3b\u673a", + "host": "\u4e3b\u673a\u5730\u5740", "port": "\u7aef\u53e3" }, "description": "\u8bf7\u8f93\u5165\u60a8\u7684 [ESPHome](https://esphomelib.com/) \u8282\u70b9\u7684\u8fde\u63a5\u8bbe\u7f6e\u3002" diff --git a/homeassistant/components/esphome/translations/zh-Hant.json b/homeassistant/components/esphome/translations/zh-Hant.json index 6e9e43eae026a..976d0317faaa3 100644 --- a/homeassistant/components/esphome/translations/zh-Hant.json +++ b/homeassistant/components/esphome/translations/zh-Hant.json @@ -2,14 +2,16 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d" + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 ESP\uff0c\u8acb\u78ba\u5b9a\u60a8\u7684 YAML \u6a94\u6848\u5305\u542b\u300capi:\u300d\u8a2d\u5b9a\u5217\u3002", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_psk": "\u50b3\u8f38\u91d1\u9470\u7121\u6548\u3002\u8acb\u78ba\u5b9a\u8207\u8a2d\u5b9a\u5167\u6240\u8a2d\u5b9a\u4e4b\u91d1\u9470\u76f8\u7b26\u5408", "resolve_error": "\u7121\u6cd5\u89e3\u6790 ESP \u4f4d\u5740\uff0c\u5047\u5982\u6b64\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u53c3\u8003\u8aaa\u660e\u8a2d\u5b9a\u70ba\u975c\u614b\u56fa\u5b9a IP \uff1a https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome\uff1a{name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -21,6 +23,18 @@ "description": "\u662f\u5426\u8981\u5c07 ESPHome \u7bc0\u9ede `{name}` \u65b0\u589e\u81f3 Home Assistant\uff1f", "title": "\u81ea\u52d5\u63a2\u7d22\u5230 ESPHome \u7bc0\u9ede" }, + "encryption_key": { + "data": { + "noise_psk": "\u91d1\u9470" + }, + "description": "\u8acb\u8f38\u5165 {name} \u8a2d\u5b9a\u4e2d\u6240\u8a2d\u5b9a\u4e4b\u91d1\u9470\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u91d1\u9470" + }, + "description": "ESPHome \u88dd\u7f6e {name} \u5df2\u958b\u555f\u50b3\u8f38\u52a0\u5bc6\u6216\u8b8a\u66f4\u91d1\u9470\u3002\u8acb\u8f38\u5165\u66f4\u65b0\u91d1\u9470\u3002" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/essent/__init__.py b/homeassistant/components/essent/__init__.py deleted file mode 100644 index 42e867c6d2144..0000000000000 --- a/homeassistant/components/essent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The Essent component.""" diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json deleted file mode 100644 index d136cae43a9a3..0000000000000 --- a/homeassistant/components/essent/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "essent", - "name": "Essent", - "documentation": "https://www.home-assistant.io/integrations/essent", - "requirements": ["PyEssent==0.14"], - "codeowners": ["@TheLastProject"], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py deleted file mode 100644 index f0dc70d7be4fd..0000000000000 --- a/homeassistant/components/essent/sensor.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Support for Essent API.""" -from __future__ import annotations - -from datetime import timedelta - -from pyessent import PyEssent -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, ENERGY_KILO_WATT_HOUR -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle - -SCAN_INTERVAL = timedelta(hours=1) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Essent platform.""" - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - - essent = EssentBase(username, password) - meters = [] - for meter in essent.retrieve_meters(): - data = essent.retrieve_meter_data(meter) - for tariff in data["values"]["LVR"]: - meters.append( - EssentMeter( - essent, - meter, - data["type"], - tariff, - data["values"]["LVR"][tariff]["unit"], - ) - ) - - if not meters: - hass.components.persistent_notification.create( - "Couldn't find any meter readings. " - "Please ensure Verbruiks Manager is enabled in Mijn Essent " - "and at least one reading has been logged to Meterstanden.", - title="Essent", - notification_id="essent_notification", - ) - return - - add_devices(meters, True) - - -class EssentBase: - """Essent Base.""" - - def __init__(self, username, password): - """Initialize the Essent API.""" - self._username = username - self._password = password - self._meter_data = {} - - self.update() - - def retrieve_meters(self): - """Retrieve the list of meters.""" - return self._meter_data.keys() - - def retrieve_meter_data(self, meter): - """Retrieve the data for this meter.""" - return self._meter_data[meter] - - @Throttle(timedelta(minutes=30)) - def update(self): - """Retrieve the latest meter data from Essent.""" - essent = PyEssent(self._username, self._password) - eans = set(essent.get_EANs()) - for possible_meter in eans: - meter_data = essent.read_meter(possible_meter, only_last_meter_reading=True) - if meter_data: - self._meter_data[possible_meter] = meter_data - - -class EssentMeter(SensorEntity): - """Representation of Essent measurements.""" - - def __init__(self, essent_base, meter, meter_type, tariff, unit): - """Initialize the sensor.""" - self._state = None - self._essent_base = essent_base - self._meter = meter - self._type = meter_type - self._tariff = tariff - self._unit = unit - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return f"{self._meter}-{self._type}-{self._tariff}" - - @property - def name(self): - """Return the name of the sensor.""" - return f"Essent {self._type} ({self._tariff})" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - if self._unit.lower() == "kwh": - return ENERGY_KILO_WATT_HOUR - - return self._unit - - def update(self): - """Fetch the energy usage.""" - # Ensure our data isn't too old - self._essent_base.update() - - # Retrieve our meter - data = self._essent_base.retrieve_meter_data(self._meter) - - # Set our value - self._state = next( - iter(data["values"]["LVR"][self._tariff]["records"].values()) - ) diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 1fa2edbf2e8f1..b1ec3cddb0c84 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if token: token = token.upper() if not name: - name = "%s Balance" % token + name = f"{token} Balance" if not name: name = "ETH Balance" @@ -59,12 +59,12 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py new file mode 100644 index 0000000000000..2fbd85938f0f8 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -0,0 +1,100 @@ +"""The Evil Genius Labs integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import cast + +from async_timeout import timeout +import pyevilgenius + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + update_coordinator, +) +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN + +PLATFORMS = [Platform.LIGHT] + +UPDATE_INTERVAL = 10 + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Evil Genius Labs from a config entry.""" + coordinator = EvilGeniusUpdateCoordinator( + hass, + entry.title, + pyevilgenius.EvilGeniusDevice( + entry.data["host"], aiohttp_client.async_get_clientsession(hass) + ), + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class EvilGeniusUpdateCoordinator(update_coordinator.DataUpdateCoordinator[dict]): + """Update coordinator for Evil Genius data.""" + + info: dict + + def __init__( + self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + ) -> None: + """Initialize the data update coordinator.""" + self.client = client + super().__init__( + hass, + logging.getLogger(__name__), + name=name, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + @property + def device_name(self) -> str: + """Return the device name.""" + return cast(str, self.data["name"]["value"]) + + async def _async_update_data(self) -> dict: + """Update Evil Genius data.""" + if not hasattr(self, "info"): + async with timeout(5): + self.info = await self.client.get_info() + + async with timeout(5): + return cast(dict, await self.client.get_data()) + + +class EvilGeniusEntity(update_coordinator.CoordinatorEntity): + """Base entity for Evil Genius.""" + + coordinator: EvilGeniusUpdateCoordinator + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + info = self.coordinator.info + return DeviceInfo( + identifiers={(DOMAIN, info["wiFiChipId"])}, + connections={(dr.CONNECTION_NETWORK_MAC, info["macAddress"])}, + name=self.coordinator.device_name, + manufacturer="Evil Genius Labs", + sw_version=info["coreVersion"].replace("_", "."), + configuration_url=self.coordinator.client.url, + ) diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py new file mode 100644 index 0000000000000..744e0194ded74 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Evil Genius Labs integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import aiohttp +import async_timeout +import pyevilgenius +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + hub = pyevilgenius.EvilGeniusDevice( + data["host"], aiohttp_client.async_get_clientsession(hass) + ) + + try: + async with async_timeout.timeout(10): + data = await hub.get_data() + info = await hub.get_info() + except aiohttp.ClientError as err: + _LOGGER.debug("Unable to connect: %s", err) + raise CannotConnect from err + + return {"title": data["name"]["value"], "unique_id": info["wiFiChipId"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Evil Genius Labs.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("host"): str, + } + ), + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except asyncio.TimeoutError: + errors["base"] = "timeout" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["unique_id"]) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("host", default=user_input["host"]): str, + } + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/evil_genius_labs/const.py b/homeassistant/components/evil_genius_labs/const.py new file mode 100644 index 0000000000000..c335e5eaee2e9 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/const.py @@ -0,0 +1,3 @@ +"""Constants for the Evil Genius Labs integration.""" + +DOMAIN = "evil_genius_labs" diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py new file mode 100644 index 0000000000000..cb837668a4c02 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/light.py @@ -0,0 +1,120 @@ +"""Light platform for Evil Genius Light.""" +from __future__ import annotations + +from typing import Any, cast + +from async_timeout import timeout + +from homeassistant.components import light +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EvilGeniusEntity, EvilGeniusUpdateCoordinator +from .const import DOMAIN +from .util import update_when_done + +HA_NO_EFFECT = "None" +FIB_NO_EFFECT = "Solid Color" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Evil Genius light platform.""" + coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([EvilGeniusLight(coordinator)]) + + +class EvilGeniusLight(EvilGeniusEntity, light.LightEntity): + """Evil Genius Labs light.""" + + _attr_supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_EFFECT | light.SUPPORT_COLOR + ) + _attr_supported_color_modes = {light.COLOR_MODE_RGB} + _attr_color_mode = light.COLOR_MODE_RGB + + def __init__(self, coordinator: EvilGeniusUpdateCoordinator) -> None: + """Initialize the Evil Genius light.""" + super().__init__(coordinator) + self._attr_unique_id = self.coordinator.info["wiFiChipId"] + self._attr_effect_list = [ + pattern + for pattern in self.coordinator.data["pattern"]["options"] + if pattern != FIB_NO_EFFECT + ] + self._attr_effect_list.insert(0, HA_NO_EFFECT) + + @property + def name(self) -> str: + """Return name.""" + return cast(str, self.coordinator.data["name"]["value"]) + + @property + def is_on(self) -> bool: + """Return if light is on.""" + return cast(int, self.coordinator.data["power"]["value"]) == 1 + + @property + def brightness(self) -> int: + """Return brightness.""" + return cast(int, self.coordinator.data["brightness"]["value"]) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Return the rgb color value [int, int, int].""" + return cast( + "tuple[int, int, int]", + tuple( + int(val) + for val in self.coordinator.data["solidColor"]["value"].split(",") + ), + ) + + @property + def effect(self) -> str: + """Return current effect.""" + value = cast( + str, + self.coordinator.data["pattern"]["options"][ + self.coordinator.data["pattern"]["value"] + ], + ) + if value == FIB_NO_EFFECT: + return HA_NO_EFFECT + return value + + @update_when_done + async def async_turn_on( + self, + **kwargs: Any, + ) -> None: + """Turn light on.""" + if (brightness := kwargs.get(light.ATTR_BRIGHTNESS)) is not None: + async with timeout(5): + await self.coordinator.client.set_path_value("brightness", brightness) + + # Setting a color will change the effect to "Solid Color" so skip setting effect + if (rgb_color := kwargs.get(light.ATTR_RGB_COLOR)) is not None: + async with timeout(5): + await self.coordinator.client.set_rgb_color(*rgb_color) + + elif (effect := kwargs.get(light.ATTR_EFFECT)) is not None: + if effect == HA_NO_EFFECT: + effect = FIB_NO_EFFECT + async with timeout(5): + await self.coordinator.client.set_path_value( + "pattern", self.coordinator.data["pattern"]["options"].index(effect) + ) + + async with timeout(5): + await self.coordinator.client.set_path_value("power", 1) + + @update_when_done + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + async with timeout(5): + await self.coordinator.client.set_path_value("power", 0) diff --git a/homeassistant/components/evil_genius_labs/manifest.json b/homeassistant/components/evil_genius_labs/manifest.json new file mode 100644 index 0000000000000..698c13b43e6d7 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "evil_genius_labs", + "name": "Evil Genius Labs", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/evil_genius_labs", + "requirements": ["pyevilgenius==1.0.0"], + "codeowners": ["@balloob"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json new file mode 100644 index 0000000000000..790e9a69c7fd5 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/evil_genius_labs/translations/bg.json b/homeassistant/components/evil_genius_labs/translations/bg.json new file mode 100644 index 0000000000000..dcdcdcfc1867a --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/ca.json b/homeassistant/components/evil_genius_labs/translations/ca.json new file mode 100644 index 0000000000000..aa6d7355314b0 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/ca.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "timeout": "Temps m\u00e0xim d'espera per establir la connexi\u00f3 esgotat", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/de.json b/homeassistant/components/evil_genius_labs/translations/de.json new file mode 100644 index 0000000000000..296d38e6f6303 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "timeout": "Zeit\u00fcberschreitung beim Verbindungsaufbau", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/en.json b/homeassistant/components/evil_genius_labs/translations/en.json new file mode 100644 index 0000000000000..75c630665abb3 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "timeout": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/es.json b/homeassistant/components/evil_genius_labs/translations/es.json new file mode 100644 index 0000000000000..d0f2d525bfdfd --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/et.json b/homeassistant/components/evil_genius_labs/translations/et.json new file mode 100644 index 0000000000000..84dd92bfbe1d8 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/et.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "timeout": "\u00dchenduse loomise ajal\u00f5pp", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/fr.json b/homeassistant/components/evil_genius_labs/translations/fr.json new file mode 100644 index 0000000000000..bd75678406e89 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/he.json b/homeassistant/components/evil_genius_labs/translations/he.json new file mode 100644 index 0000000000000..00011f86933e6 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/hu.json b/homeassistant/components/evil_genius_labs/translations/hu.json new file mode 100644 index 0000000000000..c1431690d6ef5 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a kapcsolat l\u00e9trehoz\u00e1sa sor\u00e1n", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/id.json b/homeassistant/components/evil_genius_labs/translations/id.json new file mode 100644 index 0000000000000..8d550418ce289 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "timeout": "Tenggang waktu membuat koneksi habis", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/it.json b/homeassistant/components/evil_genius_labs/translations/it.json new file mode 100644 index 0000000000000..fa418257b0e54 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/it.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "timeout": "Tempo scaduto per stabile la connessione.", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/ja.json b/homeassistant/components/evil_genius_labs/translations/ja.json new file mode 100644 index 0000000000000..9ee1382e424ed --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "timeout": "\u63a5\u7d9a\u78ba\u7acb\u6642\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/nl.json b/homeassistant/components/evil_genius_labs/translations/nl.json new file mode 100644 index 0000000000000..88c6875cc58fc --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "timeout": "Time-out bij het maken van verbinding", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/no.json b/homeassistant/components/evil_genius_labs/translations/no.json new file mode 100644 index 0000000000000..814ac7f108059 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/no.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "timeout": "Tidsavbrudd oppretter forbindelse", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/pl.json b/homeassistant/components/evil_genius_labs/translations/pl.json new file mode 100644 index 0000000000000..a8bab923c353a --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/ru.json b/homeassistant/components/evil_genius_labs/translations/ru.json new file mode 100644 index 0000000000000..007c1f7b374fa --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/ru.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/sl.json b/homeassistant/components/evil_genius_labs/translations/sl.json new file mode 100644 index 0000000000000..3d0d816aa1218 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/tr.json b/homeassistant/components/evil_genius_labs/translations/tr.json new file mode 100644 index 0000000000000..46c39ff6b8e82 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "timeout": "Ba\u011flant\u0131 kurulurken zaman a\u015f\u0131m\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Sunucu" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/zh-Hans.json b/homeassistant/components/evil_genius_labs/translations/zh-Hans.json new file mode 100644 index 0000000000000..a98284453021f --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "timeout": "\u5efa\u7acb\u8fde\u63a5\u8d85\u65f6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/zh-Hant.json b/homeassistant/components/evil_genius_labs/translations/zh-Hant.json new file mode 100644 index 0000000000000..c32ec5190e576 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "timeout": "\u5efa\u7acb\u9023\u7dda\u903e\u6642", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py new file mode 100644 index 0000000000000..42088f6979734 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/util.py @@ -0,0 +1,21 @@ +"""Utilities for Evil Genius Labs.""" +from collections.abc import Callable +from functools import wraps +from typing import Any, TypeVar, cast + +from . import EvilGeniusEntity + +CallableT = TypeVar("CallableT", bound=Callable) + + +def update_when_done(func: CallableT) -> CallableT: + """Decorate function to trigger update when function is done.""" + + @wraps(func) + async def wrapper(self: EvilGeniusEntity, *args: Any, **kwargs: Any) -> Any: + """Wrap function.""" + result = await func(self, *args, **kwargs) + await self.coordinator.async_request_refresh() + return result + + return cast(CallableT, wrapper) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index cadeefa3c3a71..d7b1407642d8e 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime as dt, timedelta +from http import HTTPStatus import logging import re from typing import Any @@ -19,8 +20,6 @@ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, - HTTP_SERVICE_UNAVAILABLE, - HTTP_TOO_MANY_REQUESTS, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback @@ -158,13 +157,13 @@ def _handle_exception(err) -> bool: ) except aiohttp.ClientResponseError: - if err.status == HTTP_SERVICE_UNAVAILABLE: + if err.status == HTTPStatus.SERVICE_UNAVAILABLE: _LOGGER.warning( "The vendor says their server is currently unavailable. " "Check the vendor's service status page" ) - elif err.status == HTTP_TOO_MANY_REQUESTS: + elif err.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.warning( "The vendor's API rate limit has been exceeded. " "If this message persists, consider increasing the %s", @@ -180,7 +179,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def load_auth_tokens(store) -> tuple[dict, dict | None]: app_storage = await store.async_load() - tokens = dict(app_storage if app_storage else {}) + tokens = dict(app_storage or {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: # any tokens won't be valid, and store might be be corrupt @@ -406,10 +405,12 @@ async def save_auth_tokens(self) -> None: # evohomeasync2 uses naive/local datetimes access_token_expires = _dt_local_to_aware(self.client.access_token_expires) - app_storage = {CONF_USERNAME: self.client.username} - app_storage[REFRESH_TOKEN] = self.client.refresh_token - app_storage[ACCESS_TOKEN] = self.client.access_token - app_storage[ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat() + app_storage = { + CONF_USERNAME: self.client.username, + REFRESH_TOKEN: self.client.refresh_token, + ACCESS_TOKEN: self.client.access_token, + ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), + } if self.client_v1 and self.client_v1.user_data: app_storage[USER_DATA] = { @@ -529,7 +530,7 @@ async def async_refresh(self, payload: dict | None = None) -> None: return if payload["unique_id"] != self._unique_id: return - if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: + if payload["service"] in (SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE): await self.async_zone_svc_request(payload["service"], payload["data"]) return await self.async_tcs_svc_request(payload["service"], payload["data"]) @@ -651,10 +652,10 @@ def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: this_sp_day = -1 if sp_idx == -1 else 0 next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 - for key, offset, idx in [ + for key, offset, idx in ( ("this", this_sp_day, sp_idx), ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), - ]: + ): sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] switchpoint = day["Switchpoints"][idx] diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8021ad6ba246a..c293034e05a29 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -194,7 +194,7 @@ async def async_zone_svc_request(self, service: dict, data: dict) -> None: @property def hvac_mode(self) -> str: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return HVAC_MODE_AUTO is_off = self.target_temperature <= self.min_temp return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT @@ -207,7 +207,7 @@ def target_temperature(self) -> float: @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) @@ -230,9 +230,8 @@ def max_temp(self) -> float: async def async_set_temperature(self, **kwargs) -> None: """Set a new target temperature.""" temperature = kwargs["temperature"] - until = kwargs.get("until") - if until is None: + if (until := kwargs.get("until")) is None: if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index b9f93c295d665..09f9cf81cd163 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -2,7 +2,7 @@ "domain": "evohome", "name": "Honeywell Total Connect Comfort (Europe)", "documentation": "https://www.home-assistant.io/integrations/evohome", - "requirements": ["evohome-async==0.3.8"], + "requirements": ["evohome-async==0.3.15"], "codeowners": ["@zxdavb"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 04f7a3ac2aa33..bdcb116e4e3ca 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -2,52 +2,93 @@ # Describes the format for available services set_system_mode: + name: Set system mode description: >- Set the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes. fields: mode: - description: "One of: Auto, AutoWithEco, Away, DayOff, HeatingOff, or Custom." + name: Mode + description: "Mode to set thermostat." example: Away + selector: + select: + options: + - 'Auto' + - 'AutoWithEco' + - 'Away' + - 'Custom' + - 'DayOff' + - 'HeatingOff' period: + name: Period description: >- A period of time in days; used only with Away, DayOff, or Custom. The system will revert to Auto at midnight (up to 99 days, today is day 1). example: '{"days": 28}' + selector: + object: duration: + name: Duration description: The duration in hours; used only with AutoWithEco (up to 24 hours). example: '{"hours": 18}' + selector: + object: reset_system: + name: Reset system description: >- Set the system to Auto mode and reset all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode). refresh_system: + name: Refresh system description: >- Pull the latest data from the vendor's servers now, rather than waiting for the next scheduled update. set_zone_override: + name: Set zone override description: >- Override a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule. fields: entity_id: + name: Entity description: The entity_id of the Evohome zone. + required: true example: climate.bathroom + selector: + entity: + integration: evohome + domain: climate setpoint: + name: Setpoint description: The temperature to be used instead of the scheduled setpoint. - example: 5.0 + required: true + selector: + number: + min: 4.0 + max: 35.0 + step: 0.1 duration: + name: Duration description: >- The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint. example: '{"minutes": 135}' + selector: + object: clear_zone_override: + name: Clear zone override description: Set a zone to follow its schedule. fields: entity_id: + name: Entity description: The entity_id of the zone. - example: climate.bathroom + required: true + selector: + entity: + integration: evohome + domain: climate diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 692c4dbbc49be..3d799a64e4d71 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -59,11 +59,6 @@ def __init__(self, evo_broker, evo_device) -> None: self._precision = PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE - @property - def state(self): - """Return the current state.""" - return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] - @property def current_operation(self) -> str: """Return the current operating mode (Auto, On, or Off).""" @@ -112,6 +107,14 @@ async def async_turn_away_mode_off(self): """Turn away mode off.""" await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + async def async_turn_on(self): + """Turn on.""" + await self._evo_broker.call_client_api(self._evo_device.set_dhw_on()) + + async def async_turn_off(self): + """Turn off.""" + await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) + async def async_update(self) -> None: """Get the latest state data for a DHW controller.""" await super().async_update() diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 670e07a07dc1e..9d6f7864b84b1 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -1,16 +1,19 @@ """Support for Ezviz camera.""" -from datetime import timedelta import logging -from pyezviz.client import EzvizClient, HTTPError, InvalidURL, PyEzvizError +from pyezviz.client import EzvizClient +from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_TIMEOUT, CONF_TYPE, CONF_URL, CONF_USERNAME, + Platform, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( @@ -27,27 +30,24 @@ _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - PLATFORMS = [ - "binary_sensor", - "camera", - "sensor", - "switch", + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, ] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ezviz from a config entry.""" hass.data.setdefault(DOMAIN, {}) if not entry.options: options = { - CONF_FFMPEG_ARGUMENTS: entry.data.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - CONF_TIMEOUT: entry.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, } + hass.config_entries.async_update_entry(entry, options=options) if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: @@ -69,7 +69,9 @@ async def async_setup_entry(hass, entry): _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator(hass, api=ezviz_client) + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) await coordinator.async_refresh() if not coordinator.last_update_success: @@ -86,7 +88,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: @@ -99,12 +101,12 @@ async def async_unload_entry(hass, entry): return unload_ok -async def _async_update_listener(hass, entry): +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -def _get_ezviz_client_instance(entry): +def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: """Initialize a new instance of EzvizClientApi.""" ezviz_client = EzvizClient( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index 9d8db7fbb30ee..942ceeecdb241 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -1,77 +1,75 @@ """Support for Ezviz binary sensors.""" -import logging - -from pyezviz.constants import BinarySensorType - -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, entry, async_add_entities): +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { + "Motion_Trigger": BinarySensorEntityDescription( + key="Motion_Trigger", + device_class=BinarySensorDeviceClass.MOTION, + ), + "alarm_schedules_enabled": BinarySensorEntityDescription( + key="alarm_schedules_enabled" + ), + "encrypted": BinarySensorEntityDescription(key="encrypted"), + "upgrade_available": BinarySensorEntityDescription( + key="upgrade_available", + device_class=BinarySensorDeviceClass.UPDATE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ezviz sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - sensors = [] - sensor_type_name = "None" - - for idx, camera in enumerate(coordinator.data): - for name in camera: - # Only add sensor with value. - if camera.get(name) is None: - continue - - if name in BinarySensorType.__members__: - sensor_type_name = getattr(BinarySensorType, name).value - sensors.append( - EzvizBinarySensor(coordinator, idx, name, sensor_type_name) - ) - - async_add_entities(sensors) - - -class EzvizBinarySensor(CoordinatorEntity, BinarySensorEntity): + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + [ + EzvizBinarySensor(coordinator, camera, binary_sensor) + for camera in coordinator.data + for binary_sensor, value in coordinator.data[camera].items() + if binary_sensor in BINARY_SENSOR_TYPES + if value is not None + ] + ) + + +class EzvizBinarySensor(EzvizEntity, BinarySensorEntity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, name, sensor_type_name): - """Initialize the sensor.""" - super().__init__(coordinator) - self._idx = idx - self._camera_name = self.coordinator.data[self._idx]["name"] - self._name = name - self._sensor_name = f"{self._camera_name}.{self._name}" - self.sensor_type_name = sensor_type_name - self._serial = self.coordinator.data[self._idx]["serial"] + coordinator: EzvizDataUpdateCoordinator - @property - def name(self): - """Return the name of the Ezviz sensor.""" - return self._sensor_name + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + binary_sensor: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._sensor_name = binary_sensor + self._attr_name = f"{self._camera_name} {binary_sensor.title()}" + self._attr_unique_id = f"{serial}_{self._camera_name}.{binary_sensor}" + self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" - return self.coordinator.data[self._idx][self._name] - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._serial}_{self._sensor_name}" - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._serial)}, - "name": self.coordinator.data[self._idx]["name"], - "model": self.coordinator.data[self._idx]["device_sub_category"], - "manufacturer": MANUFACTURER, - "sw_version": self.coordinator.data[self._idx]["version"], - } - - @property - def device_class(self): - """Device class for the sensor.""" - return self.sensor_type_name + return self.data[self._sensor_name] diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 919ff5039b216..95e51ab8b61f8 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -1,30 +1,51 @@ """Support ezviz camera devices.""" -import asyncio -from datetime import timedelta +from __future__ import annotations + import logging -from haffmpeg.tools import IMAGE_JPEG, ImageFrame +from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IGNORE, SOURCE_IMPORT +from homeassistant.components.ffmpeg import get_ffmpeg_manager +from homeassistant.config_entries import ( + SOURCE_DISCOVERY, + SOURCE_IGNORE, + SOURCE_IMPORT, + ConfigEntry, +) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + ATTR_DIRECTION, + ATTR_ENABLE, + ATTR_LEVEL, ATTR_SERIAL, + ATTR_SPEED, + ATTR_TYPE, CONF_CAMERAS, CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_RTSP_PORT, + DIR_DOWN, + DIR_LEFT, + DIR_RIGHT, + DIR_UP, DOMAIN, - MANUFACTURER, + SERVICE_ALARM_SOUND, + SERVICE_ALARM_TRIGER, + SERVICE_DETECTION_SENSITIVITY, + SERVICE_PTZ, + SERVICE_WAKE_DEVICE, ) +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity CAMERA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} @@ -40,10 +61,13 @@ _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SESSION_RENEW = timedelta(seconds=90) - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a Ezviz IP Camera from platform config.""" _LOGGER.warning( "Loading ezviz via platform config is deprecated, it will be automatically imported. Please remove it afterwards" @@ -79,45 +103,47 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: """Set up Ezviz cameras based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - camera_config_entries = hass.config_entries.async_entries(DOMAIN) + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] camera_entities = [] - for idx, camera in enumerate(coordinator.data): - - # There seem to be a bug related to localRtspPort in Ezviz API... - local_rtsp_port = DEFAULT_RTSP_PORT + for camera, value in coordinator.data.items(): camera_rtsp_entry = [ item - for item in camera_config_entries - if item.unique_id == camera[ATTR_SERIAL] + for item in hass.config_entries.async_entries(DOMAIN) + if item.unique_id == camera and item.source != SOURCE_IGNORE ] - if camera["local_rtsp_port"] != 0: - local_rtsp_port = camera["local_rtsp_port"] + # There seem to be a bug related to localRtspPort in Ezviz API. + local_rtsp_port = ( + value["local_rtsp_port"] + if value["local_rtsp_port"] != 0 + else DEFAULT_RTSP_PORT + ) if camera_rtsp_entry: - conf_cameras = camera_rtsp_entry[0] - - # Skip ignored entities. - if conf_cameras.source == SOURCE_IGNORE: - continue - ffmpeg_arguments = conf_cameras.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ) + ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] + camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] + camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_username = conf_cameras.data[CONF_USERNAME] - camera_password = conf_cameras.data[CONF_PASSWORD] - - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{camera['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" _LOGGER.debug( - "Camera %s source stream: %s", camera[ATTR_SERIAL], camera_rtsp_stream + "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", + camera, + value["local_ip"], + local_rtsp_port, + ffmpeg_arguments, ) else: @@ -127,26 +153,27 @@ async def async_setup_entry(hass, entry, async_add_entities): DOMAIN, context={"source": SOURCE_DISCOVERY}, data={ - ATTR_SERIAL: camera[ATTR_SERIAL], - CONF_IP_ADDRESS: camera["local_ip"], + ATTR_SERIAL: camera, + CONF_IP_ADDRESS: value["local_ip"], }, ) ) - camera_username = DEFAULT_CAMERA_USERNAME - camera_password = "" - camera_rtsp_stream = "" - ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS _LOGGER.warning( "Found camera with serial %s without configuration. Please go to integration to complete setup", - camera[ATTR_SERIAL], + camera, ) + ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS + camera_username = DEFAULT_CAMERA_USERNAME + camera_password = None + camera_rtsp_stream = "" + camera_entities.append( EzvizCamera( hass, coordinator, - idx, + camera, camera_username, camera_password, camera_rtsp_stream, @@ -157,117 +184,190 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(camera_entities) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_PTZ, + { + vol.Required(ATTR_DIRECTION): vol.In( + [DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT] + ), + vol.Required(ATTR_SPEED): cv.positive_int, + }, + "perform_ptz", + ) + + platform.async_register_entity_service( + SERVICE_ALARM_TRIGER, + { + vol.Required(ATTR_ENABLE): cv.positive_int, + }, + "perform_sound_alarm", + ) + + platform.async_register_entity_service( + SERVICE_WAKE_DEVICE, {}, "perform_wake_device" + ) -class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): + platform.async_register_entity_service( + SERVICE_ALARM_SOUND, + {vol.Required(ATTR_LEVEL): cv.positive_int}, + "perform_alarm_sound", + ) + + platform.async_register_entity_service( + SERVICE_DETECTION_SENSITIVITY, + { + vol.Required(ATTR_LEVEL): cv.positive_int, + vol.Required(ATTR_TYPE): cv.positive_int, + }, + "perform_set_alarm_detection_sensibility", + ) + + +class EzvizCamera(EzvizEntity, Camera): """An implementation of a Ezviz security camera.""" + coordinator: EzvizDataUpdateCoordinator + def __init__( self, - hass, - coordinator, - idx, - camera_username, - camera_password, - camera_rtsp_stream, - local_rtsp_port, - ffmpeg_arguments, - ): + hass: HomeAssistant, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + camera_username: str, + camera_password: str | None, + camera_rtsp_stream: str | None, + local_rtsp_port: int, + ffmpeg_arguments: str | None, + ) -> None: """Initialize a Ezviz security camera.""" - super().__init__(coordinator) + super().__init__(coordinator, serial) Camera.__init__(self) self._username = camera_username self._password = camera_password self._rtsp_stream = camera_rtsp_stream - self._idx = idx - self._ffmpeg = hass.data[DATA_FFMPEG] self._local_rtsp_port = local_rtsp_port self._ffmpeg_arguments = ffmpeg_arguments - - self._serial = self.coordinator.data[self._idx]["serial"] - self._name = self.coordinator.data[self._idx]["name"] - self._local_ip = self.coordinator.data[self._idx]["local_ip"] + self._ffmpeg = get_ffmpeg_manager(hass) + self._attr_unique_id = serial + self._attr_name = self.data["name"] @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" - if self.coordinator.data[self._idx]["status"] == 2: - return False - - return True + return self.data["status"] != 2 @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" - if self._rtsp_stream: + if self._password: return SUPPORT_STREAM return 0 @property - def name(self): - """Return the name of this device.""" - return self._name - - @property - def model(self): - """Return the model of this device.""" - return self.coordinator.data[self._idx]["device_sub_category"] - - @property - def brand(self): - """Return the manufacturer of this device.""" - return MANUFACTURER - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" - return bool(self.coordinator.data[self._idx]["status"]) + return bool(self.data["status"]) @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" - return self.coordinator.data[self._idx]["alarm_notify"] + return self.data["alarm_notify"] @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Camera Motion Detection Status.""" - return self.coordinator.data[self._idx]["alarm_notify"] + return self.data["alarm_notify"] - @property - def unique_id(self): - """Return the name of this camera.""" - return self._serial + def enable_motion_detection(self) -> None: + """Enable motion detection in camera.""" + try: + self.coordinator.ezviz_client.set_camera_defence(self._serial, 1) - async def async_camera_image(self): - """Return a frame from the camera stream.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) + except InvalidHost as err: + raise InvalidHost("Error enabling motion detection") from err + + def disable_motion_detection(self) -> None: + """Disable motion detection.""" + try: + self.coordinator.ezviz_client.set_camera_defence(self._serial, 0) + + except InvalidHost as err: + raise InvalidHost("Error disabling motion detection") from err - image = await asyncio.shield( - ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG) + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a frame from the camera stream.""" + if self._rtsp_stream is None: + return None + return await ffmpeg.async_get_image( + self.hass, self._rtsp_stream, width=width, height=height ) - return image - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._serial)}, - "name": self.coordinator.data[self._idx]["name"], - "model": self.coordinator.data[self._idx]["device_sub_category"], - "manufacturer": MANUFACTURER, - "sw_version": self.coordinator.data[self._idx]["version"], - } - - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the stream source.""" - local_ip = self.coordinator.data[self._idx]["local_ip"] - if self._local_rtsp_port: - rtsp_stream_source = ( - f"rtsp://{self._username}:{self._password}@" - f"{local_ip}:{self._local_rtsp_port}{self._ffmpeg_arguments}" + if self._password is None: + return None + local_ip = self.data["local_ip"] + self._rtsp_stream = ( + f"rtsp://{self._username}:{self._password}@" + f"{local_ip}:{self._local_rtsp_port}{self._ffmpeg_arguments}" + ) + _LOGGER.debug( + "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", + self._serial, + local_ip, + self._local_rtsp_port, + self._ffmpeg_arguments, + ) + + return self._rtsp_stream + + def perform_ptz(self, direction: str, speed: int) -> None: + """Perform a PTZ action on the camera.""" + try: + self.coordinator.ezviz_client.ptz_control( + str(direction).upper(), self._serial, "START", speed ) - _LOGGER.debug( - "Camera %s source stream: %s", self._serial, rtsp_stream_source + self.coordinator.ezviz_client.ptz_control( + str(direction).upper(), self._serial, "STOP", speed + ) + + except HTTPError as err: + raise HTTPError("Cannot perform PTZ") from err + + def perform_sound_alarm(self, enable: int) -> None: + """Sound the alarm on a camera.""" + try: + self.coordinator.ezviz_client.sound_alarm(self._serial, enable) + except HTTPError as err: + raise HTTPError("Cannot sound alarm") from err + + def perform_wake_device(self) -> None: + """Basically wakes the camera by querying the device.""" + try: + self.coordinator.ezviz_client.get_detection_sensibility(self._serial) + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError("Cannot wake device") from err + + def perform_alarm_sound(self, level: int) -> None: + """Enable/Disable movement sound alarm.""" + try: + self.coordinator.ezviz_client.alarm_sound(self._serial, level, 1) + except HTTPError as err: + raise HTTPError( + "Cannot set alarm sound level for on movement detected" + ) from err + + def perform_set_alarm_detection_sensibility( + self, level: int, type_value: int + ) -> None: + """Set camera detection sensibility level service.""" + try: + self.coordinator.ezviz_client.detection_sensibility( + self._serial, level, type_value ) - self._rtsp_stream = rtsp_stream_source - return rtsp_stream_source - return None + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError("Cannot set detection sensitivity level") from err diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 6915d8fa8db0e..8f10e3f069820 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,8 +1,15 @@ """Config flow for ezviz.""" import logging -from pyezviz.client import EzvizClient, HTTPError, InvalidURL, PyEzvizError -from pyezviz.test_cam_rtsp import AuthTestResultFailed, InvalidHost, TestRTSPAuth +from pyezviz.client import EzvizClient +from pyezviz.exceptions import ( + AuthTestResultFailed, + HTTPError, + InvalidHost, + InvalidURL, + PyEzvizError, +) +from pyezviz.test_cam_rtsp import TestRTSPAuth import voluptuous as vol from homeassistant.config_entries import ConfigFlow, OptionsFlow diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index c307f0693f6b5..ec1471d8bc4cd 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -6,34 +6,35 @@ # Configuration ATTR_SERIAL = "serial" CONF_CAMERAS = "cameras" -ATTR_SWITCH = "switch" -ATTR_ENABLE = "enable" -ATTR_DIRECTION = "direction" -ATTR_SPEED = "speed" -ATTR_LEVEL = "level" -ATTR_TYPE = "type_value" -DIR_UP = "up" -DIR_DOWN = "down" -DIR_LEFT = "left" -DIR_RIGHT = "right" -ATTR_LIGHT = "LIGHT" -ATTR_SOUND = "SOUND" -ATTR_INFRARED_LIGHT = "INFRARED_LIGHT" -ATTR_PRIVACY = "PRIVACY" -ATTR_SLEEP = "SLEEP" -ATTR_MOBILE_TRACKING = "MOBILE_TRACKING" -ATTR_TRACKING = "TRACKING" CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +# Services data +DIR_UP = "up" +DIR_DOWN = "down" +DIR_LEFT = "left" +DIR_RIGHT = "right" +ATTR_ENABLE = "enable" +ATTR_DIRECTION = "direction" +ATTR_SPEED = "speed" +ATTR_LEVEL = "level" +ATTR_TYPE = "type_value" + +# Service names +SERVICE_PTZ = "ptz" +SERVICE_ALARM_TRIGER = "sound_alarm" +SERVICE_WAKE_DEVICE = "wake_device" +SERVICE_ALARM_SOUND = "alarm_sound" +SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" + # Defaults EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = "554" +DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index 2fc9f6c9f825b..8729aa4cf211c 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -3,8 +3,10 @@ import logging from async_timeout import timeout -from pyezviz.client import HTTPError, InvalidURL, PyEzvizError +from pyezviz.client import EzvizClient +from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -15,23 +17,24 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Ezviz data.""" - def __init__(self, hass, *, api): + def __init__( + self, hass: HomeAssistant, *, api: EzvizClient, api_timeout: int + ) -> None: """Initialize global Ezviz data updater.""" self.ezviz_client = api + self._api_timeout = api_timeout update_interval = timedelta(seconds=30) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self): + def _update_data(self) -> dict: """Fetch data from Ezviz via camera load function.""" - cameras = self.ezviz_client.load_cameras() + return self.ezviz_client.load_cameras() - return cameras - - async def _async_update_data(self): + async def _async_update_data(self) -> dict: """Fetch data from Ezviz.""" try: - async with timeout(35): + async with timeout(self._api_timeout): return await self.hass.async_add_executor_job(self._update_data) except (InvalidURL, HTTPError, PyEzvizError) as error: diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py new file mode 100644 index 0000000000000..288c4a5d9ebdd --- /dev/null +++ b/homeassistant/components/ezviz/entity.py @@ -0,0 +1,36 @@ +"""An abstract class common to all Ezviz entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import EzvizDataUpdateCoordinator + + +class EzvizEntity(CoordinatorEntity, Entity): + """Generic entity encapsulating common features of Ezviz device.""" + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._serial = serial + self._camera_name = self.data["name"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + manufacturer=MANUFACTURER, + model=self.data["device_sub_category"], + name=self.data["name"], + sw_version=self.data["version"], + ) + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data for this entity.""" + return self.coordinator.data[self._serial] diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 46abf8bc99a4c..4618f7e44046a 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "dependencies": ["ffmpeg"], "codeowners": ["@RenierM26", "@baqs"], - "requirements": ["pyezviz==0.1.8.7"], + "requirements": ["pyezviz==0.2.0.5"], "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index f4f9f6588f01e..a7334a3d18b0b 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -1,75 +1,81 @@ """Support for Ezviz sensors.""" -import logging - -from pyezviz.constants import SensorType - -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, entry, async_add_entities): +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "sw_version": SensorEntityDescription(key="sw_version"), + "battery_level": SensorEntityDescription( + key="battery_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + "alarm_sound_mod": SensorEntityDescription(key="alarm_sound_mod"), + "detection_sensibility": SensorEntityDescription(key="detection_sensibility"), + "last_alarm_time": SensorEntityDescription(key="last_alarm_time"), + "Seconds_Last_Trigger": SensorEntityDescription( + key="Seconds_Last_Trigger", + entity_registry_enabled_default=False, + ), + "last_alarm_pic": SensorEntityDescription(key="last_alarm_pic"), + "supported_channels": SensorEntityDescription(key="supported_channels"), + "local_ip": SensorEntityDescription(key="local_ip"), + "wan_ip": SensorEntityDescription(key="wan_ip"), + "PIR_Status": SensorEntityDescription(key="PIR_Status"), + "last_alarm_type_code": SensorEntityDescription(key="last_alarm_type_code"), + "last_alarm_type_name": SensorEntityDescription(key="last_alarm_type_name"), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ezviz sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - sensors = [] - sensor_type_name = "None" - - for idx, camera in enumerate(coordinator.data): - for name in camera: - # Only add sensor with value. - if camera.get(name) is None: - continue - - if name in SensorType.__members__: - sensor_type_name = getattr(SensorType, name).value - sensors.append(EzvizSensor(coordinator, idx, name, sensor_type_name)) - - async_add_entities(sensors) - - -class EzvizSensor(CoordinatorEntity, Entity): + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + [ + EzvizSensor(coordinator, camera, sensor) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in SENSOR_TYPES + if value is not None + ] + ) + + +class EzvizSensor(EzvizEntity, SensorEntity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, name, sensor_type_name): - """Initialize the sensor.""" - super().__init__(coordinator) - self._idx = idx - self._camera_name = self.coordinator.data[self._idx]["name"] - self._name = name - self._sensor_name = f"{self._camera_name}.{self._name}" - self.sensor_type_name = sensor_type_name - self._serial = self.coordinator.data[self._idx]["serial"] + coordinator: EzvizDataUpdateCoordinator - @property - def name(self): - """Return the name of the Ezviz sensor.""" - return self._sensor_name + def __init__( + self, coordinator: EzvizDataUpdateCoordinator, serial: str, sensor: str + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._sensor_name = sensor + self._attr_name = f"{self._camera_name} {sensor.title()}" + self._attr_unique_id = f"{serial}_{self._camera_name}.{sensor}" + self.entity_description = SENSOR_TYPES[sensor] @property - def state(self): + def native_value(self) -> int | str: """Return the state of the sensor.""" - return self.coordinator.data[self._idx][self._name] - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._serial}_{self._sensor_name}" - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._serial)}, - "name": self.coordinator.data[self._idx]["name"], - "model": self.coordinator.data[self._idx]["device_sub_category"], - "manufacturer": MANUFACTURER, - "sw_version": self.coordinator.data[self._idx]["version"], - } - - @property - def device_class(self): - """Device class for the sensor.""" - return self.sensor_type_name + return self.data[self._sensor_name] diff --git a/homeassistant/components/ezviz/services.yaml b/homeassistant/components/ezviz/services.yaml new file mode 100644 index 0000000000000..2635662e63656 --- /dev/null +++ b/homeassistant/components/ezviz/services.yaml @@ -0,0 +1,111 @@ +alarm_sound: + name: Set warning sound level. + description: Set movement warning sound level. + target: + entity: + integration: ezviz + domain: camera + fields: + level: + name: Sound level + description: Sound level (2 is disabled, 1 intensive, 0 soft). + required: true + example: 0 + default: 0 + selector: + number: + min: 0 + max: 2 + step: 1 + mode: box +ptz: + name: PTZ + description: Moves the camera to the direction, with defined speed + target: + entity: + integration: ezviz + domain: camera + fields: + direction: + name: Direction + description: Direction to move camera (up, down, left, right). + required: true + example: "up" + default: "up" + selector: + select: + options: + - "up" + - "down" + - "left" + - "right" + speed: + name: Speed + description: Speed of movement (from 1 to 9). + required: true + example: 5 + default: 5 + selector: + number: + min: 1 + max: 9 + step: 1 + mode: box +set_alarm_detection_sensibility: + name: Detection sensitivity + description: Sets the detection sensibility level. + target: + entity: + integration: ezviz + domain: camera + fields: + level: + name: Sensitivity Level + description: 'Sensibility level (1-6) for type 0 (Normal camera) + or (1-100) for type 3 (PIR sensor camera).' + required: true + example: 3 + default: 3 + selector: + number: + min: 1 + max: 100 + step: 1 + mode: box + type_value: + name: Detection type + description: 'Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera' + required: true + example: '0' + default: '0' + selector: + select: + options: + - '0' + - '3' +sound_alarm: + name: Sound Alarm + description: Sounds the alarm on your camera. + target: + entity: + integration: ezviz + domain: camera + fields: + enable: + description: Enter 1 or 2 (1=disable, 2=enable). + required: true + example: 1 + default: 1 + selector: + number: + min: 1 + max: 2 + step: 1 + mode: box +wake_device: + name: Wake Camera + description: This can be used to wake the camera/device from hibernation. + target: + entity: + integration: ezviz + domain: camera diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 00230a3ac2d50..ea8f1e83f70db 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -1,90 +1,85 @@ """Support for Ezviz Switch sensors.""" -import logging +from __future__ import annotations -from pyezviz.constants import DeviceSwitchType +from typing import Any -from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pyezviz.constants import DeviceSwitchType +from pyezviz.exceptions import HTTPError, PyEzvizError -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -_LOGGER = logging.getLogger(__name__) +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ezviz switch based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - switch_entities = [] - supported_switches = [] - - for switches in DeviceSwitchType: - supported_switches.append(switches.value) - - supported_switches = set(supported_switches) + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] - for idx, camera in enumerate(coordinator.data): - if not camera.get("switches"): - continue - for switch in camera["switches"]: - if switch not in supported_switches: - continue - switch_entities.append(EzvizSwitch(coordinator, idx, switch)) + supported_switches = {switches.value for switches in DeviceSwitchType} - async_add_entities(switch_entities) + async_add_entities( + [ + EzvizSwitch(coordinator, camera, switch) + for camera in coordinator.data + for switch in coordinator.data[camera].get("switches") + if switch in supported_switches + ] + ) -class EzvizSwitch(CoordinatorEntity, SwitchEntity): +class EzvizSwitch(EzvizEntity, SwitchEntity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, switch): + coordinator: EzvizDataUpdateCoordinator + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, coordinator: EzvizDataUpdateCoordinator, serial: str, switch: str + ) -> None: """Initialize the switch.""" - super().__init__(coordinator) - self._idx = idx - self._camera_name = self.coordinator.data[self._idx]["name"] + super().__init__(coordinator, serial) self._name = switch - self._sensor_name = f"{self._camera_name}.{DeviceSwitchType(self._name).name}" - self._serial = self.coordinator.data[self._idx]["serial"] - self._device_class = DEVICE_CLASS_SWITCH - - @property - def name(self): - """Return the name of the Ezviz switch.""" - return f"{self._camera_name}.{DeviceSwitchType(self._name).name}" + self._attr_name = f"{self._camera_name} {DeviceSwitchType(switch).name.title()}" + self._attr_unique_id = ( + f"{serial}_{self._camera_name}.{DeviceSwitchType(switch).name}" + ) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the switch.""" - return self.coordinator.data[self._idx]["switches"][self._name] + return self.data["switches"][self._name] - @property - def unique_id(self): - """Return the unique ID of this switch.""" - return f"{self._serial}_{self._sensor_name}" - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" - _LOGGER.debug("Set EZVIZ Switch '%s' to on", self._name) + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, self._serial, self._name, 1 + ) - self.coordinator.ezviz_client.switch_status(self._serial, self._name, 1) + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError("Failed to turn on switch {self._name}") from err - def turn_off(self, **kwargs): - """Change a device switch on the camera.""" - _LOGGER.debug("Set EZVIZ Switch '%s' to off", self._name) + if update_ok: + await self.coordinator.async_request_refresh() - self.coordinator.ezviz_client.switch_status(self._serial, self._name, 0) + async def async_turn_off(self, **kwargs: Any) -> None: + """Change a device switch on the camera.""" + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, self._serial, self._name, 0 + ) - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._serial)}, - "name": self.coordinator.data[self._idx]["name"], - "model": self.coordinator.data[self._idx]["device_sub_category"], - "manufacturer": MANUFACTURER, - "sw_version": self.coordinator.data[self._idx]["version"], - } + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError(f"Failed to turn off switch {self._name}") from err - @property - def device_class(self): - """Device class for the sensor.""" - return self._device_class + if update_ok: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ezviz/translations/ar.json b/homeassistant/components/ezviz/translations/ar.json new file mode 100644 index 0000000000000..4ebc849467936 --- /dev/null +++ b/homeassistant/components/ezviz/translations/ar.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "ezviz_cloud_account_missing": "\u062d\u0633\u0627\u0628 \u0633\u062d\u0627\u0628\u0629 Ezviz \u0645\u0641\u0642\u0648\u062f. \u064a\u0631\u062c\u0649 \u0625\u0639\u0627\u062f\u0629 \u062a\u0643\u0648\u064a\u0646 \u062d\u0633\u0627\u0628 \u0633\u062d\u0627\u0628\u0629 Ezviz" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/ca.json b/homeassistant/components/ezviz/translations/ca.json index c7c71e0712213..7c71de300f683 100644 --- a/homeassistant/components/ezviz/translations/ca.json +++ b/homeassistant/components/ezviz/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured_account": "El compte ja ha estat configurat", + "already_configured_account": "El compte ja est\u00e0 configurat", "ezviz_cloud_account_missing": "Falta el compte d'Ezviz cloud. Torna'l a configurar", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/ezviz/translations/de.json b/homeassistant/components/ezviz/translations/de.json index 0286f942487c8..92faeff2b81cb 100644 --- a/homeassistant/components/ezviz/translations/de.json +++ b/homeassistant/components/ezviz/translations/de.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured_account": "Konto wurde bereits konfiguriert", + "ezviz_cloud_account_missing": "Ezviz-Cloud-Konto fehlt. Bitte konfiguriere das Ezviz-Cloud-Konto neu", "unknown": "Unerwarteter Fehler" }, "error": { @@ -9,12 +10,15 @@ "invalid_auth": "Ung\u00fcltige Authentifizierung", "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, + "flow_title": "{serial}", "step": { "confirm": { "data": { "password": "Passwort", "username": "Benutzername" - } + }, + "description": "RTSP-Anmeldeinformationen f\u00fcr Ezviz-Kamera {serial} mit IP {ip_address} eingeben", + "title": "Entdeckte Ezviz-Kamera" }, "user": { "data": { @@ -29,7 +33,9 @@ "password": "Passwort", "url": "URL", "username": "Benutzername" - } + }, + "description": "URL Region manuell festlegen", + "title": "Verbinden mit benutzerdefinierter Ezviz-URL" } } }, @@ -37,6 +43,7 @@ "step": { "init": { "data": { + "ffmpeg_arguments": "An ffmpeg \u00fcbergebene Argumente f\u00fcr Kameras", "timeout": "Anfrage-Timeout (Sekunden)" } } diff --git a/homeassistant/components/ezviz/translations/es-419.json b/homeassistant/components/ezviz/translations/es-419.json new file mode 100644 index 0000000000000..376cb65c383ba --- /dev/null +++ b/homeassistant/components/ezviz/translations/es-419.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "ezviz_cloud_account_missing": "Falta la cuenta en la nube de Ezviz. Vuelva a configurar la cuenta en la nube de Ezviz" + }, + "flow_title": "{serial}" + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/fr.json b/homeassistant/components/ezviz/translations/fr.json index 216cf73c7b755..ddce689a2ba90 100644 --- a/homeassistant/components/ezviz/translations/fr.json +++ b/homeassistant/components/ezviz/translations/fr.json @@ -15,7 +15,7 @@ "confirm": { "data": { "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "description": "Entrez les informations d'identification RTSP pour la cam\u00e9ra Ezviz {serial} avec IP {ip_address}", "title": "Cam\u00e9ra Ezviz d\u00e9couverte" @@ -24,7 +24,7 @@ "data": { "password": "Mot de passe", "url": "URL", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous \u00e0 Ezviz Cloud" }, @@ -32,7 +32,7 @@ "data": { "password": "Mot de passe", "url": "URL", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "description": "Sp\u00e9cifiez manuellement l'URL de votre r\u00e9gion", "title": "Connectez-vous \u00e0 l'URL Ezviz personnalis\u00e9e" diff --git a/homeassistant/components/ezviz/translations/he.json b/homeassistant/components/ezviz/translations/he.json new file mode 100644 index 0000000000000..e45d7b58600bc --- /dev/null +++ b/homeassistant/components/ezviz/translations/he.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "already_configured_account": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user_custom_url": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d1\u05e7\u05e9\u05d4 (\u05e9\u05e0\u05d9\u05d5\u05ea)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/hu.json b/homeassistant/components/ezviz/translations/hu.json new file mode 100644 index 0000000000000..5907f66ceb42d --- /dev/null +++ b/homeassistant/components/ezviz/translations/hu.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "ezviz_cloud_account_missing": "Ezviz cloud fi\u00f3k hi\u00e1nyzik. K\u00e9rj\u00fck, konfigur\u00e1lja \u00fajra az Ezviz cloud fi\u00f3kot.", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "invalid_host": "\u00c9rv\u00e9nytelen C\u00edm" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "\u00cdrja be az RTSP-hiteles\u00edt\u0151 adatokat az Ezviz {serial} kamer\u00e1hoz IP- {ip_address}", + "title": "Felfedezett Ezviz kamera" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Csatlakozzon az Ezviz Cloud szolg\u00e1ltat\u00e1shoz" + }, + "user_custom_url": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg k\u00e9zzel a r\u00e9gi\u00f3 URL-j\u00e9t", + "title": "Csatlakozzon az Ezviz-hez egy\u00e9ni URL" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "A kamer\u00e1khoz az ffmpeg-nek \u00e1tadott argumentumok", + "timeout": "K\u00e9r\u00e9s id\u0151korl\u00e1tja (m\u00e1sodperc)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/it.json b/homeassistant/components/ezviz/translations/it.json index 84e7811a4a558..0c6ce669ba3fb 100644 --- a/homeassistant/components/ezviz/translations/it.json +++ b/homeassistant/components/ezviz/translations/it.json @@ -34,7 +34,7 @@ "url": "URL", "username": "Nome utente" }, - "description": "Specificare manualmente l'URL dell'area geografica", + "description": "Specifica manualmente l'URL dell'area geografica", "title": "Connettiti all'URL personalizzato di Ezviz" } } diff --git a/homeassistant/components/ezviz/translations/ja.json b/homeassistant/components/ezviz/translations/ja.json new file mode 100644 index 0000000000000..4780b5fd10a2e --- /dev/null +++ b/homeassistant/components/ezviz/translations/ja.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "ezviz_cloud_account_missing": "Ezviz cloud account\u304c\u3042\u308a\u307e\u305b\u3093\u3002Ezviz cloud account\u3092\u518d\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "IP {ip_address} \u3092\u6301\u3064Ezviz\u30ab\u30e1\u30e9 {serial} \u306eRTSP\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u767a\u898b\u3055\u308c\u305fEzviz\u30ab\u30e1\u30e9" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Ezviz Cloud\u306b\u63a5\u7d9a" + }, + "user_custom_url": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30ea\u30fc\u30b8\u30e7\u30f3\u306eURL\u3092\u624b\u52d5\u3067\u6307\u5b9a\u3059\u308b", + "title": "\u30ab\u30b9\u30bf\u30e0 Ezviz Cloud\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "ffmpeg\u306b\u6e21\u3055\u308c\u308b\u30ab\u30e1\u30e9\u7528\u306e\u5f15\u6570", + "timeout": "\u30ea\u30af\u30a8\u30b9\u30c8\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/nl.json b/homeassistant/components/ezviz/translations/nl.json index a6f7b3e985ce5..7b43acb394751 100644 --- a/homeassistant/components/ezviz/translations/nl.json +++ b/homeassistant/components/ezviz/translations/nl.json @@ -35,7 +35,7 @@ "username": "Gebruikersnaam" }, "description": "Geef handmatig de URL van uw regio op", - "title": "Verbind met aangepast Elvis URL" + "title": "Verbind met aangepast Ezviz URL" } } }, diff --git a/homeassistant/components/ezviz/translations/tr.json b/homeassistant/components/ezviz/translations/tr.json new file mode 100644 index 0000000000000..a1ba775da7f8e --- /dev/null +++ b/homeassistant/components/ezviz/translations/tr.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "ezviz_cloud_account_missing": "Ezviz bulut hesab\u0131 eksik. L\u00fctfen Ezviz bulut hesab\u0131n\u0131 yeniden yap\u0131land\u0131r\u0131n", + "unknown": "Beklenmeyen hata" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "IP {ip_address} {serial} i\u00e7in RTSP kimlik bilgilerini girin", + "title": "Ke\u015ffedilen Ezviz Kamera" + }, + "user": { + "data": { + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Ezviz Cloud'a ba\u011flan\u0131n" + }, + "user_custom_url": { + "data": { + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "B\u00f6lge URL'nizi manuel olarak belirtin", + "title": "\u00d6zel Ezviz URL'sine ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Kameralar i\u00e7in ffmpeg'e ge\u00e7irilen arg\u00fcmanlar", + "timeout": "\u0130stek Zaman A\u015f\u0131m\u0131 (saniye)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/zh-Hans.json b/homeassistant/components/ezviz/translations/zh-Hans.json new file mode 100644 index 0000000000000..e88a68c17c251 --- /dev/null +++ b/homeassistant/components/ezviz/translations/zh-Hans.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "ezviz_cloud_account_missing": "\u8424\u77f3\u4e91\u8d26\u53f7\u4e22\u5931\u3002\u8bf7\u91cd\u65b0\u914d\u7f6e\u8424\u77f3\u4e91\u8d26\u53f7\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "invalid_host": "\u65e0\u6548\u7684\u4e3b\u673a\u5730\u5740\u6216 IP \u5730\u5740" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u5e26\u6709 RTSP \u51ed\u8bc1\u7684\u8424\u77f3\u6444\u50cf\u5934{serial} IP {ip_address} ", + "title": "\u5df2\u53d1\u73b0\u7684\u8424\u77f3\u6444\u50cf\u5934" + }, + "user": { + "data": { + "password": "\u5bc6\u7801", + "url": "\u7f51\u5740", + "username": "\u7528\u6237\u540d" + }, + "title": "\u8fde\u63a5\u81f3\u8424\u77f3\u4e91" + }, + "user_custom_url": { + "data": { + "password": "\u5bc6\u7801", + "url": "URL", + "username": "\u7528\u6237\u540d" + }, + "description": "\u624b\u52a8\u6307\u5b9a\u4f60\u7684\u533a\u57df\u7f51\u5740", + "title": "\u8fde\u63a5\u5230\u81ea\u5b9a\u4e49\u8424\u77f3\u4e91\u5730\u5740" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "FFmpeg \u53c2\u6570\u4f20\u9012\u81f3\u6444\u50cf\u673a", + "timeout": "\u8bf7\u6c42\u8d85\u65f6\uff08\u79d2\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index 56cf9ad13bcd2..8bfcf60f30ae6 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -7,7 +7,7 @@ from faadelays import Airport from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID +from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,10 +16,10 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor"] +PLATFORMS = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up FAA Delays from a config entry.""" code = entry.data[CONF_ID] @@ -34,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -56,7 +56,7 @@ def __init__(self, hass, code): async def _async_update_data(self): try: - with timeout(10): + async with timeout(10): await self.data.update() except ClientConnectionError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index b96ee24a5bc21..9ef55a1f2ccb7 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,6 +1,12 @@ """Platform for FAA Delays sensor component.""" -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import ATTR_ICON, ATTR_NAME +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, FAA_BINARY_SENSORS @@ -10,83 +16,68 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up a FAA sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - binary_sensors = [] - for kind, attrs in FAA_BINARY_SENSORS.items(): - name = attrs[ATTR_NAME] - icon = attrs[ATTR_ICON] - - binary_sensors.append( - FAABinarySensor(coordinator, kind, name, icon, entry.entry_id) - ) + entities = [ + FAABinarySensor(coordinator, entry.entry_id, description) + for description in FAA_BINARY_SENSORS + ] - async_add_entities(binary_sensors) + async_add_entities(entities) class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): """Define a binary sensor for FAA Delays.""" - def __init__(self, coordinator, sensor_type, name, icon, entry_id): + def __init__( + self, coordinator, entry_id, description: BinarySensorEntityDescription + ): """Initialize the sensor.""" super().__init__(coordinator) + self.entity_description = description self.coordinator = coordinator self._entry_id = entry_id - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._id = self.coordinator.data.iata - self._attrs = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._id} {self._name}" - - @property - def icon(self): - """Return the icon.""" - return self._icon + self._attrs: dict[str, Any] = {} + _id = coordinator.data.iata + self._attr_name = f"{_id} {description.name}" + self._attr_unique_id = f"{_id}_{description.key}" @property def is_on(self): """Return the status of the sensor.""" - if self._sensor_type == "GROUND_DELAY": + sensor_type = self.entity_description.key + if sensor_type == "GROUND_DELAY": return self.coordinator.data.ground_delay.status - if self._sensor_type == "GROUND_STOP": + if sensor_type == "GROUND_STOP": return self.coordinator.data.ground_stop.status - if self._sensor_type == "DEPART_DELAY": + if sensor_type == "DEPART_DELAY": return self.coordinator.data.depart_delay.status - if self._sensor_type == "ARRIVE_DELAY": + if sensor_type == "ARRIVE_DELAY": return self.coordinator.data.arrive_delay.status - if self._sensor_type == "CLOSURE": + if sensor_type == "CLOSURE": return self.coordinator.data.closure.status return None - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._id}_{self._sensor_type}" - @property def extra_state_attributes(self): """Return attributes for sensor.""" - if self._sensor_type == "GROUND_DELAY": + sensor_type = self.entity_description.key + if sensor_type == "GROUND_DELAY": self._attrs["average"] = self.coordinator.data.ground_delay.average self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif self._sensor_type == "GROUND_STOP": + elif sensor_type == "GROUND_STOP": self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif self._sensor_type == "DEPART_DELAY": + elif sensor_type == "DEPART_DELAY": self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum self._attrs["trend"] = self.coordinator.data.depart_delay.trend self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif self._sensor_type == "ARRIVE_DELAY": + elif sensor_type == "ARRIVE_DELAY": self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum self._attrs["trend"] = self.coordinator.data.arrive_delay.trend self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif self._sensor_type == "CLOSURE": + elif sensor_type == "CLOSURE": self._attrs["begin"] = self.coordinator.data.closure.begin self._attrs["end"] = self.coordinator.data.closure.end self._attrs["reason"] = self.coordinator.data.closure.reason diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py index c725be8810644..f7ee8e7bad8ca 100644 --- a/homeassistant/components/faa_delays/const.py +++ b/homeassistant/components/faa_delays/const.py @@ -1,28 +1,34 @@ """Constants for the FAA Delays integration.""" +from __future__ import annotations -from homeassistant.const import ATTR_ICON, ATTR_NAME +from homeassistant.components.binary_sensor import BinarySensorEntityDescription DOMAIN = "faa_delays" -FAA_BINARY_SENSORS = { - "GROUND_DELAY": { - ATTR_NAME: "Ground Delay", - ATTR_ICON: "mdi:airport", - }, - "GROUND_STOP": { - ATTR_NAME: "Ground Stop", - ATTR_ICON: "mdi:airport", - }, - "DEPART_DELAY": { - ATTR_NAME: "Departure Delay", - ATTR_ICON: "mdi:airplane-takeoff", - }, - "ARRIVE_DELAY": { - ATTR_NAME: "Arrival Delay", - ATTR_ICON: "mdi:airplane-landing", - }, - "CLOSURE": { - ATTR_NAME: "Closure", - ATTR_ICON: "mdi:airplane:off", - }, -} +FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="GROUND_DELAY", + name="Ground Delay", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="GROUND_STOP", + name="Ground Stop", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="DEPART_DELAY", + name="Departure Delay", + icon="mdi:airplane-takeoff", + ), + BinarySensorEntityDescription( + key="ARRIVE_DELAY", + name="Arrival Delay", + icon="mdi:airplane-landing", + ), + BinarySensorEntityDescription( + key="CLOSURE", + name="Closure", + icon="mdi:airplane:off", + ), +) diff --git a/homeassistant/components/faa_delays/translations/bg.json b/homeassistant/components/faa_delays/translations/bg.json index 0995436221b2d..93fa3f04d6c41 100644 --- a/homeassistant/components/faa_delays/translations/bg.json +++ b/homeassistant/components/faa_delays/translations/bg.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u0422\u043e\u0432\u0430 \u043b\u0435\u0442\u0438\u0449\u0435 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e." + }, "error": { + "invalid_airport": "\u041a\u043e\u0434\u044a\u0442 \u043d\u0430 \u043b\u0435\u0442\u0438\u0449\u0435\u0442\u043e \u043d\u0435 \u0435 \u0432\u0430\u043b\u0438\u0434\u0435\u043d", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { diff --git a/homeassistant/components/faa_delays/translations/de.json b/homeassistant/components/faa_delays/translations/de.json index 9519c7d44705f..b10619dc23d10 100644 --- a/homeassistant/components/faa_delays/translations/de.json +++ b/homeassistant/components/faa_delays/translations/de.json @@ -13,7 +13,7 @@ "data": { "id": "Flughafen" }, - "description": "Geben Sie einen US-Flughafencode im IATA-Format ein", + "description": "Gib einen US-Flughafencode im IATA-Format ein", "title": "FAA Delays" } } diff --git a/homeassistant/components/faa_delays/translations/es-419.json b/homeassistant/components/faa_delays/translations/es-419.json new file mode 100644 index 0000000000000..838f7af274dc0 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Este aeropuerto ya est\u00e1 configurado." + }, + "error": { + "invalid_airport": "El c\u00f3digo del aeropuerto no es v\u00e1lido" + }, + "step": { + "user": { + "data": { + "id": "Aeropuerto" + }, + "description": "Ingrese un c\u00f3digo de aeropuerto de EE. UU. en formato IATA", + "title": "Retrasos de la FAA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/ja.json b/homeassistant/components/faa_delays/translations/ja.json new file mode 100644 index 0000000000000..144133ae2a022 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u3053\u306e\u7a7a\u6e2f\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_airport": "\u7a7a\u6e2f\u30b3\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "id": "\u7a7a\u6e2f" + }, + "description": "IATA\u5f62\u5f0f\u3067\u7c73\u56fd\u306e\u7a7a\u6e2f\u30b3\u30fc\u30c9(US Airport Code)\u3092\u5165\u529b\u3057\u307e\u3059", + "title": "FAA Delays" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/tr.json b/homeassistant/components/faa_delays/translations/tr.json new file mode 100644 index 0000000000000..bcca79ce39279 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Bu havaalan\u0131 zaten yap\u0131land\u0131r\u0131lm\u0131\u015f." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_airport": "Havaalan\u0131 kodu ge\u00e7erli de\u011fil", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "id": "Havaliman\u0131" + }, + "description": "ABD Havaalan\u0131 Kodu'nu IATA Format\u0131nda Girin", + "title": "FAA Gecikmeleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 0ce2fbfc665ed..ea8848a5af210 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -1,4 +1,5 @@ """Facebook platform for notify component.""" +from http import HTTPStatus import json import logging @@ -12,7 +13,7 @@ PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK +from homeassistant.const import CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -76,7 +77,7 @@ def send_message(self, message="", **kwargs): headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10, ) - if resp.status_code != HTTP_OK: + if resp.status_code != HTTPStatus.OK: log_error(resp) diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py index 5c90ce73560ff..ba95d1cd4767a 100644 --- a/homeassistant/components/facebox/image_processing.py +++ b/homeassistant/components/facebox/image_processing.py @@ -1,5 +1,6 @@ """Component for facial detection and identification via facebox.""" import base64 +from http import HTTPStatus import logging import requests @@ -21,9 +22,6 @@ CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - HTTP_BAD_REQUEST, - HTTP_OK, - HTTP_UNAUTHORIZED, ) from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv @@ -67,10 +65,10 @@ def check_box_health(url, username, password): kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) try: response = requests.get(url, **kwargs) - if response.status_code == HTTP_UNAUTHORIZED: + if response.status_code == HTTPStatus.UNAUTHORIZED: _LOGGER.error("AuthenticationError on %s", CLASSIFIER) return None - if response.status_code == HTTP_OK: + if response.status_code == HTTPStatus.OK: return response.json()["hostname"] except requests.exceptions.ConnectionError: _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) @@ -115,7 +113,7 @@ def post_image(url, image, username, password): kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) try: response = requests.post(url, json={"base64": encode_image(image)}, **kwargs) - if response.status_code == HTTP_UNAUTHORIZED: + if response.status_code == HTTPStatus.UNAUTHORIZED: _LOGGER.error("AuthenticationError on %s", CLASSIFIER) return None return response @@ -137,9 +135,9 @@ def teach_file(url, name, file_path, username, password): files={"file": open_file}, **kwargs, ) - if response.status_code == HTTP_UNAUTHORIZED: + if response.status_code == HTTPStatus.UNAUTHORIZED: _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - elif response.status_code == HTTP_BAD_REQUEST: + elif response.status_code == HTTPStatus.BAD_REQUEST: _LOGGER.error( "%s teaching of file %s failed with message:%s", CLASSIFIER, diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml index caa2e7df2c669..3f968cf385a8e 100644 --- a/homeassistant/components/facebox/services.yaml +++ b/homeassistant/components/facebox/services.yaml @@ -1,12 +1,25 @@ teach_face: + name: Teach face description: Teach facebox a face using a file. fields: entity_id: + name: Entity description: The facebox entity to teach. - example: "image_processing.facebox" + selector: + entity: + integration: facebox + domain: image_processing name: + name: Name description: The name of the face to teach. + required: true example: "my_name" + selector: + text: file_path: + name: File path description: The path to the image file. + required: true example: "/images/my_image.jpg" + selector: + text: diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 908ab5d77c0c2..5a7e1052b6762 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -70,7 +70,7 @@ def extra_state_attributes(self): return self.ban_dict @property - def state(self): + def native_value(self): """Return the most recently banned IP Address.""" return self.last_ban diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index ea654074a5a19..65b7a63e4194b 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -1,4 +1,6 @@ """Family Hub camera for Samsung Refrigerators.""" +from __future__ import annotations + from pyfamilyhublocal import FamilyHubCam import voluptuous as vol @@ -38,7 +40,9 @@ def __init__(self, name, family_hub_cam): self._name = name self.family_hub_cam = family_hub_cam - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" return await self.family_hub_cam.async_get_cam_image() diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index f484ca36b25aa..87f50c7d93811 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,6 +1,7 @@ """Provides functionality to interact with fans.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -9,19 +10,22 @@ import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.percentage import ( ordered_list_item_to_percentage, @@ -68,32 +72,7 @@ ATTR_PRESET_MODE = "preset_mode" ATTR_PRESET_MODES = "preset_modes" -# Invalid speeds do not conform to the entity model, but have crept -# into core integrations at some point so we are temporarily -# accommodating them in the transition to percentages. _NOT_SPEED_OFF = "off" -_NOT_SPEED_ON = "on" -_NOT_SPEED_AUTO = "auto" -_NOT_SPEED_SMART = "smart" -_NOT_SPEED_INTERVAL = "interval" -_NOT_SPEED_IDLE = "idle" -_NOT_SPEED_FAVORITE = "favorite" -_NOT_SPEED_SLEEP = "sleep" -_NOT_SPEED_SILENT = "silent" - -_NOT_SPEEDS_FILTER = { - _NOT_SPEED_OFF, - _NOT_SPEED_ON, - _NOT_SPEED_AUTO, - _NOT_SPEED_SMART, - _NOT_SPEED_INTERVAL, - _NOT_SPEED_IDLE, - _NOT_SPEED_SILENT, - _NOT_SPEED_SLEEP, - _NOT_SPEED_FAVORITE, -} - -_FAN_NATIVE = "_fan_native" OFF_SPEED_VALUES = [SPEED_OFF, None] @@ -121,7 +100,7 @@ def is_on(hass, entity_id: str) -> bool: return state.state == STATE_ON -async def async_setup(hass, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose fan control via statemachine and services.""" component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL @@ -204,77 +183,67 @@ async def async_setup(hass, config: dict): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) -def _fan_native(method): - """Native fan method not overridden.""" - setattr(method, _FAN_NATIVE, True) - return method +@dataclass +class FanEntityDescription(ToggleEntityDescription): + """A class that describes fan entities.""" class FanEntity(ToggleEntity): """Base class for fan entities.""" - @_fan_native + entity_description: FanEntityDescription + _attr_current_direction: str | None = None + _attr_oscillating: bool | None = None + _attr_percentage: int | None + _attr_preset_mode: str | None + _attr_preset_modes: list[str] | None + _attr_speed_count: int + _attr_supported_features: int = 0 + def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" raise NotImplementedError() async def async_set_speed_deprecated(self, speed: str): """Set the speed of the fan.""" - _LOGGER.warning( - "The fan.set_speed service is deprecated, use fan.set_percentage or fan.set_preset_mode instead" + _LOGGER.error( + "The fan.set_speed service is deprecated and will fail in 2022.3 and later, use fan.set_percentage or fan.set_preset_mode instead" ) await self.async_set_speed(speed) - @_fan_native async def async_set_speed(self, speed: str): """Set the speed of the fan.""" if speed == SPEED_OFF: await self.async_turn_off() return - if speed in self.preset_modes: - if not hasattr(self.async_set_preset_mode, _FAN_NATIVE): - await self.async_set_preset_mode(speed) - return - if not hasattr(self.set_preset_mode, _FAN_NATIVE): - await self.hass.async_add_executor_job(self.set_preset_mode, speed) - return - else: - if not hasattr(self.async_set_percentage, _FAN_NATIVE): - await self.async_set_percentage(self.speed_to_percentage(speed)) - return - if not hasattr(self.set_percentage, _FAN_NATIVE): - await self.hass.async_add_executor_job( - self.set_percentage, self.speed_to_percentage(speed) - ) - return + if self.preset_modes and speed in self.preset_modes: + await self.async_set_preset_mode(speed) + return - await self.hass.async_add_executor_job(self.set_speed, speed) + await self.async_set_percentage(self.speed_to_percentage(speed)) - @_fan_native def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" raise NotImplementedError() - @_fan_native async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" if percentage == 0: await self.async_turn_off() - elif not hasattr(self.set_percentage, _FAN_NATIVE): - await self.hass.async_add_executor_job(self.set_percentage, percentage) - else: - await self.async_set_speed(self.percentage_to_speed(percentage)) + await self.hass.async_add_executor_job(self.set_percentage, percentage) async def async_increase_speed(self, percentage_step: int | None = None) -> None: """Increase the speed of the fan.""" @@ -305,26 +274,18 @@ async def _async_adjust_speed( await self.async_set_percentage(new_percentage) - @_fan_native def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - self._valid_preset_mode_or_raise(preset_mode) - self.set_speed(preset_mode) + raise NotImplementedError() - @_fan_native async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if not hasattr(self.set_preset_mode, _FAN_NATIVE): - await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) - return - - self._valid_preset_mode_or_raise(preset_mode) - await self.async_set_speed(preset_mode) + await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) def _valid_preset_mode_or_raise(self, preset_mode): """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes - if preset_mode not in preset_modes: + if not preset_modes or preset_mode not in preset_modes: raise NotValidPresetModeError( f"The preset_mode {preset_mode} is not a valid preset_mode: {preset_modes}" ) @@ -360,18 +321,17 @@ async def async_turn_on_compat( This _compat version wraps async_turn_on with backwards and forward compatibility. - After the transition to percentage and preset_modes concludes, it - should be removed. + This compatibility shim will be removed in 2022.3 """ if preset_mode is not None: self._valid_preset_mode_or_raise(preset_mode) speed = preset_mode percentage = None elif speed is not None: - _LOGGER.warning( - "Calling fan.turn_on with the speed argument is deprecated, use percentage or preset_mode instead" + _LOGGER.error( + "Calling fan.turn_on with the speed argument is deprecated and will fail in 2022.3 and later, use percentage or preset_mode instead" ) - if speed in self.preset_modes: + if self.preset_modes and speed in self.preset_modes: preset_mode = speed percentage = None else: @@ -421,56 +381,27 @@ def is_on(self): """Return true if the entity is on.""" return self.speed not in [SPEED_OFF, None] - @property - def _implemented_percentage(self) -> bool: - """Return true if percentage has been implemented.""" - return not hasattr(self.set_percentage, _FAN_NATIVE) or not hasattr( - self.async_set_percentage, _FAN_NATIVE - ) - - @property - def _implemented_preset_mode(self) -> bool: - """Return true if preset_mode has been implemented.""" - return not hasattr(self.set_preset_mode, _FAN_NATIVE) or not hasattr( - self.async_set_preset_mode, _FAN_NATIVE - ) - - @property - def _implemented_speed(self) -> bool: - """Return true if speed has been implemented.""" - return not hasattr(self.set_speed, _FAN_NATIVE) or not hasattr( - self.async_set_speed, _FAN_NATIVE - ) - @property def speed(self) -> str | None: """Return the current speed.""" - if self._implemented_preset_mode: - preset_mode = self.preset_mode - if preset_mode: - return preset_mode - if self._implemented_percentage: - percentage = self.percentage - if percentage is None: - return None - return self.percentage_to_speed(percentage) - return None + if preset_mode := self.preset_mode: + return preset_mode + if (percentage := self.percentage) is None: + return None + return self.percentage_to_speed(percentage) @property def percentage(self) -> int | None: """Return the current speed as a percentage.""" - if not self._implemented_preset_mode and self.speed in self.preset_modes: - return None - if not self._implemented_percentage: - return self.speed_to_percentage(self.speed) + if hasattr(self, "_attr_percentage"): + return self._attr_percentage return 0 @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - speed_list = speed_list_without_preset_modes(self.speed_list) - if speed_list: - return len(speed_list) + if hasattr(self, "_attr_speed_count"): + return self._attr_speed_count return 100 @property @@ -481,22 +412,20 @@ def percentage_step(self) -> float: @property def speed_list(self) -> list: """Get the list of available speeds.""" - speeds = [] - if self._implemented_percentage: - speeds += [SPEED_OFF, *LEGACY_SPEED_LIST] - if self._implemented_preset_mode: - speeds += self.preset_modes + speeds = [SPEED_OFF, *LEGACY_SPEED_LIST] + if preset_modes := self.preset_modes: + speeds.extend(preset_modes) return speeds @property def current_direction(self) -> str | None: """Return the current direction of the fan.""" - return None + return self._attr_current_direction @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" - return None + return self._attr_oscillating @property def capability_attributes(self): @@ -513,84 +442,27 @@ def capability_attributes(self): return attrs - @property - def _speed_list_without_preset_modes(self) -> list: - """Return the speed list without preset modes. - - This property provides forward and backwards - compatibility for conversion to percentage speeds. - """ - if not self._implemented_speed: - return LEGACY_SPEED_LIST - return speed_list_without_preset_modes(self.speed_list) - - def speed_to_percentage(self, speed: str) -> int: - """ - Map a speed to a percentage. - - Officially this should only have to deal with the 4 pre-defined speeds: - - return { - SPEED_OFF: 0, - SPEED_LOW: 33, - SPEED_MEDIUM: 66, - SPEED_HIGH: 100, - }[speed] - - Unfortunately lots of fans make up their own speeds. So the default - mapping is more dynamic. - """ + def speed_to_percentage(self, speed: str) -> int: # pylint: disable=no-self-use + """Map a legacy speed to a percentage.""" if speed in OFF_SPEED_VALUES: return 0 - - speed_list = self._speed_list_without_preset_modes - - if speed_list and speed not in speed_list: + if speed not in LEGACY_SPEED_LIST: raise NotValidSpeedError(f"The speed {speed} is not a valid speed.") + return ordered_list_item_to_percentage(LEGACY_SPEED_LIST, speed) - try: - return ordered_list_item_to_percentage(speed_list, speed) - except ValueError as ex: - raise NoValidSpeedsError( - f"The speed_list {speed_list} does not contain any valid speeds." - ) from ex - - def percentage_to_speed(self, percentage: int) -> str: - """ - Map a percentage onto self.speed_list. - - Officially, this should only have to deal with 4 pre-defined speeds. - - if value == 0: - return SPEED_OFF - elif value <= 33: - return SPEED_LOW - elif value <= 66: - return SPEED_MEDIUM - else: - return SPEED_HIGH - - Unfortunately there is currently a high degree of non-conformancy. - Until fans have been corrected a more complicated and dynamic - mapping is used. - """ + def percentage_to_speed( # pylint: disable=no-self-use + self, percentage: int + ) -> str: + """Map a percentage to a legacy speed.""" if percentage == 0: return SPEED_OFF - - speed_list = self._speed_list_without_preset_modes - - try: - return percentage_to_ordered_list_item(speed_list, percentage) - except ValueError as ex: - raise NoValidSpeedsError( - f"The speed_list {speed_list} does not contain any valid speeds." - ) from ex + return percentage_to_ordered_list_item(LEGACY_SPEED_LIST, percentage) @final @property def state_attributes(self) -> dict: """Return optional state attributes.""" - data = {} + data: dict[str, float | str | None] = {} supported_features = self.supported_features if supported_features & SUPPORT_DIRECTION: @@ -615,7 +487,7 @@ def state_attributes(self) -> dict: @property def supported_features(self) -> int: """Flag supported features.""" - return 0 + return self._attr_supported_features @property def preset_mode(self) -> str | None: @@ -623,9 +495,8 @@ def preset_mode(self) -> str | None: Requires SUPPORT_SET_SPEED. """ - speed = self.speed - if speed in self.preset_modes: - return speed + if hasattr(self, "_attr_preset_mode"): + return self._attr_preset_mode return None @property @@ -634,54 +505,6 @@ def preset_modes(self) -> list[str] | None: Requires SUPPORT_SET_SPEED. """ - return preset_modes_from_speed_list(self.speed_list) - - -def speed_list_without_preset_modes(speed_list: list): - """Filter out non-speeds from the speed list. - - The goal is to get the speeds in a list from lowest to - highest by removing speeds that are not valid or out of order - so we can map them to percentages. - - Examples: - input: ["off", "low", "low-medium", "medium", "medium-high", "high", "auto"] - output: ["low", "low-medium", "medium", "medium-high", "high"] - - input: ["off", "auto", "low", "medium", "high"] - output: ["low", "medium", "high"] - - input: ["off", "1", "2", "3", "4", "5", "6", "7", "smart"] - output: ["1", "2", "3", "4", "5", "6", "7"] - - input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] - output: ["Medium", "High", "Strong"] - """ - - return [speed for speed in speed_list if speed.lower() not in _NOT_SPEEDS_FILTER] - - -def preset_modes_from_speed_list(speed_list: list): - """Filter out non-preset modes from the speed list. - - The goal is to return only preset modes. - - Examples: - input: ["off", "low", "low-medium", "medium", "medium-high", "high", "auto"] - output: ["auto"] - - input: ["off", "auto", "low", "medium", "high"] - output: ["auto"] - - input: ["off", "1", "2", "3", "4", "5", "6", "7", "smart"] - output: ["smart"] - - input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] - output: ["Auto", "Silent", "Favorite", "Idle"] - """ - - return [ - speed - for speed in speed_list - if speed.lower() in _NOT_SPEEDS_FILTER and speed.lower() != SPEED_OFF - ] + if hasattr(self, "_attr_preset_modes"): + return self._attr_preset_modes + return None diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index f4611d353d512..0482c31b9291e 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -28,7 +28,9 @@ ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Fan devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] @@ -38,22 +40,12 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: if entry.domain != DOMAIN: continue - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "turn_on", - } - ) - actions.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "turn_off", - } - ) + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + actions += [{**base_action, CONF_TYPE: action} for action in ACTION_TYPES] return actions diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index 9aa9620ef720e..b0882137d7f45 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -42,35 +42,23 @@ async def async_get_conditions( if entry.domain != DOMAIN: continue - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_on", - } - ) - conditions.append( - { - CONF_CONDITION: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, - CONF_TYPE: "is_off", - } - ) + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + } + + conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] return conditions @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == "is_on": state = STATE_ON else: diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 15f8f4be45e5b..503aaaac52a03 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -1,9 +1,14 @@ """Provides device automations for Fan.""" from __future__ import annotations +from typing import Any + import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -16,12 +21,16 @@ ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Fan devices.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) @@ -30,7 +39,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py index 2d4244ec2dce4..c18e8352b24fc 100644 --- a/homeassistant/components/fan/reproduce_state.py +++ b/homeassistant/components/fan/reproduce_state.py @@ -50,9 +50,7 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - cur_state = hass.states.get(state.entity_id) - - if cur_state is None: + if (cur_state := hass.states.get(state.entity_id)) is None: _LOGGER.warning("Unable to find entity %s", state.entity_id) return diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index f86a32823dcf8..ee39229699ddb 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -3,6 +3,8 @@ set_speed: name: Set speed description: Set fan speed. target: + entity: + domain: fan fields: speed: name: Speed @@ -16,6 +18,8 @@ set_preset_mode: name: Set preset mode description: Set preset mode for a fan device. target: + entity: + domain: fan fields: preset_mode: name: Preset mode @@ -29,40 +33,40 @@ set_percentage: name: Set speed percentage description: Set fan speed percentage. target: + entity: + domain: fan fields: percentage: name: Percentage description: Percentage speed setting. required: true - example: 25 selector: number: min: 0 max: 100 - step: 1 unit_of_measurement: "%" - mode: slider turn_on: name: Turn on description: Turn fan on. target: + entity: + domain: fan fields: speed: name: Speed description: Speed setting. example: "high" + selector: + text: percentage: name: Percentage description: Percentage speed setting. - example: 75 selector: number: min: 0 max: 100 - step: 1 unit_of_measurement: "%" - mode: slider preset_mode: name: Preset mode description: Preset mode setting. @@ -74,17 +78,20 @@ turn_off: name: Turn off description: Turn fan off. target: + entity: + domain: fan oscillate: name: Oscillate description: Oscillate the fan. target: + entity: + domain: fan fields: oscillating: name: Oscillating description: Flag to turn on/off oscillation. required: true - example: true selector: boolean: @@ -92,17 +99,20 @@ toggle: name: Toggle description: Toggle the fan on/off. target: + entity: + domain: fan set_direction: name: Set direction description: Set the fan rotation. target: + entity: + domain: fan fields: direction: name: Direction description: The direction to rotate. required: true - example: "forward" selector: select: options: @@ -113,34 +123,32 @@ increase_speed: name: Increase speed description: Increase the speed of the fan by one speed or a percentage_step. target: + entity: + domain: fan fields: percentage_step: advanced: true required: false description: Increase speed by a percentage. - example: 50 selector: number: min: 0 max: 100 - step: 1 unit_of_measurement: "%" - mode: slider decrease_speed: name: Decrease speed description: Decrease the speed of the fan by one speed or a percentage_step. target: + entity: + domain: fan fields: percentage_step: advanced: true required: false description: Decrease speed by a percentage. - example: 50 selector: number: min: 0 max: 100 - step: 1 unit_of_measurement: "%" - mode: slider diff --git a/homeassistant/components/fan/translations/he.json b/homeassistant/components/fan/translations/he.json index e2081b7460eba..92e38a799182b 100644 --- a/homeassistant/components/fan/translations/he.json +++ b/homeassistant/components/fan/translations/he.json @@ -1,8 +1,22 @@ { + "device_automation": { + "action_type": { + "turn_off": "\u05db\u05d9\u05d1\u05d5\u05d9 {entity_name}", + "turn_on": "\u05d4\u05e4\u05e2\u05dc\u05ea {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", + "is_on": "{entity_name} \u05e4\u05d5\u05e2\u05dc" + }, + "trigger_type": { + "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", + "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc" + } + }, "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05de\u05d0\u05d5\u05d5\u05e8\u05e8" diff --git a/homeassistant/components/fan/translations/ja.json b/homeassistant/components/fan/translations/ja.json index 15dd3796187f2..46e5a16a4d39e 100644 --- a/homeassistant/components/fan/translations/ja.json +++ b/homeassistant/components/fan/translations/ja.json @@ -1,8 +1,23 @@ { + "device_automation": { + "action_type": { + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059" + }, + "trigger_type": { + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, "state": { "_": { "off": "\u30aa\u30d5", "on": "\u30aa\u30f3" } - } + }, + "title": "\u30d5\u30a1\u30f3" } \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ru.json b/homeassistant/components/fan/translations/ru.json index 320b4e280c5f4..bc2fd221736af 100644 --- a/homeassistant/components/fan/translations/ru.json +++ b/homeassistant/components/fan/translations/ru.json @@ -15,8 +15,8 @@ }, "state": { "_": { - "off": "\u0412\u044b\u043a\u043b", - "on": "\u0412\u043a\u043b" + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" } }, "title": "\u0412\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440" diff --git a/homeassistant/components/fan/translations/tr.json b/homeassistant/components/fan/translations/tr.json index 52a07c35d8326..5f43af3e00577 100644 --- a/homeassistant/components/fan/translations/tr.json +++ b/homeassistant/components/fan/translations/tr.json @@ -4,6 +4,10 @@ "turn_off": "{entity_name} kapat", "turn_on": "{entity_name} a\u00e7\u0131n" }, + "condition_type": { + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k" + }, "trigger_type": { "turned_off": "{entity_name} kapat\u0131ld\u0131", "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index e0a4782493e4e..34c8bda2c6c42 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,15 +1,20 @@ """Support for testing internet speed via Fast.com.""" -from datetime import timedelta +from __future__ import annotations + +from datetime import datetime, timedelta import logging +from typing import Any from fastdotcom import fast_com import voluptuous as vol from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType DOMAIN = "fastdotcom" DATA_UPDATED = f"{DOMAIN}_data_updated" @@ -35,7 +40,7 @@ ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Fast.com component.""" conf = config[DOMAIN] data = hass.data[DOMAIN] = SpeedtestData(hass) @@ -43,7 +48,7 @@ async def async_setup(hass, config): if not conf[CONF_MANUAL]: async_track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL]) - def update(call=None): + def update(service_call: ServiceCall | None = None) -> None: """Service call to manually update the data.""" data.update() @@ -57,12 +62,12 @@ def update(call=None): class SpeedtestData: """Get the latest data from fast.com.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the data object.""" - self.data = None + self.data: dict[str, Any] | None = None self._hass = hass - def update(self, now=None): + def update(self, now: datetime | None = None) -> None: """Get the latest data from fast.com.""" _LOGGER.debug("Executing fast.com speedtest") diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b4406a4de95f8..8363981b5268a 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,16 +1,27 @@ """Support for Fast.com internet speed testing sensor.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.sensor import SensorEntity from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN ICON = "mdi:speedometer" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Fast.com sensor.""" async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) @@ -18,38 +29,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" - def __init__(self, speedtest_data): - """Initialize the sensor.""" - self._name = "Fast.com Download" - self.speedtest_client = speedtest_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + _attr_name = "Fast.com Download" + _attr_native_unit_of_measurement = DATA_RATE_MEGABITS_PER_SECOND + _attr_icon = ICON + _attr_should_poll = False + _attr_native_value = None - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return DATA_RATE_MEGABITS_PER_SECOND - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def should_poll(self): - """Return the polling requirement for this sensor.""" - return False + def __init__(self, speedtest_data: dict[str, Any]) -> None: + """Initialize the sensor.""" + self._speedtest_data = speedtest_data - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() @@ -59,18 +49,16 @@ async def async_added_to_hass(self): ) ) - state = await self.async_get_last_state() - if not state: + if not (state := await self.async_get_last_state()): return - self._state = state.state + self._attr_native_value = state.state - def update(self): + def update(self) -> None: """Get the latest data and update the states.""" - data = self.speedtest_client.data - if data is None: + if (data := self._speedtest_data.data) is None: # type: ignore[attr-defined] return - self._state = data["download"] + self._attr_native_value = data["download"] @callback - def _schedule_immediate_update(self): + def _schedule_immediate_update(self) -> None: self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/fastdotcom/services.yaml b/homeassistant/components/fastdotcom/services.yaml index 3664f9ece9f4e..75963557a03a2 100644 --- a/homeassistant/components/fastdotcom/services.yaml +++ b/homeassistant/components/fastdotcom/services.yaml @@ -1,2 +1,3 @@ speedtest: + name: Speed test description: Immediately execute a speed test with Fast.com diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 55e34a547e35f..e23be94f0ce1a 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -20,6 +20,7 @@ async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.loader import bind_hass DOMAIN = "ffmpeg" @@ -89,15 +90,34 @@ async def async_service_handle(service): return True +@bind_hass +def get_ffmpeg_manager(hass: HomeAssistant) -> FFmpegManager: + """Return the FFmpegManager.""" + if DATA_FFMPEG not in hass.data: + raise ValueError("ffmpeg component not initialized") + return hass.data[DATA_FFMPEG] + + +@bind_hass async def async_get_image( hass: HomeAssistant, input_source: str, output_format: str = IMAGE_JPEG, extra_cmd: str | None = None, -): + width: int | None = None, + height: int | None = None, +) -> bytes | None: """Get an image from a frame of an RTSP stream.""" manager = hass.data[DATA_FFMPEG] ffmpeg = ImageFrame(manager.binary) + + if width and height and (extra_cmd is None or "-s" not in extra_cmd): + size_cmd = f"-s {width}x{height}" + if extra_cmd is None: + extra_cmd = size_cmd + else: + extra_cmd += " " + size_cmd + image = await asyncio.shield( ffmpeg.get_image(input_source, output_format=output_format, extra_cmd=extra_cmd) ) diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 4cd8b0d145360..323eae7c1299f 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,4 +1,5 @@ """Support for Cameras with FFmpeg as decoder.""" +from __future__ import annotations from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG @@ -49,7 +50,9 @@ async def stream_source(self): """Return the stream source.""" return self._input.split(" ")[-1] - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return await async_get_image( self.hass, diff --git a/homeassistant/components/ffmpeg/services.yaml b/homeassistant/components/ffmpeg/services.yaml index 15afa82ed0a4e..1fdde46e55c46 100644 --- a/homeassistant/components/ffmpeg/services.yaml +++ b/homeassistant/components/ffmpeg/services.yaml @@ -1,18 +1,33 @@ restart: + name: Restart description: Send a restart command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entities that will restart. Platform dependent. - example: binary_sensor.ffmpeg_noise + name: Entity + description: Name of entity that will restart. Platform dependent. + selector: + entity: + integration: ffmpeg + domain: binary_sensor start: + name: Start description: Send a start command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entities that will start. Platform dependent. - example: binary_sensor.ffmpeg_noise + name: Entity + description: Name of entity that will start. Platform dependent. + selector: + entity: + integration: ffmpeg + domain: binary_sensor stop: + name: Stop description: Send a stop command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entities that will stop. Platform dependent. - example: binary_sensor.ffmpeg_noise + name: Entity + description: Name of entity that will stop. Platform dependent. + selector: + entity: + integration: ffmpeg + domain: binary_sensor diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index ecbf6f3b1aecb..bacc692c13d8d 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -3,16 +3,16 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.components.ffmpeg import ( CONF_EXTRA_ARGUMENTS, CONF_INITIAL_STATE, CONF_INPUT, - DATA_FFMPEG, FFmpegBase, + get_ffmpeg_manager, ) from homeassistant.const import CONF_NAME, CONF_REPEAT from homeassistant.core import callback @@ -49,7 +49,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the FFmpeg binary motion sensor.""" - manager = hass.data[DATA_FFMPEG] + manager = get_ffmpeg_manager(hass) entity = FFmpegMotion(hass, manager, config) async_add_entities([entity]) @@ -115,4 +115,4 @@ async def _async_start_ffmpeg(self, entity_ids): @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return DEVICE_CLASS_MOTION + return BinarySensorDeviceClass.MOTION diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index 6c84c5973f1d5..cd2b6457988c0 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -2,13 +2,16 @@ import haffmpeg.sensor as ffmpeg_sensor import voluptuous as vol -from homeassistant.components.binary_sensor import DEVICE_CLASS_SOUND, PLATFORM_SCHEMA +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, + BinarySensorDeviceClass, +) from homeassistant.components.ffmpeg import ( CONF_EXTRA_ARGUMENTS, CONF_INITIAL_STATE, CONF_INPUT, CONF_OUTPUT, - DATA_FFMPEG, + get_ffmpeg_manager, ) from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor from homeassistant.const import CONF_NAME @@ -41,7 +44,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the FFmpeg noise binary sensor.""" - manager = hass.data[DATA_FFMPEG] + manager = get_ffmpeg_manager(hass) entity = FFmpegNoise(hass, manager, config) async_add_entities([entity]) @@ -78,4 +81,4 @@ async def _async_start_ffmpeg(self, entity_ids): @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return DEVICE_CLASS_SOUND + return BinarySensorDeviceClass.SOUND diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 964c111284053..cff4e153d9869 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -307,8 +307,7 @@ def _read_devices(self): device.device_config = self._device_config.get(device.ha_id, {}) else: device.mapped_type = None - dtype = device.mapped_type - if dtype is None: + if (dtype := device.mapped_type) is None: continue device.unique_id_str = f"{self.hub_serial}.{device.id}" self._device_map[device.id] = device @@ -472,12 +471,11 @@ def action(self, cmd, *args): @property def current_power_w(self): """Return the current power usage in W.""" - if "power" in self.fibaro_device.properties: - power = self.fibaro_device.properties.power - if power: - return convert(power, float, 0.0) - else: - return None + if "power" in self.fibaro_device.properties and ( + power := self.fibaro_device.properties.power + ): + return convert(power, float, 0.0) + return None @property def current_binary_state(self): diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 96c688bac1606..d6382e6f43366 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,10 +1,7 @@ """Support for Fibaro binary sensors.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_DOOR, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_WINDOW, DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON @@ -13,11 +10,15 @@ SENSOR_TYPES = { "com.fibaro.floodSensor": ["Flood", "mdi:water", "flood"], - "com.fibaro.motionSensor": ["Motion", "mdi:run", DEVICE_CLASS_MOTION], - "com.fibaro.doorSensor": ["Door", "mdi:window-open", DEVICE_CLASS_DOOR], - "com.fibaro.windowSensor": ["Window", "mdi:window-open", DEVICE_CLASS_WINDOW], - "com.fibaro.smokeSensor": ["Smoke", "mdi:smoking", DEVICE_CLASS_SMOKE], - "com.fibaro.FGMS001": ["Motion", "mdi:run", DEVICE_CLASS_MOTION], + "com.fibaro.motionSensor": ["Motion", "mdi:run", BinarySensorDeviceClass.MOTION], + "com.fibaro.doorSensor": ["Door", "mdi:window-open", BinarySensorDeviceClass.DOOR], + "com.fibaro.windowSensor": [ + "Window", + "mdi:window-open", + BinarySensorDeviceClass.WINDOW, + ], + "com.fibaro.smokeSensor": ["Smoke", "mdi:smoking", BinarySensorDeviceClass.SMOKE], + "com.fibaro.FGMS001": ["Motion", "mdi:run", BinarySensorDeviceClass.MOTION], "com.fibaro.heatDetector": ["Heat", "mdi:fire", "heat"], } diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 58fde1e370bd3..e72eb7762d60e 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -136,7 +136,7 @@ def __init__(self, fibaro_device): "value" in device.properties or "heatingThermostatSetpoint" in device.properties ) - and (device.properties.unit == "C" or device.properties.unit == "F") + and device.properties.unit in ("C", "F") ): self._temp_sensor_device = FibaroDevice(device) tempunit = device.properties.unit diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index aed1da543eee0..fa9248dc11e3f 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -60,10 +60,25 @@ def __init__(self, fibaro_device): devconf = fibaro_device.device_config self._reset_color = devconf.get(CONF_RESET_COLOR, False) supports_color = ( - "color" in fibaro_device.properties and "setColor" in fibaro_device.actions + "color" in fibaro_device.properties + or "colorComponents" in fibaro_device.properties + or "RGB" in fibaro_device.type + or "rgb" in fibaro_device.type + or "color" in fibaro_device.baseType + ) and ( + "setColor" in fibaro_device.actions + or "setColorComponents" in fibaro_device.actions + ) + supports_white_v = ( + "setW" in fibaro_device.actions + or "RGBW" in fibaro_device.type + or "rgbw" in fibaro_device.type + ) + supports_dimming = ( + "levelChange" in fibaro_device.interfaces + or supports_color + or supports_white_v ) - supports_dimming = "levelChange" in fibaro_device.interfaces - supports_white_v = "setW" in fibaro_device.actions # Configuration can override default capability detection if devconf.get(CONF_DIMMING, supports_dimming): diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 3161e173b2a5a..b96d0b23eccbf 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -1,13 +1,9 @@ """Support for Fibaro sensors.""" from contextlib import suppress -from homeassistant.components.sensor import DOMAIN, SensorEntity +from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorEntity from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, TEMP_CELSIUS, @@ -21,7 +17,7 @@ "Temperature", None, None, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, ], "com.fibaro.smokeSensor": [ "Smoke", @@ -34,15 +30,15 @@ CONCENTRATION_PARTS_PER_MILLION, None, None, - DEVICE_CLASS_CO2, + SensorDeviceClass.CO2, ], "com.fibaro.humiditySensor": [ "Humidity", PERCENTAGE, None, - DEVICE_CLASS_HUMIDITY, + SensorDeviceClass.HUMIDITY, ], - "com.fibaro.lightSensor": ["Light", LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], + "com.fibaro.lightSensor": ["Light", LIGHT_LUX, None, SensorDeviceClass.ILLUMINANCE], } @@ -85,12 +81,12 @@ def __init__(self, fibaro_device): self._unit = self.fibaro_device.properties.unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 55ec455d8f15b..0a06c7a2b07d2 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -4,6 +4,8 @@ Get data from 'Usage Summary' page: https://www.fido.ca/pages/#/my-account/wireless """ +from __future__ import annotations + from datetime import timedelta import logging @@ -11,7 +13,11 @@ from pyfido.client import PyFidoError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -33,33 +39,135 @@ REQUESTS_TIMEOUT = 15 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -SENSOR_TYPES = { - "fido_dollar": ["Fido dollar", PRICE, "mdi:cash-usd"], - "balance": ["Balance", PRICE, "mdi:cash-usd"], - "data_used": ["Data used", DATA_KILOBITS, "mdi:download"], - "data_limit": ["Data limit", DATA_KILOBITS, "mdi:download"], - "data_remaining": ["Data remaining", DATA_KILOBITS, "mdi:download"], - "text_used": ["Text used", MESSAGES, "mdi:message-text"], - "text_limit": ["Text limit", MESSAGES, "mdi:message-text"], - "text_remaining": ["Text remaining", MESSAGES, "mdi:message-text"], - "mms_used": ["MMS used", MESSAGES, "mdi:message-image"], - "mms_limit": ["MMS limit", MESSAGES, "mdi:message-image"], - "mms_remaining": ["MMS remaining", MESSAGES, "mdi:message-image"], - "text_int_used": ["International text used", MESSAGES, "mdi:message-alert"], - "text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"], - "text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"], - "talk_used": ["Talk used", TIME_MINUTES, "mdi:cellphone"], - "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"], - "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"], - "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"], - "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"], - "other_talk_remaining": ["Other Talk remaining", TIME_MINUTES, "mdi:cellphone"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="fido_dollar", + name="Fido dollar", + native_unit_of_measurement=PRICE, + icon="mdi:cash", + ), + SensorEntityDescription( + key="balance", + name="Balance", + native_unit_of_measurement=PRICE, + icon="mdi:cash", + ), + SensorEntityDescription( + key="data_used", + name="Data used", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="data_limit", + name="Data limit", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="data_remaining", + name="Data remaining", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="text_used", + name="Text used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="text_limit", + name="Text limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="text_remaining", + name="Text remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="mms_used", + name="MMS used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="mms_limit", + name="MMS limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="mms_remaining", + name="MMS remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="text_int_used", + name="International text used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="text_int_limit", + name="International text limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="text_int_remaining", + name="International remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="talk_used", + name="Talk used", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="talk_limit", + name="Talk limit", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="talk_remaining", + name="Talk remaining", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_used", + name="Other Talk used", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_limit", + name="Other Talk limit", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_remaining", + name="Other Talk remaining", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -70,8 +178,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Fido sensor.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] httpsession = hass.helpers.aiohttp_client.async_get_clientsession() fido_data = FidoData(username, password, httpsession) @@ -79,49 +187,28 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if ret is False: return - name = config.get(CONF_NAME) + name = config[CONF_NAME] + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + FidoSensor(fido_data, name, number, description) + for number in fido_data.client.get_phone_numbers() + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - sensors = [] - for number in fido_data.client.get_phone_numbers(): - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(FidoSensor(fido_data, variable, name, number)) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class FidoSensor(SensorEntity): """Implementation of a Fido sensor.""" - def __init__(self, fido_data, sensor_type, name, number): + def __init__(self, fido_data, name, number, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self._number = number - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description self.fido_data = fido_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._number} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._number = number - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self._attr_name = f"{name} {number} {description.name}" @property def extra_state_attributes(self): @@ -131,13 +218,14 @@ def extra_state_attributes(self): async def async_update(self): """Get the latest data from Fido and update the state.""" await self.fido_data.async_update() - if self.type == "balance": - if self.fido_data.data.get(self.type) is not None: - self._state = round(self.fido_data.data[self.type], 2) + if (sensor_type := self.entity_description.key) == "balance": + if self.fido_data.data.get(sensor_type) is not None: + self._attr_native_value = round(self.fido_data.data[sensor_type], 2) else: - if self.fido_data.data.get(self._number, {}).get(self.type) is not None: - self._state = self.fido_data.data[self._number][self.type] - self._state = round(self._state, 2) + if self.fido_data.data.get(self._number, {}).get(sensor_type) is not None: + self._attr_native_value = round( + self.fido_data.data[self._number][sensor_type], 2 + ) class FidoData: diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 35c8ebf7df6d8..adfe15b7a3ce3 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -41,7 +41,7 @@ def __init__(self, hass, filename, add_timestamp): def send_message(self, message="", **kwargs): """Send a message to a file.""" - with open(self.filepath, "a") as file: + with open(self.filepath, "a", encoding="utf8") as file: if os.stat(self.filepath).st_size == 0: title = f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" file.write(title) diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index 5d8a9475235ca..6a18fa4cc8b52 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -34,9 +34,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= file_path = config.get(CONF_FILE_PATH) name = config.get(CONF_NAME) unit = config.get(CONF_UNIT_OF_MEASUREMENT) - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: + if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: value_template.hass = hass if hass.config.is_allowed_path(file_path): @@ -62,7 +61,7 @@ def name(self): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -72,7 +71,7 @@ def icon(self): return ICON @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 856b29364aeaf..dc44d3d8255f7 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -63,7 +63,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the size of the file in MB.""" decimals = 2 state_mb = round(self._size / 1e6, decimals) @@ -84,6 +84,6 @@ def extra_state_attributes(self): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/filesize/services.yaml b/homeassistant/components/filesize/services.yaml index 9f251b50e7c1b..a794303f8f160 100644 --- a/homeassistant/components/filesize/services.yaml +++ b/homeassistant/components/filesize/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all filesize entities. diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index e303dc1cf962c..665ef6b6ecdee 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import history from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, @@ -191,6 +192,7 @@ def __init__(self, name, entity_id, filters): self._filters = filters self._icon = None self._device_class = None + self._attr_state_class = None @callback def _update_filter_sensor_state_event(self, event): @@ -209,7 +211,7 @@ def _update_filter_sensor_state(self, new_state, update_ha=True): self.async_write_ha_state() return - if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._state = new_state.state self.async_write_ha_state() return @@ -248,6 +250,9 @@ def _update_filter_sensor_state(self, new_state, update_ha=True): ): self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + if self._attr_state_class is None: + self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) + if self._unit_of_measurement is None: self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT @@ -332,7 +337,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -342,7 +347,7 @@ def icon(self): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return self._unit_of_measurement @@ -405,7 +410,7 @@ def __init__( :param entity: used for debugging only """ if isinstance(window_size, int): - self.states = deque(maxlen=window_size) + self.states: deque = deque(maxlen=window_size) self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS else: self.states = deque(maxlen=0) @@ -476,7 +481,7 @@ def __init__( super().__init__(FILTER_NAME_RANGE, precision=precision, entity=entity) self._lower_bound = lower_bound self._upper_bound = upper_bound - self._stats_internal = Counter() + self._stats_internal: Counter = Counter() def _filter_state(self, new_state): """Implement the range filter.""" @@ -522,7 +527,7 @@ def __init__(self, window_size, precision, entity, radius: float): """ super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) self._radius = radius - self._stats_internal = Counter() + self._stats_internal: Counter = Counter() self._store_raw = True def _filter_state(self, new_state): diff --git a/homeassistant/components/filter/services.yaml b/homeassistant/components/filter/services.yaml index 7d64b34a4f760..431c73616ceff 100644 --- a/homeassistant/components/filter/services.yaml +++ b/homeassistant/components/filter/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all filter entities diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index e7faff46155c2..a7bec82cee009 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -1,8 +1,10 @@ """Read the balance of your bank accounts via FinTS.""" +from __future__ import annotations from collections import namedtuple from datetime import timedelta import logging +from typing import Any from fints.client import FinTS3PinTanClient from fints.dialog import FinTSDialogError @@ -77,8 +79,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.info("Skipping account %s for bank %s", account.iban, fints_name) continue - account_name = account_config.get(account.iban) - if not account_name: + if not (account_name := account_config.get(account.iban)): account_name = f"{fints_name} - {account.iban}" accounts.append(FinTsAccount(client, account, account_name)) _LOGGER.debug("Creating account %s for bank %s", account.iban, fints_name) @@ -107,7 +108,7 @@ class FinTsClient: Use this class as Context Manager to get the FinTS3Client object. """ - def __init__(self, credentials: BankCredentials, name: str): + def __init__(self, credentials: BankCredentials, name: str) -> None: """Initialize a FinTsClient.""" self._credentials = credentials self.name = name @@ -164,46 +165,23 @@ def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs balance account.""" self._client = client self._account = account - self._name = name - self._balance: float = None - self._currency: str = None + self._attr_name = name + self._attr_icon = ICON + self._attr_extra_state_attributes = { + ATTR_ACCOUNT: self._account.iban, + ATTR_ACCOUNT_TYPE: "balance", + } + if self._client.name: + self._attr_extra_state_attributes[ATTR_BANK] = self._client.name def update(self) -> None: """Get the current balance and currency for the account.""" bank = self._client.client balance = bank.get_balance(self._account) - self._balance = balance.amount.amount - self._currency = balance.amount.currency + self._attr_native_value = balance.amount.amount + self._attr_native_unit_of_measurement = balance.amount.currency _LOGGER.debug("updated balance of account %s", self.name) - @property - def name(self) -> str: - """Friendly name of the sensor.""" - return self._name - - @property - def state(self) -> float: - """Return the balance of the account as state.""" - return self._balance - - @property - def unit_of_measurement(self) -> str: - """Use the currency as unit of measurement.""" - return self._currency - - @property - def extra_state_attributes(self) -> dict: - """Additional attributes of the sensor.""" - attributes = {ATTR_ACCOUNT: self._account.iban, ATTR_ACCOUNT_TYPE: "balance"} - if self._client.name: - attributes[ATTR_BANK] = self._client.name - return attributes - - @property - def icon(self) -> str: - """Set the icon for the sensor.""" - return ICON - class FinTsHoldingsAccount(SensorEntity): """Sensor for a FinTS holdings account. @@ -215,26 +193,17 @@ class FinTsHoldingsAccount(SensorEntity): def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs holdings account.""" self._client = client - self._name = name + self._attr_name = name self._account = account - self._holdings = [] - self._total: float = None + self._holdings: list[Any] = [] + self._attr_icon = ICON + self._attr_native_unit_of_measurement = "EUR" def update(self) -> None: """Get the current holdings for the account.""" bank = self._client.client self._holdings = bank.get_holdings(self._account) - self._total = sum(h.total_value for h in self._holdings) - - @property - def state(self) -> float: - """Return total market value as state.""" - return self._total - - @property - def icon(self) -> str: - """Set the icon for the sensor.""" - return ICON + self._attr_native_value = sum(h.total_value for h in self._holdings) @property def extra_state_attributes(self) -> dict: @@ -257,18 +226,3 @@ def extra_state_attributes(self) -> dict: attributes[price_name] = holding.market_value return attributes - - @property - def name(self) -> str: - """Friendly name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self) -> str: - """Get the unit of measurement. - - Hardcoded to EUR, as the library does not provide the currency for the - holdings. And as FinTS is only used in Germany, most accounts will be - in EUR anyways. - """ - return "EUR" diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index aa10a16f0886f..4cdb7ec0c42fd 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -10,11 +10,8 @@ InvalidTokenError, ) -from homeassistant.components.binary_sensor import DOMAIN as BINARYSENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.dispatcher import dispatcher_send @@ -26,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [SENSOR_DOMAIN, BINARYSENSOR_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index 0e2259b6b5e7e..1eea9fbfbf188 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -3,7 +3,7 @@ "name": "FireServiceRota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fireservicerota", - "requirements": ["pyfireservicerota==0.0.40"], + "requirements": ["pyfireservicerota==0.0.43"], "codeowners": ["@cyberjunky"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 58b3239331ce5..f2e415daec978 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -49,7 +49,7 @@ def icon(self) -> str: return "mdi:fire-truck" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self._state @@ -67,9 +67,8 @@ def should_poll(self) -> bool: def extra_state_attributes(self) -> object: """Return available attributes for sensor.""" attr = {} - data = self._state_attributes - if not data: + if not (data := self._state_attributes): return attr for value in ( @@ -103,8 +102,7 @@ async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._state = state.state self._state_attributes = state.attributes if "id" in self._state_attributes: diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index f54e3bc1fa25e..454048a3737e8 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -98,7 +98,7 @@ def extra_state_attributes(self) -> object: return attr async def async_turn_on(self, **kwargs) -> None: - """Send Acknowlegde response status.""" + """Send Acknowledge response status.""" await self.async_set_response(True) async def async_turn_off(self, **kwargs) -> None: diff --git a/homeassistant/components/fireservicerota/translations/bg.json b/homeassistant/components/fireservicerota/translations/bg.json new file mode 100644 index 0000000000000..22cc783d4e91c --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/bg.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "create_entry": { + "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u0435\u043d" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "\u0423\u0435\u0431\u0441\u0430\u0439\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/ca.json b/homeassistant/components/fireservicerota/translations/ca.json index 287bb81e51ead..261350db3f8f9 100644 --- a/homeassistant/components/fireservicerota/translations/ca.json +++ b/homeassistant/components/fireservicerota/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "create_entry": { diff --git a/homeassistant/components/fireservicerota/translations/de.json b/homeassistant/components/fireservicerota/translations/de.json index 737fbc5ff5378..c8c18c4c3723f 100644 --- a/homeassistant/components/fireservicerota/translations/de.json +++ b/homeassistant/components/fireservicerota/translations/de.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Account wurde schon konfiguriert", - "reauth_successful": "Neuauthentifizierung erfolgreich" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "create_entry": { - "default": "Authentifizierung erfolgreich" + "default": "Erfolgreich authentifiziert" }, "error": { - "invalid_auth": "Authentifizienung ung\u00fcltig" + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "step": { "reauth": { @@ -21,7 +21,7 @@ "data": { "password": "Passwort", "url": "Webseite", - "username": "Nutzername" + "username": "Benutzername" } } } diff --git a/homeassistant/components/fireservicerota/translations/es-419.json b/homeassistant/components/fireservicerota/translations/es-419.json new file mode 100644 index 0000000000000..cf14204ec0cf5 --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "reauth": { + "description": "Los tokens de autenticaci\u00f3n dejaron de ser v\u00e1lidos, inicie sesi\u00f3n para volver a crearlos." + }, + "user": { + "data": { + "url": "Sitio web" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/fr.json b/homeassistant/components/fireservicerota/translations/fr.json index fdbf28e32e1f1..477e8df621c96 100644 --- a/homeassistant/components/fireservicerota/translations/fr.json +++ b/homeassistant/components/fireservicerota/translations/fr.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Le compte \u00e0 d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { - "default": "Autentification r\u00e9ussie" + "default": "Authentification r\u00e9ussie" }, "error": { - "invalid_auth": "Autentification invalide" + "invalid_auth": "Authentification invalide" }, "step": { "reauth": { @@ -21,7 +21,7 @@ "data": { "password": "Mot de passe", "url": "Site web", - "username": "Utilisateur" + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/fireservicerota/translations/he.json b/homeassistant/components/fireservicerota/translations/he.json new file mode 100644 index 0000000000000..61dee20d1ce48 --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "create_entry": { + "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "reauth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + }, + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05d0\u05ea\u05e8 \u05d0\u05d9\u05e0\u05d8\u05e8\u05e0\u05d8", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/hu.json b/homeassistant/components/fireservicerota/translations/hu.json index 8e8432d5df4a2..3bda2225400e6 100644 --- a/homeassistant/components/fireservicerota/translations/hu.json +++ b/homeassistant/components/fireservicerota/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" @@ -14,7 +14,8 @@ "reauth": { "data": { "password": "Jelsz\u00f3" - } + }, + "description": "A hiteles\u00edt\u00e9si tokenek \u00e9rv\u00e9nytelenn\u00e9 v\u00e1ltak, a l\u00e9trehoz\u00e1shoz jelentkezzen be." }, "user": { "data": { diff --git a/homeassistant/components/fireservicerota/translations/it.json b/homeassistant/components/fireservicerota/translations/it.json index 6960b68b2a20e..8a437e45900da 100644 --- a/homeassistant/components/fireservicerota/translations/it.json +++ b/homeassistant/components/fireservicerota/translations/it.json @@ -15,7 +15,7 @@ "data": { "password": "Password" }, - "description": "I token di autenticazione non sono validi, effettua il login per ricrearli." + "description": "I token di autenticazione non sono validi, esegui l'accesso per ricrearli." }, "user": { "data": { diff --git a/homeassistant/components/fireservicerota/translations/ja.json b/homeassistant/components/fireservicerota/translations/ja.json new file mode 100644 index 0000000000000..00358bb1f36ee --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u304c\u7121\u52b9\u306b\u306a\u3063\u305f\u306e\u3067\u3001\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u518d\u4f5c\u6210\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "Web\u30b5\u30a4\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/tr.json b/homeassistant/components/fireservicerota/translations/tr.json index f54d10f6cbf71..2b9c1b9cb0ac7 100644 --- a/homeassistant/components/fireservicerota/translations/tr.json +++ b/homeassistant/components/fireservicerota/translations/tr.json @@ -13,15 +13,15 @@ "step": { "reauth": { "data": { - "password": "\u015eifre" + "password": "Parola" }, "description": "Kimlik do\u011frulama jetonlar\u0131 ge\u00e7ersiz, yeniden olu\u015fturmak i\u00e7in oturum a\u00e7\u0131n." }, "user": { "data": { - "password": "\u015eifre", + "password": "Parola", "url": "Web sitesi", - "username": "Kullan\u0131c\u0131 ad\u0131" + "username": "Kullan\u0131c\u0131 Ad\u0131" } } } diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index 24b6420e8a524..d147d84b341c3 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -19,6 +19,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .board import FirmataBoard from .const import ( @@ -122,7 +123,7 @@ ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Firmata domain.""" # Delete specific entries that no longer exist in the config if hass.config_entries.async_entries(DOMAIN): @@ -188,7 +189,7 @@ async def handle_shutdown(event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_shutdown) ) - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={}, diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index 73e3c004cb91e..1f0052732ea68 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -32,7 +32,7 @@ class FirmataBoard: """Manages a single Firmata board.""" - def __init__(self, config: dict): + def __init__(self, config: dict) -> None: """Initialize the board.""" self.config = config self.api = None diff --git a/homeassistant/components/firmata/const.py b/homeassistant/components/firmata/const.py index 0d859363e2b48..091d724229c74 100644 --- a/homeassistant/components/firmata/const.py +++ b/homeassistant/components/firmata/const.py @@ -4,6 +4,7 @@ CONF_LIGHTS, CONF_SENSORS, CONF_SWITCHES, + Platform, ) CONF_ARDUINO_INSTANCE_ID = "arduino_instance_id" @@ -27,8 +28,8 @@ DOMAIN = "firmata" FIRMATA_MANUFACTURER = "Firmata" CONF_PLATFORM_MAP = { - CONF_BINARY_SENSORS: "binary_sensor", - CONF_LIGHTS: "light", - CONF_SENSORS: "sensor", - CONF_SWITCHES: "switch", + CONF_BINARY_SENSORS: Platform.BINARY_SENSOR, + CONF_LIGHTS: Platform.LIGHT, + CONF_SENSORS: Platform.SENSOR, + CONF_SWITCHES: Platform.SWITCH, } diff --git a/homeassistant/components/firmata/entity.py b/homeassistant/components/firmata/entity.py index 7a576c09cd1d7..0f248e0b9d77b 100644 --- a/homeassistant/components/firmata/entity.py +++ b/homeassistant/components/firmata/entity.py @@ -19,13 +19,13 @@ def __init__(self, api): @property def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "connections": {}, - "identifiers": {(DOMAIN, self._api.board.name)}, - "manufacturer": FIRMATA_MANUFACTURER, - "name": self._api.board.name, - "sw_version": self._api.board.firmware_version, - } + return DeviceInfo( + connections={}, + identifiers={(DOMAIN, self._api.board.name)}, + manufacturer=FIRMATA_MANUFACTURER, + name=self._api.board.name, + sw_version=self._api.board.firmware_version, + ) class FirmataPinEntity(FirmataEntity): @@ -37,7 +37,7 @@ def __init__( config_entry: ConfigEntry, name: str, pin: FirmataPinType, - ): + ) -> None: """Initialize the pin entity.""" super().__init__(api) self._name = name diff --git a/homeassistant/components/firmata/light.py b/homeassistant/components/firmata/light.py index 3c50a559e51b8..97901ea2f2b06 100644 --- a/homeassistant/components/firmata/light.py +++ b/homeassistant/components/firmata/light.py @@ -59,7 +59,7 @@ def __init__( config_entry: ConfigEntry, name: str, pin: FirmataPinType, - ): + ) -> None: """Initialize the light pin entity.""" super().__init__(api, config_entry, name, pin) diff --git a/homeassistant/components/firmata/pin.py b/homeassistant/components/firmata/pin.py index 3259d76cbb3ff..6dadb07fd6313 100644 --- a/homeassistant/components/firmata/pin.py +++ b/homeassistant/components/firmata/pin.py @@ -1,6 +1,8 @@ """Code to handle pins on a Firmata board.""" +from __future__ import annotations + +from collections.abc import Callable import logging -from typing import Callable from .board import FirmataBoard, FirmataPinType from .const import PIN_MODE_INPUT, PIN_MODE_PULLUP, PIN_TYPE_ANALOG @@ -15,7 +17,7 @@ class FirmataPinUsedException(Exception): class FirmataBoardPin: """Manages a single Firmata board pin.""" - def __init__(self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str): + def __init__(self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str) -> None: """Initialize the pin.""" self.board = board self._pin = pin @@ -43,7 +45,7 @@ def __init__( pin_mode: str, initial: bool, negate: bool, - ): + ) -> None: """Initialize the digital output pin.""" self._initial = initial self._negate = negate @@ -98,7 +100,7 @@ def __init__( initial: bool, minimum: int, maximum: int, - ): + ) -> None: """Initialize the PWM/analog output pin.""" self._initial = initial self._min = minimum @@ -139,7 +141,7 @@ class FirmataBinaryDigitalInput(FirmataBoardPin): def __init__( self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str, negate: bool - ): + ) -> None: """Initialize the digital input pin.""" self._negate = negate self._forward_callback = None @@ -206,7 +208,7 @@ class FirmataAnalogInput(FirmataBoardPin): def __init__( self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str, differential: int - ): + ) -> None: """Initialize the analog input pin.""" self._differential = differential self._forward_callback = None diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py index fedac6f76d907..b46e96f3c2557 100644 --- a/homeassistant/components/firmata/sensor.py +++ b/homeassistant/components/firmata/sensor.py @@ -54,6 +54,6 @@ async def async_will_remove_from_hass(self) -> None: await self._api.stop_pin() @property - def state(self) -> int: + def native_value(self) -> int: """Return sensor state.""" return self._api.state diff --git a/homeassistant/components/firmata/translations/bg.json b/homeassistant/components/firmata/translations/bg.json new file mode 100644 index 0000000000000..c30e629d8addc --- /dev/null +++ b/homeassistant/components/firmata/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/fr.json b/homeassistant/components/firmata/translations/fr.json index b3509c9126c1d..ea09d58c35474 100644 --- a/homeassistant/components/firmata/translations/fr.json +++ b/homeassistant/components/firmata/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "Impossible de se connecter \u00e0 la carte Firmata pendant la configuration" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "one": "Vide ", diff --git a/homeassistant/components/firmata/translations/he.json b/homeassistant/components/firmata/translations/he.json new file mode 100644 index 0000000000000..0a2ba64dbef88 --- /dev/null +++ b/homeassistant/components/firmata/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/hu.json b/homeassistant/components/firmata/translations/hu.json index 563ede561557c..8224d177a9f13 100644 --- a/homeassistant/components/firmata/translations/hu.json +++ b/homeassistant/components/firmata/translations/hu.json @@ -2,6 +2,10 @@ "config": { "abort": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "one": "\u00dcres", + "other": "\u00dcres" } } } \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/it.json b/homeassistant/components/firmata/translations/it.json index a4f6f9e722205..b7eb09c2cb8e5 100644 --- a/homeassistant/components/firmata/translations/it.json +++ b/homeassistant/components/firmata/translations/it.json @@ -2,6 +2,10 @@ "config": { "abort": { "cannot_connect": "Impossibile connettersi" + }, + "step": { + "one": "Pi\u00f9", + "other": "Altri" } } } \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/ja.json b/homeassistant/components/firmata/translations/ja.json new file mode 100644 index 0000000000000..c025353783639 --- /dev/null +++ b/homeassistant/components/firmata/translations/ja.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py new file mode 100644 index 0000000000000..1da3058c79066 --- /dev/null +++ b/homeassistant/components/fitbit/const.py @@ -0,0 +1,314 @@ +"""Constants for the Fitbit platform.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + LENGTH_FEET, + MASS_KILOGRAMS, + MASS_MILLIGRAMS, + PERCENTAGE, + TIME_MILLISECONDS, + TIME_MINUTES, +) + +ATTR_ACCESS_TOKEN: Final = "access_token" +ATTR_REFRESH_TOKEN: Final = "refresh_token" +ATTR_LAST_SAVED_AT: Final = "last_saved_at" + +ATTR_DURATION: Final = "duration" +ATTR_DISTANCE: Final = "distance" +ATTR_ELEVATION: Final = "elevation" +ATTR_HEIGHT: Final = "height" +ATTR_WEIGHT: Final = "weight" +ATTR_BODY: Final = "body" +ATTR_LIQUIDS: Final = "liquids" +ATTR_BLOOD_GLUCOSE: Final = "blood glucose" +ATTR_BATTERY: Final = "battery" + +CONF_MONITORED_RESOURCES: Final = "monitored_resources" +CONF_CLOCK_FORMAT: Final = "clock_format" +ATTRIBUTION: Final = "Data provided by Fitbit.com" + +FITBIT_AUTH_CALLBACK_PATH: Final = "/api/fitbit/callback" +FITBIT_AUTH_START: Final = "/api/fitbit" +FITBIT_CONFIG_FILE: Final = "fitbit.conf" +FITBIT_DEFAULT_RESOURCES: Final[list[str]] = ["activities/steps"] + +DEFAULT_CONFIG: Final[dict[str, str]] = { + CONF_CLIENT_ID: "CLIENT_ID_HERE", + CONF_CLIENT_SECRET: "CLIENT_SECRET_HERE", +} +DEFAULT_CLOCK_FORMAT: Final = "24H" + + +@dataclass +class FitbitRequiredKeysMixin: + """Mixin for required keys.""" + + unit_type: str | None + + +@dataclass +class FitbitSensorEntityDescription(SensorEntityDescription, FitbitRequiredKeysMixin): + """Describes Fitbit sensor entity.""" + + +FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( + FitbitSensorEntityDescription( + key="activities/activityCalories", + name="Activity Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/calories", + name="Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/caloriesBMR", + name="Calories BMR", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/distance", + name="Distance", + unit_type="", + icon="mdi:map-marker", + ), + FitbitSensorEntityDescription( + key="activities/elevation", + name="Elevation", + unit_type="", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/floors", + name="Floors", + unit_type="floors", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/heart", + name="Resting Heart Rate", + unit_type="bpm", + icon="mdi:heart-pulse", + ), + FitbitSensorEntityDescription( + key="activities/minutesFairlyActive", + name="Minutes Fairly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/minutesLightlyActive", + name="Minutes Lightly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/minutesSedentary", + name="Minutes Sedentary", + unit_type=TIME_MINUTES, + icon="mdi:seat-recline-normal", + ), + FitbitSensorEntityDescription( + key="activities/minutesVeryActive", + name="Minutes Very Active", + unit_type=TIME_MINUTES, + icon="mdi:run", + ), + FitbitSensorEntityDescription( + key="activities/steps", + name="Steps", + unit_type="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/activityCalories", + name="Tracker Activity Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/calories", + name="Tracker Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/distance", + name="Tracker Distance", + unit_type="", + icon="mdi:map-marker", + ), + FitbitSensorEntityDescription( + key="activities/tracker/elevation", + name="Tracker Elevation", + unit_type="", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/floors", + name="Tracker Floors", + unit_type="floors", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesFairlyActive", + name="Tracker Minutes Fairly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesLightlyActive", + name="Tracker Minutes Lightly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesSedentary", + name="Tracker Minutes Sedentary", + unit_type=TIME_MINUTES, + icon="mdi:seat-recline-normal", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesVeryActive", + name="Tracker Minutes Very Active", + unit_type=TIME_MINUTES, + icon="mdi:run", + ), + FitbitSensorEntityDescription( + key="activities/tracker/steps", + name="Tracker Steps", + unit_type="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="body/bmi", + name="BMI", + unit_type="BMI", + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="body/fat", + name="Body Fat", + unit_type=PERCENTAGE, + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="body/weight", + name="Weight", + unit_type="", + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="sleep/awakeningsCount", + name="Awakenings Count", + unit_type="times awaken", + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/efficiency", + name="Sleep Efficiency", + unit_type=PERCENTAGE, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAfterWakeup", + name="Minutes After Wakeup", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAsleep", + name="Sleep Minutes Asleep", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAwake", + name="Sleep Minutes Awake", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesToFallAsleep", + name="Sleep Minutes to Fall Asleep", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + unit_type=None, + icon="mdi:clock", + ), + FitbitSensorEntityDescription( + key="sleep/timeInBed", + name="Sleep Time in Bed", + unit_type=TIME_MINUTES, + icon="mdi:hotel", + ), +) + +FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( + key="devices/battery", + name="Battery", + unit_type=None, + icon="mdi:battery", +) + +FITBIT_RESOURCES_KEYS: Final[list[str]] = [ + desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) +] + +FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { + "en_US": { + ATTR_DURATION: TIME_MILLISECONDS, + ATTR_DISTANCE: "mi", + ATTR_ELEVATION: LENGTH_FEET, + ATTR_HEIGHT: "in", + ATTR_WEIGHT: "lbs", + ATTR_BODY: "in", + ATTR_LIQUIDS: "fl. oz.", + ATTR_BLOOD_GLUCOSE: f"{MASS_MILLIGRAMS}/dL", + ATTR_BATTERY: "", + }, + "en_GB": { + ATTR_DURATION: TIME_MILLISECONDS, + ATTR_DISTANCE: "kilometers", + ATTR_ELEVATION: "meters", + ATTR_HEIGHT: "centimeters", + ATTR_WEIGHT: "stone", + ATTR_BODY: "centimeters", + ATTR_LIQUIDS: "milliliters", + ATTR_BLOOD_GLUCOSE: "mmol/L", + ATTR_BATTERY: "", + }, + "metric": { + ATTR_DURATION: TIME_MILLISECONDS, + ATTR_DISTANCE: "kilometers", + ATTR_ELEVATION: "meters", + ATTR_HEIGHT: "centimeters", + ATTR_WEIGHT: MASS_KILOGRAMS, + ATTR_BODY: "centimeters", + ATTR_LIQUIDS: "milliliters", + ATTR_BLOOD_GLUCOSE: "mmol/L", + ATTR_BATTERY: "", + }, +} + +BATTERY_LEVELS: Final[dict[str, int]] = { + "High": 100, + "Medium": 50, + "Low": 20, + "Empty": 0, +} diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 263ae24ff34bc..0c638f0c45573 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,162 +1,73 @@ """Support for the Fitbit API.""" + +from __future__ import annotations + import datetime import logging import os import time +from typing import Any, Final, cast +from aiohttp.web import Request from fitbit import Fitbit from fitbit.api import FitbitOauth2Client from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + SensorEntity, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM, - LENGTH_FEET, - MASS_KILOGRAMS, - MASS_MILLIGRAMS, - PERCENTAGE, - TIME_MILLISECONDS, - TIME_MINUTES, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.network import get_url +from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json, save_json -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - -ATTR_ACCESS_TOKEN = "access_token" -ATTR_REFRESH_TOKEN = "refresh_token" -ATTR_LAST_SAVED_AT = "last_saved_at" - -CONF_MONITORED_RESOURCES = "monitored_resources" -CONF_CLOCK_FORMAT = "clock_format" -ATTRIBUTION = "Data provided by Fitbit.com" - -FITBIT_AUTH_CALLBACK_PATH = "/api/fitbit/callback" -FITBIT_AUTH_START = "/api/fitbit" -FITBIT_CONFIG_FILE = "fitbit.conf" -FITBIT_DEFAULT_RESOURCES = ["activities/steps"] - -SCAN_INTERVAL = datetime.timedelta(minutes=30) - -DEFAULT_CONFIG = { - CONF_CLIENT_ID: "CLIENT_ID_HERE", - CONF_CLIENT_SECRET: "CLIENT_SECRET_HERE", -} - -FITBIT_RESOURCES_LIST = { - "activities/activityCalories": ["Activity Calories", "cal", "fire"], - "activities/calories": ["Calories", "cal", "fire"], - "activities/caloriesBMR": ["Calories BMR", "cal", "fire"], - "activities/distance": ["Distance", "", "map-marker"], - "activities/elevation": ["Elevation", "", "walk"], - "activities/floors": ["Floors", "floors", "walk"], - "activities/heart": ["Resting Heart Rate", "bpm", "heart-pulse"], - "activities/minutesFairlyActive": ["Minutes Fairly Active", TIME_MINUTES, "walk"], - "activities/minutesLightlyActive": ["Minutes Lightly Active", TIME_MINUTES, "walk"], - "activities/minutesSedentary": [ - "Minutes Sedentary", - TIME_MINUTES, - "seat-recline-normal", - ], - "activities/minutesVeryActive": ["Minutes Very Active", TIME_MINUTES, "run"], - "activities/steps": ["Steps", "steps", "walk"], - "activities/tracker/activityCalories": ["Tracker Activity Calories", "cal", "fire"], - "activities/tracker/calories": ["Tracker Calories", "cal", "fire"], - "activities/tracker/distance": ["Tracker Distance", "", "map-marker"], - "activities/tracker/elevation": ["Tracker Elevation", "", "walk"], - "activities/tracker/floors": ["Tracker Floors", "floors", "walk"], - "activities/tracker/minutesFairlyActive": [ - "Tracker Minutes Fairly Active", - TIME_MINUTES, - "walk", - ], - "activities/tracker/minutesLightlyActive": [ - "Tracker Minutes Lightly Active", - TIME_MINUTES, - "walk", - ], - "activities/tracker/minutesSedentary": [ - "Tracker Minutes Sedentary", - TIME_MINUTES, - "seat-recline-normal", - ], - "activities/tracker/minutesVeryActive": [ - "Tracker Minutes Very Active", - TIME_MINUTES, - "run", - ], - "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], - "body/bmi": ["BMI", "BMI", "human"], - "body/fat": ["Body Fat", PERCENTAGE, "human"], - "body/weight": ["Weight", "", "human"], - "devices/battery": ["Battery", None, None], - "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], - "sleep/efficiency": ["Sleep Efficiency", PERCENTAGE, "sleep"], - "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"], - "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"], - "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"], - "sleep/minutesToFallAsleep": [ - "Sleep Minutes to Fall Asleep", - TIME_MINUTES, - "sleep", - ], - "sleep/startTime": ["Sleep Start Time", None, "clock"], - "sleep/timeInBed": ["Sleep Time in Bed", TIME_MINUTES, "hotel"], -} - -FITBIT_MEASUREMENTS = { - "en_US": { - "duration": TIME_MILLISECONDS, - "distance": "mi", - "elevation": LENGTH_FEET, - "height": "in", - "weight": "lbs", - "body": "in", - "liquids": "fl. oz.", - "blood glucose": f"{MASS_MILLIGRAMS}/dL", - "battery": "", - }, - "en_GB": { - "duration": TIME_MILLISECONDS, - "distance": "kilometers", - "elevation": "meters", - "height": "centimeters", - "weight": "stone", - "body": "centimeters", - "liquids": "milliliters", - "blood glucose": "mmol/L", - "battery": "", - }, - "metric": { - "duration": TIME_MILLISECONDS, - "distance": "kilometers", - "elevation": "meters", - "height": "centimeters", - "weight": MASS_KILOGRAMS, - "body": "centimeters", - "liquids": "milliliters", - "blood glucose": "mmol/L", - "battery": "", - }, -} - -BATTERY_LEVELS = {"High": 100, "Medium": 50, "Low": 20, "Empty": 0} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +from .const import ( + ATTR_ACCESS_TOKEN, + ATTR_LAST_SAVED_AT, + ATTR_REFRESH_TOKEN, + ATTRIBUTION, + BATTERY_LEVELS, + CONF_CLOCK_FORMAT, + CONF_MONITORED_RESOURCES, + DEFAULT_CLOCK_FORMAT, + DEFAULT_CONFIG, + FITBIT_AUTH_CALLBACK_PATH, + FITBIT_AUTH_START, + FITBIT_CONFIG_FILE, + FITBIT_DEFAULT_RESOURCES, + FITBIT_MEASUREMENTS, + FITBIT_RESOURCE_BATTERY, + FITBIT_RESOURCES_KEYS, + FITBIT_RESOURCES_LIST, + FitbitSensorEntityDescription, +) + +_LOGGER: Final = logging.getLogger(__name__) + +_CONFIGURING: dict[str, str] = {} + +SCAN_INTERVAL: Final = datetime.timedelta(minutes=30) + +PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional( CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES - ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), - vol.Optional(CONF_CLOCK_FORMAT, default="24H"): vol.In(["12H", "24H"]), + ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]), + vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( + ["12H", "24H"] + ), vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( ["en_GB", "en_US", "metric", "default"] ), @@ -164,11 +75,17 @@ ) -def request_app_setup(hass, config, add_entities, config_path, discovery_info=None): +def request_app_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + config_path: str, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Assist user with configuring the Fitbit dev application.""" configurator = hass.components.configurator - def fitbit_configuration_callback(callback_data): + def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: """Handle configuration updates.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): @@ -184,16 +101,22 @@ def fitbit_configuration_callback(callback_data): else: setup_platform(hass, config, add_entities, discovery_info) - start_url = f"{get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}" - - description = f"""Please create a Fitbit developer app at + try: + description = f"""Please create a Fitbit developer app at https://dev.fitbit.com/apps/new. For the OAuth 2.0 Application Type choose Personal. - Set the Callback URL to {start_url}. + Set the Callback URL to {get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}. + (Note: Your Home Assistant instance must be accessible via HTTPS.) They will provide you a Client ID and secret. These need to be saved into the file located at: {config_path}. Then come back here and hit the below button. """ + except NoURLAvailableError: + _LOGGER.error( + "Could not find an SSL enabled URL for your Home Assistant instance. " + "Fitbit requires that your Home Assistant instance is accessible via HTTPS" + ) + return submit = "I have saved my Client ID and Client Secret into fitbit.conf." @@ -206,7 +129,7 @@ def fitbit_configuration_callback(callback_data): ) -def request_oauth_completion(hass): +def request_oauth_completion(hass: HomeAssistant) -> None: """Request user complete Fitbit OAuth2 flow.""" configurator = hass.components.configurator if "fitbit" in _CONFIGURING: @@ -216,10 +139,10 @@ def request_oauth_completion(hass): return - def fitbit_configuration_callback(callback_data): + def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: """Handle configuration updates.""" - start_url = f"{get_url(hass)}{FITBIT_AUTH_START}" + start_url = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_START}" description = f"Please authorize Fitbit by visiting {start_url}" @@ -231,28 +154,37 @@ def fitbit_configuration_callback(callback_data): ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file = load_json(config_path) + config_file: ConfigType = cast(ConfigType, load_json(config_path)) if config_file == DEFAULT_CONFIG: request_app_setup( hass, config, add_entities, config_path, discovery_info=None ) - return False + return else: save_json(config_path, DEFAULT_CONFIG) request_app_setup(hass, config, add_entities, config_path, discovery_info=None) - return False + return if "fitbit" in _CONFIGURING: hass.components.configurator.request_done(_CONFIGURING.pop("fitbit")) - access_token = config_file.get(ATTR_ACCESS_TOKEN) - refresh_token = config_file.get(ATTR_REFRESH_TOKEN) - expires_at = config_file.get(ATTR_LAST_SAVED_AT) - if None not in (access_token, refresh_token): + access_token: str | None = config_file.get(ATTR_ACCESS_TOKEN) + refresh_token: str | None = config_file.get(ATTR_REFRESH_TOKEN) + expires_at: int | None = config_file.get(ATTR_LAST_SAVED_AT) + if ( + access_token is not None + and refresh_token is not None + and expires_at is not None + ): authd_client = Fitbit( config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET), @@ -265,8 +197,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() - unit_system = config.get(CONF_UNIT_SYSTEM) - if unit_system == "default": + if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": authd_client.system = authd_client.user_profile_get()["user"]["locale"] if authd_client.system != "en_GB": if hass.config.units.is_metric: @@ -276,42 +207,42 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: authd_client.system = unit_system - dev = [] registered_devs = authd_client.get_devices() - clock_format = config.get(CONF_CLOCK_FORMAT) - for resource in config.get(CONF_MONITORED_RESOURCES): - - # monitor battery for all linked FitBit devices - if resource == "devices/battery": - for dev_extra in registered_devs: - dev.append( - FitbitSensor( - authd_client, - config_path, - resource, - hass.config.units.is_metric, - clock_format, - dev_extra, - ) - ) - else: - dev.append( + clock_format = config[CONF_CLOCK_FORMAT] + monitored_resources = config[CONF_MONITORED_RESOURCES] + entities = [ + FitbitSensor( + authd_client, + config_path, + description, + hass.config.units.is_metric, + clock_format, + ) + for description in FITBIT_RESOURCES_LIST + if description.key in monitored_resources + ] + if "devices/battery" in monitored_resources: + entities.extend( + [ FitbitSensor( authd_client, config_path, - resource, + FITBIT_RESOURCE_BATTERY, hass.config.units.is_metric, clock_format, + dev_extra, ) - ) - add_entities(dev, True) + for dev_extra in registered_devs + ] + ) + add_entities(entities, True) else: oauth = FitbitOauth2Client( config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET) ) - redirect_uri = f"{get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}" + redirect_uri = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}" fitbit_auth_start_url, _ = oauth.authorize_token_url( redirect_uri=redirect_uri, @@ -339,16 +270,21 @@ class FitbitAuthCallbackView(HomeAssistantView): url = FITBIT_AUTH_CALLBACK_PATH name = "api:fitbit:callback" - def __init__(self, config, add_entities, oauth): + def __init__( + self, + config: ConfigType, + add_entities: AddEntitiesCallback, + oauth: FitbitOauth2Client, + ) -> None: """Initialize the OAuth callback view.""" self.config = config self.add_entities = add_entities self.oauth = oauth @callback - async def get(self, request): + async def get(self, request: Request) -> str: """Finish OAuth callback request.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] data = request.query response_message = """Fitbit has been successfully authorized! @@ -407,22 +343,28 @@ async def get(self, request): class FitbitSensor(SensorEntity): """Implementation of a Fitbit sensor.""" + entity_description: FitbitSensorEntityDescription + def __init__( - self, client, config_path, resource_type, is_metric, clock_format, extra=None - ): + self, + client: Fitbit, + config_path: str, + description: FitbitSensorEntityDescription, + is_metric: bool, + clock_format: str, + extra: dict[str, str] | None = None, + ) -> None: """Initialize the Fitbit sensor.""" + self.entity_description = description self.client = client self.config_path = config_path - self.resource_type = resource_type self.is_metric = is_metric self.clock_format = clock_format self.extra = extra - self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] - if self.extra: - self._name = f"{self.extra.get('deviceVersion')} Battery" - unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] - if unit_type == "": - split_resource = self.resource_type.split("/") + if self.extra is not None: + self._attr_name = f"{self.extra.get('deviceVersion')} Battery" + if (unit_type := description.unit_type) == "": + split_resource = description.key.rsplit("/", maxsplit=1)[-1] try: measurement_system = FITBIT_MEASUREMENTS[self.client.system] except KeyError: @@ -430,73 +372,61 @@ def __init__( measurement_system = FITBIT_MEASUREMENTS["metric"] else: measurement_system = FITBIT_MEASUREMENTS["en_US"] - unit_type = measurement_system[split_resource[-1]] - self._unit_of_measurement = unit_type - self._state = 0 + unit_type = measurement_system[split_resource] + self._attr_native_unit_of_measurement = unit_type @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if self.resource_type == "devices/battery" and self.extra: - battery_level = BATTERY_LEVELS[self.extra.get("battery")] - return icon_for_battery_level(battery_level=battery_level, charging=None) - return f"mdi:{FITBIT_RESOURCES_LIST[self.resource_type][2]}" + if ( + self.entity_description.key == "devices/battery" + and self.extra is not None + and (extra_battery := self.extra.get("battery")) is not None + and (battery_level := BATTERY_LEVELS.get(extra_battery)) is not None + ): + return icon_for_battery_level(battery_level=battery_level) + return self.entity_description.icon @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs = {} - - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + attrs: dict[str, str | None] = {ATTR_ATTRIBUTION: ATTRIBUTION} - if self.extra: + if self.extra is not None: attrs["model"] = self.extra.get("deviceVersion") - attrs["type"] = self.extra.get("type").lower() + extra_type = self.extra.get("type") + attrs["type"] = extra_type.lower() if extra_type is not None else None return attrs - def update(self): + def update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" - if self.resource_type == "devices/battery" and self.extra: - registered_devs = self.client.get_devices() + resource_type = self.entity_description.key + if resource_type == "devices/battery" and self.extra is not None: + registered_devs: list[dict[str, Any]] = self.client.get_devices() device_id = self.extra.get("id") self.extra = list( filter(lambda device: device.get("id") == device_id, registered_devs) )[0] - self._state = self.extra.get("battery") + self._attr_native_value = self.extra.get("battery") else: - container = self.resource_type.replace("/", "-") - response = self.client.time_series(self.resource_type, period="7d") + container = resource_type.replace("/", "-") + response = self.client.time_series(resource_type, period="7d") raw_state = response[container][-1].get("value") - if self.resource_type == "activities/distance": - self._state = format(float(raw_state), ".2f") - elif self.resource_type == "activities/tracker/distance": - self._state = format(float(raw_state), ".2f") - elif self.resource_type == "body/bmi": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "body/fat": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "body/weight": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "sleep/startTime": + if resource_type == "activities/distance": + self._attr_native_value = format(float(raw_state), ".2f") + elif resource_type == "activities/tracker/distance": + self._attr_native_value = format(float(raw_state), ".2f") + elif resource_type == "body/bmi": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "body/fat": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "body/weight": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "sleep/startTime": if raw_state == "": - self._state = "-" + self._attr_native_value = "-" elif self.clock_format == "12H": hours, minutes = raw_state.split(":") hours, minutes = int(hours), int(minutes) @@ -506,20 +436,22 @@ def update(self): hours -= 12 elif hours == 0: hours = 12 - self._state = f"{hours}:{minutes:02d} {setting}" + self._attr_native_value = f"{hours}:{minutes:02d} {setting}" else: - self._state = raw_state + self._attr_native_value = raw_state else: if self.is_metric: - self._state = raw_state + self._attr_native_value = raw_state else: try: - self._state = f"{int(raw_state):,}" + self._attr_native_value = f"{int(raw_state):,}" except TypeError: - self._state = raw_state + self._attr_native_value = raw_state - if self.resource_type == "activities/heart": - self._state = response[container][-1].get("value").get("restingHeartRate") + if resource_type == "activities/heart": + self._attr_native_value = ( + response[container][-1].get("value").get("restingHeartRate") + ) token = self.client.client.session.token config_contents = { diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 9214dd6907e02..3108f7d327266 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -64,12 +64,12 @@ def name(self): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._target @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py new file mode 100644 index 0000000000000..64962a746f7fb --- /dev/null +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -0,0 +1,151 @@ +"""The Fjäråskupan integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +import logging + +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from fjaraskupan import UUID_SERVICE, Device, State, device_filter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DISPATCH_DETECTION, DOMAIN + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, +] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeviceState: + """Store state of a device.""" + + device: Device + coordinator: DataUpdateCoordinator[State] + device_info: DeviceInfo + + +@dataclass +class EntryState: + """Store state of config entry.""" + + scanner: BleakScanner + devices: dict[str, DeviceState] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Fjäråskupan from a config entry.""" + + scanner = BleakScanner(filters={"UUIDs": [str(UUID_SERVICE)]}) + + state = EntryState(scanner, {}) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = state + + async def detection_callback( + ble_device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + if data := state.devices.get(ble_device.address): + _LOGGER.debug( + "Update: %s %s - %s", ble_device.name, ble_device, advertisement_data + ) + + data.device.detection_callback(ble_device, advertisement_data) + data.coordinator.async_set_updated_data(data.device.state) + else: + if not device_filter(ble_device, advertisement_data): + return + + _LOGGER.debug( + "Detected: %s %s - %s", ble_device.name, ble_device, advertisement_data + ) + + device = Device(ble_device) + device.detection_callback(ble_device, advertisement_data) + + async def async_update_data(): + """Handle an explicit update request.""" + await device.update() + return device.state + + coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator( + hass, + logger=_LOGGER, + name="Fjaraskupan Updater", + update_interval=timedelta(seconds=120), + update_method=async_update_data, + ) + coordinator.async_set_updated_data(device.state) + + device_info = DeviceInfo( + identifiers={(DOMAIN, ble_device.address)}, + manufacturer="Fjäråskupan", + name="Fjäråskupan", + ) + device_state = DeviceState(device, coordinator, device_info) + state.devices[ble_device.address] = device_state + async_dispatcher_send( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", device_state + ) + + scanner.register_detection_callback(detection_callback) + await scanner.start() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +@callback +def async_setup_entry_platform( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + constructor: Callable[[DeviceState], list[Entity]], +) -> None: + """Set up a platform with added entities.""" + + entry_state: EntryState = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + entity + for device_state in entry_state.devices.values() + for entity in constructor(device_state) + ) + + @callback + def _detection(device_state: DeviceState) -> None: + async_add_entities(constructor(device_state)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", _detection + ) + ) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id) + await entry_state.scanner.stop() + + return unload_ok diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py new file mode 100644 index 0000000000000..9f24a3d39d2cd --- /dev/null +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -0,0 +1,95 @@ +"""Support for sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from fjaraskupan import Device, State + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +@dataclass +class EntityDescription(BinarySensorEntityDescription): + """Entity description.""" + + is_on: Callable = lambda _: False + + +SENSORS = ( + EntityDescription( + key="grease-filter", + name="Grease Filter", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on=lambda state: state.grease_filter_full, + ), + EntityDescription( + key="carbon-filter", + name="Carbon Filter", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on=lambda state: state.carbon_filter_full, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + BinarySensor( + device_state.coordinator, + device_state.device, + device_state.device_info, + entity_description, + ) + for entity_description in SENSORS + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class BinarySensor(CoordinatorEntity[State], BinarySensorEntity): + """Grease filter sensor.""" + + entity_description: EntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + entity_description: EntityDescription, + ) -> None: + """Init sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + self._attr_unique_id = f"{device.address}-{entity_description.key}" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} {entity_description.name}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if data := self.coordinator.data: + return self.entity_description.is_on(data) + return None diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py new file mode 100644 index 0000000000000..4d4d1882dcd23 --- /dev/null +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -0,0 +1,40 @@ +"""Config flow for Fjäråskupan integration.""" +from __future__ import annotations + +import asyncio + +import async_timeout +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from fjaraskupan import UUID_SERVICE, device_filter + +from homeassistant.helpers.config_entry_flow import register_discovery_flow + +from .const import DOMAIN + +CONST_WAIT_TIME = 5.0 + + +async def _async_has_devices(hass) -> bool: + """Return if there are devices that can be discovered.""" + + event = asyncio.Event() + + def detection(device: BLEDevice, advertisement_data: AdvertisementData): + if device_filter(device, advertisement_data): + event.set() + + async with BleakScanner( + detection_callback=detection, filters={"UUIDs": [str(UUID_SERVICE)]} + ): + try: + async with async_timeout.timeout(CONST_WAIT_TIME): + await event.wait() + except asyncio.TimeoutError: + return False + + return True + + +register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices) diff --git a/homeassistant/components/fjaraskupan/const.py b/homeassistant/components/fjaraskupan/const.py new file mode 100644 index 0000000000000..957ac518293af --- /dev/null +++ b/homeassistant/components/fjaraskupan/const.py @@ -0,0 +1,5 @@ +"""Constants for the Fjäråskupan integration.""" + +DOMAIN = "fjaraskupan" + +DISPATCH_DETECTION = f"{DOMAIN}.detection" diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py new file mode 100644 index 0000000000000..bbc04a9607c58 --- /dev/null +++ b/homeassistant/components/fjaraskupan/fan.py @@ -0,0 +1,207 @@ +"""Support for Fjäråskupan fans.""" +from __future__ import annotations + +from fjaraskupan import ( + COMMAND_AFTERCOOKINGTIMERAUTO, + COMMAND_AFTERCOOKINGTIMERMANUAL, + COMMAND_AFTERCOOKINGTIMEROFF, + COMMAND_STOP_FAN, + Device, + State, +) + +from homeassistant.components.fan import ( + SUPPORT_PRESET_MODE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from . import DeviceState, async_setup_entry_platform + +ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"] + +PRESET_MODE_NORMAL = "normal" +PRESET_MODE_AFTER_COOKING_MANUAL = "after_cooking_manual" +PRESET_MODE_AFTER_COOKING_AUTO = "after_cooking_auto" +PRESET_MODE_PERIODIC_VENTILATION = "periodic_ventilation" +PRESET_MODES = [ + PRESET_MODE_NORMAL, + PRESET_MODE_AFTER_COOKING_AUTO, + PRESET_MODE_AFTER_COOKING_MANUAL, + PRESET_MODE_PERIODIC_VENTILATION, +] + +PRESET_TO_COMMAND = { + PRESET_MODE_AFTER_COOKING_MANUAL: COMMAND_AFTERCOOKINGTIMERMANUAL, + PRESET_MODE_AFTER_COOKING_AUTO: COMMAND_AFTERCOOKINGTIMERAUTO, + PRESET_MODE_NORMAL: COMMAND_AFTERCOOKINGTIMEROFF, +} + + +class UnsupportedPreset(HomeAssistantError): + """The preset is unsupported.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState): + return [ + Fan(device_state.coordinator, device_state.device, device_state.device_info) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class Fan(CoordinatorEntity[State], FanEntity): + """Fan entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init fan entity.""" + super().__init__(coordinator) + self._device = device + self._default_on_speed = 25 + self._attr_name = device_info["name"] + self._attr_unique_id = device.address + self._attr_device_info = device_info + self._percentage = 0 + self._preset_mode = PRESET_MODE_NORMAL + self._update_from_device_data(coordinator.data) + + async def async_set_percentage(self, percentage: int) -> None: + """Set speed.""" + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + await self._device.send_fan_speed(int(new_speed)) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the fan.""" + + if preset_mode is None: + preset_mode = self._preset_mode + + if percentage is None: + percentage = self._default_on_speed + + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + + async with self._device: + if preset_mode != self._preset_mode: + if command := PRESET_TO_COMMAND.get(preset_mode): + await self._device.send_command(command) + else: + raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") + + if preset_mode == PRESET_MODE_NORMAL: + await self._device.send_fan_speed(int(new_speed)) + elif preset_mode == PRESET_MODE_AFTER_COOKING_MANUAL: + await self._device.send_after_cooking(int(new_speed)) + elif preset_mode == PRESET_MODE_AFTER_COOKING_AUTO: + await self._device.send_after_cooking(0) + + self.coordinator.async_set_updated_data(self._device.state) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if command := PRESET_TO_COMMAND.get(preset_mode): + await self._device.send_command(command) + self.coordinator.async_set_updated_data(self._device.state) + else: + raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self._device.send_command(COMMAND_STOP_FAN) + self.coordinator.async_set_updated_data(self._device.state) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) + + @property + def percentage(self) -> int | None: + """Return the current speed.""" + return self._percentage + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self._percentage != 0 + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + return PRESET_MODES + + def _update_from_device_data(self, data: State | None) -> None: + """Handle data update.""" + if not data: + self._percentage = 0 + return + + if data.fan_speed: + self._percentage = ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, str(data.fan_speed) + ) + else: + self._percentage = 0 + + if data.after_cooking_on: + if data.after_cooking_fan_speed: + self._preset_mode = PRESET_MODE_AFTER_COOKING_MANUAL + else: + self._preset_mode = PRESET_MODE_AFTER_COOKING_AUTO + elif data.periodic_venting_on: + self._preset_mode = PRESET_MODE_PERIODIC_VENTILATION + else: + self._preset_mode = PRESET_MODE_NORMAL + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + + self._update_from_device_data(self.coordinator.data) + self.async_write_ha_state() diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py new file mode 100644 index 0000000000000..8c44460a0997a --- /dev/null +++ b/homeassistant/components/fjaraskupan/light.py @@ -0,0 +1,85 @@ +"""Support for lights.""" +from __future__ import annotations + +from fjaraskupan import COMMAND_LIGHT_ON_OFF, Device, State + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + COLOR_MODE_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up tuya sensors dynamically through tuya discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + Light( + device_state.coordinator, device_state.device, device_state.device_info + ) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class Light(CoordinatorEntity[State], LightEntity): + """Light device.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init light entity.""" + super().__init__(coordinator) + self._device = device + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + self._attr_unique_id = device.address + self._attr_device_info = device_info + self._attr_name = device_info["name"] + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.send_dim(int(kwargs[ATTR_BRIGHTNESS] * (100.0 / 255.0))) + else: + if not self.is_on: + await self._device.send_command(COMMAND_LIGHT_ON_OFF) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if self.is_on: + await self._device.send_command(COMMAND_LIGHT_ON_OFF) + self.coordinator.async_set_updated_data(self._device.state) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if data := self.coordinator.data: + return data.light_on + return False + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + if data := self.coordinator.data: + return int(data.dim_level * (255.0 / 100.0)) + return None diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json new file mode 100644 index 0000000000000..fb27d8b803f58 --- /dev/null +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "fjaraskupan", + "name": "Fj\u00e4r\u00e5skupan", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", + "requirements": [ + "fjaraskupan==1.0.2" + ], + "codeowners": [ + "@elupus" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py new file mode 100644 index 0000000000000..eecb0b3b8e1bb --- /dev/null +++ b/homeassistant/components/fjaraskupan/number.py @@ -0,0 +1,69 @@ +"""Support for sensors.""" +from __future__ import annotations + +from fjaraskupan import Device, State + +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_MINUTES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + PeriodicVentingTime( + device_state.coordinator, device_state.device, device_state.device_info + ), + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class PeriodicVentingTime(CoordinatorEntity[State], NumberEntity): + """Periodic Venting.""" + + _attr_max_value: float = 59 + _attr_min_value: float = 0 + _attr_step: float = 1 + _attr_entity_category = EntityCategory.CONFIG + _attr_unit_of_measurement = TIME_MINUTES + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init number entities.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = f"{device.address}-periodic-venting" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} Periodic Venting" + + @property + def value(self) -> float | None: + """Return the entity value to represent the entity state.""" + if data := self.coordinator.data: + return data.periodic_venting + return None + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + await self._device.send_periodic_venting(int(value)) + self.coordinator.async_set_updated_data(self._device.state) diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py new file mode 100644 index 0000000000000..8c19b3e3cec0a --- /dev/null +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -0,0 +1,67 @@ +"""Support for sensors.""" +from __future__ import annotations + +from fjaraskupan import Device, State + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + RssiSensor( + device_state.coordinator, device_state.device, device_state.device_info + ) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class RssiSensor(CoordinatorEntity[State], SensorEntity): + """Sensor device.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{device.address}-signal-strength" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} Signal Strength" + self._attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + self._attr_entity_registry_enabled_default = False + self._attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if data := self.coordinator.data: + return data.rssi + return None diff --git a/homeassistant/components/fjaraskupan/strings.json b/homeassistant/components/fjaraskupan/strings.json new file mode 100644 index 0000000000000..c72fc777772b6 --- /dev/null +++ b/homeassistant/components/fjaraskupan/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Do you want to set up Fjäråskupan?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/bg.json b/homeassistant/components/fjaraskupan/translations/bg.json new file mode 100644 index 0000000000000..4db9b1af40ed6 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/bg.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/ca.json b/homeassistant/components/fjaraskupan/translations/ca.json new file mode 100644 index 0000000000000..56172862caa5d --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ca.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "step": { + "confirm": { + "description": "Vols configurar Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/cs.json b/homeassistant/components/fjaraskupan/translations/cs.json new file mode 100644 index 0000000000000..5f890becd5690 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed", + "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/de.json b/homeassistant/components/fjaraskupan/translations/de.json new file mode 100644 index 0000000000000..d1150e177c738 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/de.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du Fj\u00e4r\u00e5skupan einrichten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/el.json b/homeassistant/components/fjaraskupan/translations/el.json new file mode 100644 index 0000000000000..cb6a9ccddb233 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/el.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0394\u03b5\u03bd \u03b2\u03c1\u03ad\u03b8\u03b7\u03ba\u03b5 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03bf" + }, + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {integration};" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/en.json b/homeassistant/components/fjaraskupan/translations/en.json new file mode 100644 index 0000000000000..c0616b6b9e62d --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/es.json b/homeassistant/components/fjaraskupan/translations/es.json new file mode 100644 index 0000000000000..36ff18840480a --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/es.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/et.json b/homeassistant/components/fjaraskupan/translations/et.json new file mode 100644 index 0000000000000..57fe9c81c8f9d --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet", + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + }, + "step": { + "confirm": { + "description": "Kas soovid seadistada Fj\u00e4r\u00e5skupani?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/fr.json b/homeassistant/components/fjaraskupan/translations/fr.json new file mode 100644 index 0000000000000..4239f4eff7f0f --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/he.json b/homeassistant/components/fjaraskupan/translations/he.json new file mode 100644 index 0000000000000..380dbc5d7fcdc --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/hu.json b/homeassistant/components/fjaraskupan/translations/hu.json new file mode 100644 index 0000000000000..cb7e29986aca3 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva. Csak egyetlen konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a Fj\u00e4r\u00e5skupan szolg\u00e1ltat\u00e1st?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/id.json b/homeassistant/components/fjaraskupan/translations/id.json new file mode 100644 index 0000000000000..ed64894fff4a4 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/id.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "confirm": { + "description": "Ingin menyiapkan Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/it.json b/homeassistant/components/fjaraskupan/translations/it.json new file mode 100644 index 0000000000000..49b68f1f9a8ac --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/it.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo trovato sulla rete", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "step": { + "confirm": { + "description": "Vuoi impostare Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/ja.json b/homeassistant/components/fjaraskupan/translations/ja.json new file mode 100644 index 0000000000000..f22b3c04c84e1 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "Fj\u00e4r\u00e5skupan\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/nl.json b/homeassistant/components/fjaraskupan/translations/nl.json new file mode 100644 index 0000000000000..498ef7af1be6c --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je Fj\u00e4r\u00e5skupan opzetten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/no.json b/homeassistant/components/fjaraskupan/translations/no.json new file mode 100644 index 0000000000000..b05779cbe06c0 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/pl.json b/homeassistant/components/fjaraskupan/translations/pl.json new file mode 100644 index 0000000000000..65fcb66af6deb --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/pl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/ru.json b/homeassistant/components/fjaraskupan/translations/ru.json new file mode 100644 index 0000000000000..5d165713eb10d --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ru.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/tr.json b/homeassistant/components/fjaraskupan/translations/tr.json new file mode 100644 index 0000000000000..c64f41f5c8859 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/tr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "confirm": { + "description": "Fj\u00e4r\u00e5skupan'\u0131 kurmak istiyor musunuz?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/zh-Hant.json b/homeassistant/components/fjaraskupan/translations/zh-Hant.json new file mode 100644 index 0000000000000..3312cea3576a1 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/zh-Hant.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Fj\u00e4r\u00e5skupan\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index 1f4c0d0ddfcb5..688531c11f141 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -5,7 +5,9 @@ from ritassist import API import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, +) from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -18,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index cf4662b98666e..ce3e5a68e1e6e 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -3,7 +3,6 @@ import logging -from pyflexit.pyflexit import pyflexit import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity @@ -12,7 +11,15 @@ SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN +from homeassistant.components.modbus import get_hub +from homeassistant.components.modbus.const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CALL_TYPE_WRITE_REGISTER, + CONF_HUB, + DEFAULT_HUB, +) +from homeassistant.components.modbus.modbus import ModbusHub from homeassistant.const import ( ATTR_TEMPERATURE, CONF_NAME, @@ -20,7 +27,9 @@ DEVICE_DEFAULT_NAME, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -35,18 +44,25 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities, + discovery_info: DiscoveryInfoType = None, +): """Set up the Flexit Platform.""" modbus_slave = config.get(CONF_SLAVE) name = config.get(CONF_NAME) - hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)] - add_entities([Flexit(hub, modbus_slave, name)], True) + hub = get_hub(hass, config[CONF_HUB]) + async_add_entities([Flexit(hub, modbus_slave, name)], True) class Flexit(ClimateEntity): """Representation of a Flexit AC unit.""" - def __init__(self, hub, modbus_slave, name): + def __init__( + self, hub: ModbusHub, modbus_slave: int | None, name: str | None + ) -> None: """Initialize the unit.""" self._hub = hub self._name = name @@ -64,34 +80,65 @@ def __init__(self, hub, modbus_slave, name): self._heating = None self._cooling = None self._alarm = False - self.unit = pyflexit(hub, modbus_slave) + self._outdoor_air_temp = None @property def supported_features(self): """Return the list of supported features.""" return SUPPORT_FLAGS - def update(self): + async def async_update(self): """Update unit attributes.""" - if not self.unit.update(): - _LOGGER.warning("Modbus read failed") - - self._target_temperature = self.unit.get_target_temp - self._current_temperature = self.unit.get_temp - self._current_fan_mode = self._fan_modes[self.unit.get_fan_speed] - self._filter_hours = self.unit.get_filter_hours - # Mechanical heat recovery, 0-100% - self._heat_recovery = self.unit.get_heat_recovery - # Heater active 0-100% - self._heating = self.unit.get_heating - # Cooling active 0-100% - self._cooling = self.unit.get_cooling - # Filter alarm 0/1 - self._filter_alarm = self.unit.get_filter_alarm - # Heater enabled or not. Does not mean it's necessarily heating - self._heater_enabled = self.unit.get_heater_enabled - # Current operation mode - self._current_operation = self.unit.get_operation + self._target_temperature = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_HOLDING, 8 + ) + self._current_temperature = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_INPUT, 9 + ) + res = await self._async_read_int16_from_register(CALL_TYPE_REGISTER_HOLDING, 17) + if res < len(self._fan_modes): + self._current_fan_mode = res + self._filter_hours = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 8 + ) + # # Mechanical heat recovery, 0-100% + self._heat_recovery = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 14 + ) + # # Heater active 0-100% + self._heating = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 15 + ) + # # Cooling active 0-100% + self._cooling = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 13 + ) + # # Filter alarm 0/1 + self._filter_alarm = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 27 + ) + # # Heater enabled or not. Does not mean it's necessarily heating + self._heater_enabled = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 28 + ) + self._outdoor_air_temp = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_INPUT, 11 + ) + + actual_air_speed = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 48 + ) + + if self._heating: + self._current_operation = "Heating" + elif self._cooling: + self._current_operation = "Cooling" + elif self._heat_recovery: + self._current_operation = "Recovering" + elif actual_air_speed: + self._current_operation = "Fan Only" + else: + self._current_operation = "Off" @property def extra_state_attributes(self): @@ -103,6 +150,7 @@ def extra_state_attributes(self): "heating": self._heating, "heater_enabled": self._heater_enabled, "cooling": self._cooling, + "outdoor_air_temp": self._outdoor_air_temp, } @property @@ -153,12 +201,53 @@ def fan_modes(self): """Return the list of available fan modes.""" return self._fan_modes - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if kwargs.get(ATTR_TEMPERATURE) is not None: - self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - self.unit.set_temp(self._target_temperature) + target_temperature = kwargs.get(ATTR_TEMPERATURE) + else: + _LOGGER.error("Received invalid temperature") + return + + if await self._async_write_int16_to_register(8, target_temperature * 10): + self._target_temperature = target_temperature + else: + _LOGGER.error("Modbus error setting target temperature to Flexit") - def set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_modes.index(fan_mode)) + if await self._async_write_int16_to_register( + 17, self.fan_modes.index(fan_mode) + ): + self._current_fan_mode = self.fan_modes.index(fan_mode) + else: + _LOGGER.error("Modbus error setting fan mode to Flexit") + + # Based on _async_read_register in ModbusThermostat class + async def _async_read_int16_from_register(self, register_type, register) -> int: + """Read register using the Modbus hub slave.""" + result = await self._hub.async_pymodbus_call( + self._slave, register, 1, register_type + ) + if result is None: + _LOGGER.error("Error reading value from Flexit modbus adapter") + return -1 + + return int(result.registers[0]) + + async def _async_read_temp_from_register(self, register_type, register) -> float: + result = float( + await self._async_read_int16_from_register(register_type, register) + ) + if result == -1: + return -1 + return result / 10.0 + + async def _async_write_int16_to_register(self, register, value) -> bool: + value = int(value) + result = await self._hub.async_pymodbus_call( + self._slave, register, value, CALL_TYPE_WRITE_REGISTER + ) + if result == -1: + return False + return True diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json index 96ed5b55904f1..d9f84d5ab8179 100644 --- a/homeassistant/components/flexit/manifest.json +++ b/homeassistant/components/flexit/manifest.json @@ -2,7 +2,6 @@ "domain": "flexit", "name": "Flexit", "documentation": "https://www.home-assistant.io/integrations/flexit", - "requirements": ["pyflexit==0.3"], "dependencies": ["modbus"], "codeowners": [], "iot_class": "local_polling" diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json index c7018199d91bd..7480257fcaa06 100644 --- a/homeassistant/components/flic/manifest.json +++ b/homeassistant/components/flic/manifest.json @@ -2,7 +2,7 @@ "domain": "flic", "name": "Flic", "documentation": "https://www.home-assistant.io/integrations/flic", - "requirements": ["pyflic-homeassistant==0.4.dev0"], + "requirements": ["pyflic==2.0.3"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py index 54167b6a55fde..8fca87a6814e3 100644 --- a/homeassistant/components/flick_electric/__init__.py +++ b/homeassistant/components/flick_electric/__init__.py @@ -13,6 +13,7 @@ CONF_CLIENT_SECRET, CONF_PASSWORD, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -21,10 +22,10 @@ CONF_ID_TOKEN = "id_token" -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flick Electric from a config entry.""" auth = HassFlickAuth(hass, entry) @@ -36,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -47,7 +48,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class HassFlickAuth(AbstractFlickAuth): """Implementation of AbstractFlickAuth based on a Home Assistant entity config.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Flick authention based on a Home Assistant entity config.""" super().__init__(aiohttp_client.async_get_clientsession(hass)) self._entry = entry diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index c76b44396f579..7f21397d5a789 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -45,7 +45,7 @@ async def _validate_input(self, user_input): ) try: - with async_timeout.timeout(60): + async with async_timeout.timeout(60): token = await auth.async_get_access_token() except asyncio.TimeoutError as err: raise CannotConnect() from err diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index e68806002126e..7ca3b99f928d0 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -36,7 +36,9 @@ async def async_setup_entry( class FlickPricingSensor(SensorEntity): """Entity object for Flick Electric sensor.""" - def __init__(self, api: FlickAPI): + _attr_native_unit_of_measurement = UNIT_NAME + + def __init__(self, api: FlickAPI) -> None: """Entity object for Flick Electric sensor.""" self._api: FlickAPI = api self._price: FlickPrice = None @@ -51,15 +53,10 @@ def name(self): return FRIENDLY_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._price.price - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return UNIT_NAME - @property def extra_state_attributes(self): """Return the state attributes.""" @@ -70,7 +67,7 @@ async def async_update(self): if self._price and self._price.end_at >= utcnow(): return # Power price data is still valid - with async_timeout.timeout(60): + async with async_timeout.timeout(60): self._price = await self._api.getPricing() self._attributes[ATTR_START_AT] = self._price.start_at diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json index a20b5059ef7b9..cb8382539b4e9 100644 --- a/homeassistant/components/flick_electric/strings.json +++ b/homeassistant/components/flick_electric/strings.json @@ -6,8 +6,8 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "Client ID (Optional)", - "client_secret": "Client Secret (Optional)" + "client_id": "Client ID (optional)", + "client_secret": "Client Secret (optional)" } } }, diff --git a/homeassistant/components/flick_electric/translations/bg.json b/homeassistant/components/flick_electric/translations/bg.json new file mode 100644 index 0000000000000..5e1ec63739aa0 --- /dev/null +++ b/homeassistant/components/flick_electric/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID (\u043f\u043e \u0438\u0437\u0431\u043e\u0440)", + "client_secret": "Client Secret (\u043f\u043e \u0438\u0437\u0431\u043e\u0440)", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/ca.json b/homeassistant/components/flick_electric/translations/ca.json index 74fd0e79708b3..b98cfc742db13 100644 --- a/homeassistant/components/flick_electric/translations/ca.json +++ b/homeassistant/components/flick_electric/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/flick_electric/translations/de.json b/homeassistant/components/flick_electric/translations/de.json index 13ae855560840..8409250c5fa49 100644 --- a/homeassistant/components/flick_electric/translations/de.json +++ b/homeassistant/components/flick_electric/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/flick_electric/translations/en.json b/homeassistant/components/flick_electric/translations/en.json index ecade0c677bf1..9fdef5dd01d6a 100644 --- a/homeassistant/components/flick_electric/translations/en.json +++ b/homeassistant/components/flick_electric/translations/en.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "Client ID (Optional)", - "client_secret": "Client Secret (Optional)", + "client_id": "Client ID (optional)", + "client_secret": "Client Secret (optional)", "password": "Password", "username": "Username" }, diff --git a/homeassistant/components/flick_electric/translations/es-419.json b/homeassistant/components/flick_electric/translations/es-419.json new file mode 100644 index 0000000000000..59ecddf99c35b --- /dev/null +++ b/homeassistant/components/flick_electric/translations/es-419.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "client_id": "Id. de cliente (opcional)", + "client_secret": "Secreto de cliente (opcional)" + }, + "title": "Credenciales de acceso a Flick" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/fr.json b/homeassistant/components/flick_electric/translations/fr.json index 291a8e59fe45a..fc7605c097565 100644 --- a/homeassistant/components/flick_electric/translations/fr.json +++ b/homeassistant/components/flick_electric/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/flick_electric/translations/he.json b/homeassistant/components/flick_electric/translations/he.json index 856885307118c..0cbd1ab331b2f 100644 --- a/homeassistant/components/flick_electric/translations/he.json +++ b/homeassistant/components/flick_electric/translations/he.json @@ -1,17 +1,18 @@ { "config": { "abort": { - "already_configured": "\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4" + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "user": { "data": { - "client_id": "Client ID (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", - "client_secret": "Client Secret (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "client_id": "\u05de\u05d6\u05d4\u05d4 \u05dc\u05e7\u05d5\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "client_secret": "\u05e1\u05d5\u05d3 \u05dc\u05e7\u05d5\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } diff --git a/homeassistant/components/flick_electric/translations/hu.json b/homeassistant/components/flick_electric/translations/hu.json index f7ed726e43305..90ea92089e1e0 100644 --- a/homeassistant/components/flick_electric/translations/hu.json +++ b/homeassistant/components/flick_electric/translations/hu.json @@ -11,6 +11,8 @@ "step": { "user": { "data": { + "client_id": "Kliens ID (opcion\u00e1lis)", + "client_secret": "Kliens jelsz\u00f3 (nem k\u00f6telez\u0151)", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/flick_electric/translations/id.json b/homeassistant/components/flick_electric/translations/id.json index 8c283cfd56edd..3085534a86282 100644 --- a/homeassistant/components/flick_electric/translations/id.json +++ b/homeassistant/components/flick_electric/translations/id.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "ID Klien (Opsional)", - "client_secret": "Kode Rahasia Klien (Opsional)", + "client_id": "ID Klien (opsional)", + "client_secret": "Kode Rahasia Klien (opsional)", "password": "Kata Sandi", "username": "Nama Pengguna" }, diff --git a/homeassistant/components/flick_electric/translations/it.json b/homeassistant/components/flick_electric/translations/it.json index a505f915c6fab..ba3e26163c918 100644 --- a/homeassistant/components/flick_electric/translations/it.json +++ b/homeassistant/components/flick_electric/translations/it.json @@ -12,7 +12,7 @@ "user": { "data": { "client_id": "ID cliente (opzionale)", - "client_secret": "Client Secret (opzionale)", + "client_secret": "Segreto client (opzionale)", "password": "Password", "username": "Nome utente" }, diff --git a/homeassistant/components/flick_electric/translations/ja.json b/homeassistant/components/flick_electric/translations/ja.json new file mode 100644 index 0000000000000..6091cfde5c696 --- /dev/null +++ b/homeassistant/components/flick_electric/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "client_id": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8ID(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "client_secret": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u30b7\u30fc\u30af\u30ec\u30c3\u30c8(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Flick\u306e\u30ed\u30b0\u30a4\u30f3\u8a8d\u8a3c\u60c5\u5831" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/tr.json b/homeassistant/components/flick_electric/translations/tr.json index a83e1936fb4a1..64be92a8e5fbc 100644 --- a/homeassistant/components/flick_electric/translations/tr.json +++ b/homeassistant/components/flick_electric/translations/tr.json @@ -11,9 +11,12 @@ "step": { "user": { "data": { + "client_id": "\u0130stemci Kimli\u011fi (iste\u011fe ba\u011fl\u0131)", + "client_secret": "\u0130stemci Gizlili\u011fi (iste\u011fe ba\u011fl\u0131)", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Flick oturum a\u00e7ma kimlik bilgileri" } } } diff --git a/homeassistant/components/flick_electric/translations/zh-Hant.json b/homeassistant/components/flick_electric/translations/zh-Hant.json index 3df68984ec5d1..10e76956fd60c 100644 --- a/homeassistant/components/flick_electric/translations/zh-Hant.json +++ b/homeassistant/components/flick_electric/translations/zh-Hant.json @@ -12,7 +12,7 @@ "user": { "data": { "client_id": "\u5ba2\u6236\u7aef ID\uff08\u9078\u9805\uff09", - "client_secret": "\u5ba2\u6236\u7aef\u5bc6\u9470\uff08\u9078\u9805\uff09", + "client_secret": "\u5ba2\u6236\u7aef\u79c1\u9470\uff08\u9078\u9805\uff09", "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py new file mode 100644 index 0000000000000..8379845982a8b --- /dev/null +++ b/homeassistant/components/flipr/__init__.py @@ -0,0 +1,97 @@ +"""The Flipr integration.""" +from datetime import timedelta +import logging + +from flipr_api import FliprAPIRestClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=60) + + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flipr from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + coordinator = FliprDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class FliprDataUpdateCoordinator(DataUpdateCoordinator): + """Class to hold Flipr data retrieval.""" + + def __init__(self, hass, entry): + """Initialize.""" + username = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + self.flipr_id = entry.data[CONF_FLIPR_ID] + + # Establishes the connection. + self.client = FliprAPIRestClient(username, password) + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=f"Flipr data measure for {self.flipr_id}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + return await self.hass.async_add_executor_job( + self.client.get_pool_measure_latest, self.flipr_id + ) + + +class FliprEntity(CoordinatorEntity): + """Implements a common class elements representing the Flipr component.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize Flipr sensor.""" + super().__init__(coordinator) + self.entity_description = description + if coordinator.config_entry: + flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] + self._attr_unique_id = f"{flipr_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, flipr_id)}, + manufacturer=MANUFACTURER, + name=NAME, + ) + + self._attr_name = f"Flipr {flipr_id} {description.name}" diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py new file mode 100644 index 0000000000000..54f902488020c --- /dev/null +++ b/homeassistant/components/flipr/binary_sensor.py @@ -0,0 +1,46 @@ +"""Support for Flipr binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) + +from . import FliprEntity +from .const import DOMAIN + +BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="ph_status", + name="PH Status", + device_class=BinarySensorDeviceClass.PROBLEM, + ), + BinarySensorEntityDescription( + key="chlorine_status", + name="Chlorine Status", + device_class=BinarySensorDeviceClass.PROBLEM, + ), +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup of flipr binary sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FliprBinarySensor(coordinator, description) + for description in BINARY_SENSORS_TYPES + ) + + +class FliprBinarySensor(FliprEntity, BinarySensorEntity): + """Representation of Flipr binary sensors.""" + + @property + def is_on(self): + """Return true if the binary sensor is on in case of a Problem is detected.""" + return ( + self.coordinator.data[self.entity_description.key] == "TooLow" + or self.coordinator.data[self.entity_description.key] == "TooHigh" + ) diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py new file mode 100644 index 0000000000000..b1e4f31d04419 --- /dev/null +++ b/homeassistant/components/flipr/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow for Flipr integration.""" +from __future__ import annotations + +import logging + +from flipr_api import FliprAPIRestClient +from requests.exceptions import HTTPError, Timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import CONF_FLIPR_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flipr.""" + + VERSION = 1 + + _username: str | None = None + _password: str | None = None + _flipr_id: str | None = None + _possible_flipr_ids: list[str] | None = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self._show_setup_form() + + self._username = user_input[CONF_EMAIL] + self._password = user_input[CONF_PASSWORD] + + errors = {} + if not self._flipr_id: + try: + flipr_ids = await self._authenticate_and_search_flipr() + except HTTPError: + errors["base"] = "invalid_auth" + except (Timeout, ConnectionError): + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(exception) + + if not errors and not flipr_ids: + # No flipr_id found. Tell the user with an error message. + errors["base"] = "no_flipr_id_found" + + if errors: + return self._show_setup_form(errors) + + if len(flipr_ids) == 1: + self._flipr_id = flipr_ids[0] + else: + # If multiple flipr found (rare case), we ask the user to choose one in a select box. + # The user will have to run config_flow as many times as many fliprs he has. + self._possible_flipr_ids = flipr_ids + return await self.async_step_flipr_id() + + # Check if already configured + await self.async_set_unique_id(self._flipr_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._flipr_id, + data={ + CONF_EMAIL: self._username, + CONF_PASSWORD: self._password, + CONF_FLIPR_ID: self._flipr_id, + }, + ) + + 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_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def _authenticate_and_search_flipr(self) -> list[str]: + """Validate the username and password provided and searches for a flipr id.""" + # Instantiates the flipr API that does not require async since it is has no network access. + client = FliprAPIRestClient(self._username, self._password) + + flipr_ids = await self.hass.async_add_executor_job(client.search_flipr_ids) + + return flipr_ids + + async def async_step_flipr_id(self, user_input=None): + """Handle the initial step.""" + if not user_input: + # Creation of a select with the proposal of flipr ids values found by API. + flipr_ids_for_form = {} + for flipr_id in self._possible_flipr_ids: + flipr_ids_for_form[flipr_id] = f"{flipr_id}" + + return self.async_show_form( + step_id="flipr_id", + data_schema=vol.Schema( + { + vol.Required(CONF_FLIPR_ID): vol.All( + vol.Coerce(str), vol.In(flipr_ids_for_form) + ) + } + ), + ) + + # Get chosen flipr_id. + self._flipr_id = user_input[CONF_FLIPR_ID] + + return await self.async_step_user( + { + CONF_EMAIL: self._username, + CONF_PASSWORD: self._password, + CONF_FLIPR_ID: self._flipr_id, + } + ) diff --git a/homeassistant/components/flipr/const.py b/homeassistant/components/flipr/const.py new file mode 100644 index 0000000000000..d28353f477600 --- /dev/null +++ b/homeassistant/components/flipr/const.py @@ -0,0 +1,10 @@ +"""Constants for the Flipr integration.""" + +DOMAIN = "flipr" + +CONF_FLIPR_ID = "flipr_id" + +ATTRIBUTION = "Flipr Data" + +MANUFACTURER = "CTAC-TECH" +NAME = "Flipr" diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json new file mode 100644 index 0000000000000..330fea7de8b96 --- /dev/null +++ b/homeassistant/components/flipr/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "flipr", + "name": "Flipr", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flipr", + "requirements": [ + "flipr-api==1.4.1"], + "codeowners": [ + "@cnico" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py new file mode 100644 index 0000000000000..9fcef425a2641 --- /dev/null +++ b/homeassistant/components/flipr/sensor.py @@ -0,0 +1,60 @@ +"""Sensor platform for the Flipr's pool_sensor.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ELECTRIC_POTENTIAL_MILLIVOLT, TEMP_CELSIUS + +from . import FliprEntity +from .const import DOMAIN + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="chlorine", + name="Chlorine", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + ), + SensorEntityDescription( + key="ph", + name="pH", + icon="mdi:pool", + ), + SensorEntityDescription( + key="temperature", + name="Water Temp", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="date_time", + name="Last Measured", + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key="red_ox", + name="Red OX", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + ), +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [FliprSensor(coordinator, description) for description in SENSOR_TYPES] + async_add_entities(sensors) + + +class FliprSensor(FliprEntity, SensorEntity): + """Sensor representing FliprSensor data.""" + + @property + def native_value(self): + """State of the sensor.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json new file mode 100644 index 0000000000000..55feaa691f72b --- /dev/null +++ b/homeassistant/components/flipr/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Flipr", + "description": "Connect using your Flipr account.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "flipr_id": { + "title": "Choose your Flipr", + "description": "Choose your Flipr ID in the list", + "data": { + "flipr_id": "Flipr ID" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/flipr/translations/bg.json b/homeassistant/components/flipr/translations/bg.json new file mode 100644 index 0000000000000..51ee3653e152c --- /dev/null +++ b/homeassistant/components/flipr/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/ca.json b/homeassistant/components/flipr/translations/ca.json new file mode 100644 index 0000000000000..fcb43623030a8 --- /dev/null +++ b/homeassistant/components/flipr/translations/ca.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "no_flipr_id_found": "De moment, no hi ha cap identificador de Flipr associat al teu compte. Primer hauries de verificar que funciona amb l'aplicaci\u00f3 m\u00f2bil de Flipr.", + "unknown": "Error inesperat" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Tria l'ID Flipr de la llista", + "title": "Tria el teu Flipr" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "description": "Connecta't amb el teu compte de Flipr.", + "title": "Connexi\u00f3 amb Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/cs.json b/homeassistant/components/flipr/translations/cs.json new file mode 100644 index 0000000000000..29c2ebc17138c --- /dev/null +++ b/homeassistant/components/flipr/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/de.json b/homeassistant/components/flipr/translations/de.json new file mode 100644 index 0000000000000..4dbbfbc9ef9fb --- /dev/null +++ b/homeassistant/components/flipr/translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "no_flipr_id_found": "Deinem Konto ist im Moment keine Flipr-ID zugeordnet. Du solltest zuerst \u00fcberpr\u00fcfen, ob es mit der mobilen App von Flipr funktioniert.", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr-ID" + }, + "description": "W\u00e4hle deine Flipr-ID in der Liste", + "title": "W\u00e4hle deinen Flipr" + }, + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + }, + "description": "Verbinde dich mit deinem Flipr-Konto.", + "title": "Mit Flipr verbinden" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/en.json b/homeassistant/components/flipr/translations/en.json new file mode 100644 index 0000000000000..667824d407bae --- /dev/null +++ b/homeassistant/components/flipr/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first.", + "unknown": "Unexpected error" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Choose your Flipr ID in the list", + "title": "Choose your Flipr" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "description": "Connect using your Flipr account.", + "title": "Connect to Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json new file mode 100644 index 0000000000000..0a066451b84f7 --- /dev/null +++ b/homeassistant/components/flipr/translations/es.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "no_flipr_id_found": "Por ahora no hay ning\u00fan ID de Flipr asociado a tu cuenta. Deber\u00edas verificar que est\u00e1 funcionando con la aplicaci\u00f3n m\u00f3vil de Flipr primero.", + "unknown": "Error inesperado" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID de Flipr" + }, + "description": "Elige tu ID de Flipr en la lista", + "title": "Elige tu Flipr" + }, + "user": { + "data": { + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + }, + "description": "Con\u00e9ctese usando su cuenta Flipr.", + "title": "Conectarse a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/et.json b/homeassistant/components/flipr/translations/et.json new file mode 100644 index 0000000000000..46be2f4378fa8 --- /dev/null +++ b/homeassistant/components/flipr/translations/et.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "no_flipr_id_found": "Kontoga pole praegu \u00fchtegi flipr-it seostatud. K\u00f5igepealt pead kontrollima, kas see t\u00f6\u00f6tab Flipri mobiilirakendusega.", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipri ID" + }, + "description": "Vali loendist oma Flipri ID", + "title": "Vali oma Flipr" + }, + "user": { + "data": { + "email": "E-post", + "password": "Salas\u00f5na" + }, + "description": "\u00dchenda oma Flipr konto abil.", + "title": "Flipriga \u00fchenduse loomine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/fr.json b/homeassistant/components/flipr/translations/fr.json new file mode 100644 index 0000000000000..ec9260aa8a789 --- /dev/null +++ b/homeassistant/components/flipr/translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "no_flipr_id_found": "Aucun identifiant Flipr n'est associ\u00e9 \u00e0 votre compte pour le moment. Vous devez d'abord v\u00e9rifier qu'il fonctionne avec l'application mobile de Flipr.", + "unknown": "Erreur inattendue" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Choisissez votre ID Flipr dans la liste", + "title": "Choisir votre Flipr" + }, + "user": { + "data": { + "email": "Email", + "password": "Mot de passe" + }, + "description": "Connectez-vous \u00e0 votre compte Flipr.", + "title": "Connexion a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/he.json b/homeassistant/components/flipr/translations/he.json new file mode 100644 index 0000000000000..ecb8a74bc6f58 --- /dev/null +++ b/homeassistant/components/flipr/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/hu.json b/homeassistant/components/flipr/translations/hu.json new file mode 100644 index 0000000000000..4daf0446abc46 --- /dev/null +++ b/homeassistant/components/flipr/translations/hu.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_flipr_id_found": "A fi\u00f3kj\u00e1hoz jelenleg nem tartozik Flipr-azonos\u00edt\u00f3. El\u0151sz\u00f6r ellen\u0151riznie kell, hogy m\u0171k\u00f6dik-e a Flipr mobilalkalmaz\u00e1s\u00e1val.", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr azonos\u00edt\u00f3" + }, + "description": "V\u00e1lassza ki a Flipr azonos\u00edt\u00f3j\u00e1t a list\u00e1b\u00f3l", + "title": "V\u00e1lassza ki a Flipr-t" + }, + "user": { + "data": { + "email": "Email", + "password": "Jelsz\u00f3" + }, + "description": "Csatlakozzon a Flipr-fi\u00f3kj\u00e1val.", + "title": "Csatlakoz\u00e1s a Flipr-hez" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/id.json b/homeassistant/components/flipr/translations/id.json new file mode 100644 index 0000000000000..0f1758f2d8f41 --- /dev/null +++ b/homeassistant/components/flipr/translations/id.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "no_flipr_id_found": "Tidak ada id flipr yang terkait dengan akun Anda untuk saat ini. Anda harus memverifikasinya dengan aplikasi seluler Flipr terlebih dahulu.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Pilih ID Flipr Anda dari daftar", + "title": "Pilih ID Flipr Anda" + }, + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + }, + "description": "Hubungkan menggunakan akun Flipr Anda.", + "title": "Hubungkan ke Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/it.json b/homeassistant/components/flipr/translations/it.json new file mode 100644 index 0000000000000..fd3b36cbdf3cc --- /dev/null +++ b/homeassistant/components/flipr/translations/it.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "no_flipr_id_found": "Nessun ID flipr associato al tuo account per ora. Dovresti prima verificare che funzioni con l'app mobile di Flipr.", + "unknown": "Errore imprevisto" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Scegli il tuo ID Flipr nell'elenco", + "title": "Scegli il tuo Flipr" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "description": "Connettiti usando il tuo account Flipr.", + "title": "Connettiti a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/ja.json b/homeassistant/components/flipr/translations/ja.json new file mode 100644 index 0000000000000..7d87c4a3c39eb --- /dev/null +++ b/homeassistant/components/flipr/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_flipr_id_found": "\u73fe\u5728\u3001\u3042\u306a\u305f\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u95a2\u9023\u4ed8\u3051\u3089\u308c\u3066\u3044\u308bflipr id\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u307e\u305a\u306f\u3001Flipr\u306e\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea\u3067\u52d5\u4f5c\u3057\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "\u30ea\u30b9\u30c8\u306e\u4e2d\u304b\u3089FliprID\u3092\u9078\u3076", + "title": "Flipr\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u3042\u306a\u305f\u306eFlipr\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u4f7f\u7528\u3057\u3066\u63a5\u7d9a\u3057\u307e\u3059\u3002", + "title": "Flipr\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/nl.json b/homeassistant/components/flipr/translations/nl.json new file mode 100644 index 0000000000000..d66028ee244ef --- /dev/null +++ b/homeassistant/components/flipr/translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "no_flipr_id_found": "Er is nog geen flipr id aan uw account gekoppeld. U moet eerst controleren of het werkt met de mobiele app van Flipr.", + "unknown": "Onverwachte fout" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Kies uw Flipr-ID in de lijst", + "title": "Kies uw Flipr" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "description": "Maak verbinding met uw Flipr-account.", + "title": "Maak verbinding met Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/no.json b/homeassistant/components/flipr/translations/no.json new file mode 100644 index 0000000000000..550b0bae05836 --- /dev/null +++ b/homeassistant/components/flipr/translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "no_flipr_id_found": "Ingen flipr -ID er knyttet til kontoen din forel\u00f8pig. Du b\u00f8r bekrefte at den fungerer med Flipr -mobilappen f\u00f8rst.", + "unknown": "Uventet feil" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Velg din Flipr -ID i listen", + "title": "Velg din Flipr" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "Koble til ved hjelp av Flipr-kontoen din.", + "title": "Koble til Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/pl.json b/homeassistant/components/flipr/translations/pl.json new file mode 100644 index 0000000000000..436061f7f61b8 --- /dev/null +++ b/homeassistant/components/flipr/translations/pl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "no_flipr_id_found": "Brak identyfikatora Flipr powi\u0105zanego z Twoim kontem. Sprawd\u017a najpierw, czy dzia\u0142a z aplikacj\u0105 mobiln\u0105 Flipr.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Identyfikator Flipr" + }, + "description": "Wybierz sw\u00f3j identyfikator Flipr z listy", + "title": "Wyb\u00f3r identyfikatora Flipr" + }, + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + }, + "description": "Po\u0142\u0105cz, u\u017cywaj\u0105c swojego konta Flipr.", + "title": "Po\u0142\u0105czenie z Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/ru.json b/homeassistant/components/flipr/translations/ru.json new file mode 100644 index 0000000000000..d7625b5bb4184 --- /dev/null +++ b/homeassistant/components/flipr/translations/ru.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "no_flipr_id_found": "\u041d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u0441 \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e \u043d\u0435 \u0441\u0432\u044f\u0437\u0430\u043d\u044b Flipr ID. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0432\u0441\u0451 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0432 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u043c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 Flipr.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Flipr ID \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430", + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0412\u0430\u0448 Flipr" + }, + "user": { + "data": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0435\u0441\u044c, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Flipr.", + "title": "Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/tr.json b/homeassistant/components/flipr/translations/tr.json new file mode 100644 index 0000000000000..e5649496f387f --- /dev/null +++ b/homeassistant/components/flipr/translations/tr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "no_flipr_id_found": "\u015eu anda hesab\u0131n\u0131zla ili\u015fkilendirilmi\u015f bir flipr kimli\u011fi yok. \u00d6nce Flipr'\u0131n mobil uygulamas\u0131yla \u00e7al\u0131\u015ft\u0131\u011f\u0131n\u0131 do\u011frulaman\u0131z gerekir.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr Kimli\u011fi" + }, + "description": "Listeden Flipr kimli\u011finizi se\u00e7in", + "title": "Flipr'inizi se\u00e7in" + }, + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + }, + "description": "Flipr hesab\u0131n\u0131z\u0131 kullanarak ba\u011flan\u0131n.", + "title": "Flipr'e ba\u011flan" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/zh-Hans.json b/homeassistant/components/flipr/translations/zh-Hans.json new file mode 100644 index 0000000000000..d217ccdc8429d --- /dev/null +++ b/homeassistant/components/flipr/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/zh-Hant.json b/homeassistant/components/flipr/translations/zh-Hant.json new file mode 100644 index 0000000000000..546db0beccf15 --- /dev/null +++ b/homeassistant/components/flipr/translations/zh-Hant.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "no_flipr_id_found": "\u76ee\u524d\u5e33\u865f\u4e2d\u6c92\u6709\u4efb\u4f55\u95dc\u806f\u7684 Flipr ID\uff0c\u8acb\u5148\u900f\u904e Flipr \u624b\u6a5f App \u9032\u884c\u9a57\u8b49\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "\u7531\u5217\u8868\u4e2d\u9078\u64c7 Flipr ID", + "title": "\u9078\u64c7 Flipr ID" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "description": "\u4f7f\u7528 Flipr \u5e33\u865f\u9032\u884c\u9023\u7dda\u3002", + "title": "\u9023\u7dda\u81f3 Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index 890f18ee3b751..2dcca979acc34 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -6,7 +6,7 @@ from aioflo.errors import RequestError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -16,10 +16,10 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor", "switch"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up flo from a config entry.""" session = async_get_clientsession(hass) hass.data.setdefault(DOMAIN, {}) @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index bd623aa38bb1b..2118635d9ad4e 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_PROBLEM, + BinarySensorDeviceClass, BinarySensorEntity, ) @@ -37,6 +37,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FloPendingAlertsBinarySensor(FloEntity, BinarySensorEntity): """Binary sensor that reports on if there are any pending system alerts.""" + _attr_device_class = BinarySensorDeviceClass.PROBLEM + def __init__(self, device): """Initialize the pending alerts binary sensor.""" super().__init__("pending_system_alerts", "Pending System Alerts", device) @@ -57,15 +59,12 @@ def extra_state_attributes(self): "critical": self._device.pending_critical_alerts_count, } - @property - def device_class(self): - """Return the device class for the binary sensor.""" - return DEVICE_CLASS_PROBLEM - class FloWaterDetectedBinarySensor(FloEntity, BinarySensorEntity): """Binary sensor that reports if water is detected (for leak detectors).""" + _attr_device_class = BinarySensorDeviceClass.PROBLEM + def __init__(self, device): """Initialize the pending alerts binary sensor.""" super().__init__("water_detected", "Water Detected", device) @@ -74,8 +73,3 @@ def __init__(self, device): def is_on(self): """Return true if the Flo device is detecting water.""" return self._device.water_detected - - @property - def device_class(self): - """Return the device class for the binary sensor.""" - return DEVICE_CLASS_PROBLEM diff --git a/homeassistant/components/flo/config_flow.py b/homeassistant/components/flo/config_flow.py index 038fc33777a7f..306ec945a3ea6 100644 --- a/homeassistant/components/flo/config_flow.py +++ b/homeassistant/components/flo/config_flow.py @@ -9,7 +9,7 @@ from .const import DOMAIN, LOGGER -DATA_SCHEMA = vol.Schema({"username": str, "password": str}) +DATA_SCHEMA = vol.Schema({vol.Required("username"): str, vol.Required("password"): str}) async def validate_input(hass: core.HomeAssistant, data): diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index e955c784ae4e4..cc32acb485c7c 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -21,7 +21,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, api_client: API, location_id: str, device_id: str - ): + ) -> None: """Initialize the device.""" self.hass: HomeAssistant = hass self.api_client: API = api_client @@ -42,7 +42,11 @@ async def _async_update_data(self): try: async with timeout(10): await asyncio.gather( - *[self._update_device(), self._update_consumption_data()] + *[ + self.send_presence_ping(), + self._update_device(), + self._update_consumption_data(), + ] ) except (RequestError) as error: raise UpdateFailed(error) from error @@ -188,6 +192,10 @@ def battery_level(self) -> float: """Return the battery level for battery-powered device, e.g. leak detectors.""" return self._device_information["battery"]["level"] + async def send_presence_ping(self): + """Send Flo a presence ping.""" + await self.api_client.presence.ping() + async def async_set_mode_home(self): """Set the Flo location to home mode.""" await self.api_client.location.set_mode_home(self._flo_location_id) diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 26aef603a2291..280f19dc57e4f 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -13,56 +13,40 @@ class FloEntity(Entity): """A base class for Flo entities.""" + _attr_force_update = False + _attr_should_poll = False + def __init__( self, entity_type: str, name: str, device: FloDeviceDataUpdateCoordinator, **kwargs, - ): + ) -> None: """Init Flo entity.""" - self._unique_id: str = f"{device.mac_address}_{entity_type}" - self._name: str = name + self._attr_name = name + self._attr_unique_id = f"{device.mac_address}_{entity_type}" + self._device: FloDeviceDataUpdateCoordinator = device self._state: Any = None - @property - def name(self) -> str: - """Return Entity's default name.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - @property def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" - return { - "identifiers": {(FLO_DOMAIN, self._device.id)}, - "connections": {(CONNECTION_NETWORK_MAC, self._device.mac_address)}, - "manufacturer": self._device.manufacturer, - "model": self._device.model, - "name": self._device.device_name, - "sw_version": self._device.firmware_version, - } + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._device.mac_address)}, + identifiers={(FLO_DOMAIN, self._device.id)}, + manufacturer=self._device.manufacturer, + model=self._device.model, + name=self._device.device_name, + sw_version=self._device.firmware_version, + ) @property def available(self) -> bool: """Return True if device is available.""" return self._device.available - @property - def force_update(self) -> bool: - """Force update this entity.""" - return False - - @property - def should_poll(self) -> bool: - """Poll state from device.""" - return False - async def async_update(self): """Update Flo entity.""" await self._device.async_request_refresh() diff --git a/homeassistant/components/flo/manifest.json b/homeassistant/components/flo/manifest.json index 11972f5056b8d..6d1e002012c4e 100644 --- a/homeassistant/components/flo/manifest.json +++ b/homeassistant/components/flo/manifest.json @@ -3,7 +3,7 @@ "name": "Flo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flo", - "requirements": ["aioflo==0.4.1"], + "requirements": ["aioflo==2021.11.0"], "codeowners": ["@dmulcahey"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 1e362e75f8cbf..c98f99f74ce8b 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -1,12 +1,8 @@ """Support for Flo Water Monitor sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRESSURE_PSI, TEMP_FAHRENHEIT, @@ -60,28 +56,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FloDailyUsageSensor(FloEntity, SensorEntity): """Monitors the daily water usage.""" + _attr_icon = WATER_ICON + _attr_native_unit_of_measurement = VOLUME_GALLONS + def __init__(self, device): """Initialize the daily water usage sensor.""" super().__init__("daily_consumption", NAME_DAILY_USAGE, device) self._state: float = None @property - def icon(self) -> str: - """Return the daily usage icon.""" - return WATER_ICON - - @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current daily usage.""" if self._device.consumption_today is None: return None return round(self._device.consumption_today, 1) - @property - def unit_of_measurement(self) -> str: - """Return gallons as the unit measurement for water.""" - return VOLUME_GALLONS - class FloSystemModeSensor(FloEntity, SensorEntity): """Monitors the current Flo system mode.""" @@ -92,7 +81,7 @@ def __init__(self, device): self._state: str = None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the current system mode.""" if not self._device.current_system_mode: return None @@ -102,126 +91,91 @@ def state(self) -> str | None: class FloCurrentFlowRateSensor(FloEntity, SensorEntity): """Monitors the current water flow rate.""" + _attr_icon = GAUGE_ICON + _attr_native_unit_of_measurement = "gpm" + def __init__(self, device): """Initialize the flow rate sensor.""" super().__init__("current_flow_rate", NAME_FLOW_RATE, device) self._state: float = None @property - def icon(self) -> str: - """Return the daily usage icon.""" - return GAUGE_ICON - - @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current flow rate.""" if self._device.current_flow_rate is None: return None return round(self._device.current_flow_rate, 1) - @property - def unit_of_measurement(self) -> str: - """Return the unit measurement.""" - return "gpm" - class FloTemperatureSensor(FloEntity, SensorEntity): """Monitors the temperature.""" + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_FAHRENHEIT + def __init__(self, name, device): """Initialize the temperature sensor.""" super().__init__("temperature", name, device) self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current temperature.""" if self._device.temperature is None: return None return round(self._device.temperature, 1) - @property - def unit_of_measurement(self) -> str: - """Return fahrenheit as the unit measurement for temperature.""" - return TEMP_FAHRENHEIT - - @property - def device_class(self) -> str | None: - """Return the device class for this sensor.""" - return DEVICE_CLASS_TEMPERATURE - class FloHumiditySensor(FloEntity, SensorEntity): """Monitors the humidity.""" + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, device): """Initialize the humidity sensor.""" super().__init__("humidity", NAME_HUMIDITY, device) self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current humidity.""" if self._device.humidity is None: return None return round(self._device.humidity, 1) - @property - def unit_of_measurement(self) -> str: - """Return percent as the unit measurement for humidity.""" - return PERCENTAGE - - @property - def device_class(self) -> str | None: - """Return the device class for this sensor.""" - return DEVICE_CLASS_HUMIDITY - class FloPressureSensor(FloEntity, SensorEntity): """Monitors the water pressure.""" + _attr_device_class = SensorDeviceClass.PRESSURE + _attr_native_unit_of_measurement = PRESSURE_PSI + def __init__(self, device): """Initialize the pressure sensor.""" super().__init__("water_pressure", NAME_WATER_PRESSURE, device) self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current water pressure.""" if self._device.current_psi is None: return None return round(self._device.current_psi, 1) - @property - def unit_of_measurement(self) -> str: - """Return gallons as the unit measurement for water.""" - return PRESSURE_PSI - - @property - def device_class(self) -> str | None: - """Return the device class for this sensor.""" - return DEVICE_CLASS_PRESSURE - class FloBatterySensor(FloEntity, SensorEntity): """Monitors the battery level for battery-powered leak detectors.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, device): """Initialize the battery sensor.""" super().__init__("battery", NAME_BATTERY, device) self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current battery level.""" return self._device.battery_level - - @property - def unit_of_measurement(self) -> str: - """Return percentage as the unit measurement for battery.""" - return PERCENTAGE - - @property - def device_class(self) -> str | None: - """Return the device class for this sensor.""" - return DEVICE_CLASS_BATTERY diff --git a/homeassistant/components/flo/services.yaml b/homeassistant/components/flo/services.yaml index b5797020ac0e6..fb3dbb3ee0a7b 100644 --- a/homeassistant/components/flo/services.yaml +++ b/homeassistant/components/flo/services.yaml @@ -1,32 +1,50 @@ # Describes the format for available Flo services set_sleep_mode: + name: Set sleep mode description: Set the location into sleep mode. + target: + entity: + integration: flo + domain: switch fields: - entity_id: - description: Flo switch entity id - example: "switch.shutoff_valve" sleep_minutes: + name: Sleep minutes description: The time to sleep in minutes. - example: 120 + default: true + selector: + select: + options: + - '120' + - '1440' + - '4320' revert_to_mode: + name: Revert to mode description: The mode to revert to after sleep_minutes has elapsed. - example: "home" + default: true + selector: + select: + options: + - 'away' + - 'home' set_away_mode: + name: Set away mode description: Set the location into away mode. - fields: - entity_id: - description: Flo switch entity id - example: "switch.shutoff_valve" + target: + entity: + integration: flo + domain: switch set_home_mode: + name: Set home mode description: Set the location into home mode. - fields: - entity_id: - description: Flo switch entity id - example: "switch.shutoff_valve" + target: + entity: + integration: flo + domain: switch run_health_test: + name: Run health test description: Have the Flo device run a health test. - fields: - entity_id: - description: Flo switch entity id - example: "switch.shutoff_valve" + target: + entity: + integration: flo + domain: switch diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index ce9b48d1421c1..15bbbc78c6994 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FloSwitch(FloEntity, SwitchEntity): """Switch class for the Flo by Moen valve.""" - def __init__(self, device: FloDeviceDataUpdateCoordinator): + def __init__(self, device: FloDeviceDataUpdateCoordinator) -> None: """Initialize the Flo switch.""" super().__init__("shutoff_valve", "Shutoff Valve", device) self._state = self._device.last_known_valve_state == "open" diff --git a/homeassistant/components/flo/translations/bg.json b/homeassistant/components/flo/translations/bg.json new file mode 100644 index 0000000000000..7b92255e7c966 --- /dev/null +++ b/homeassistant/components/flo/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/he.json b/homeassistant/components/flo/translations/he.json new file mode 100644 index 0000000000000..479d2f2f5e809 --- /dev/null +++ b/homeassistant/components/flo/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/hu.json b/homeassistant/components/flo/translations/hu.json index 0abcc301f0c85..9590d3c12bed6 100644 --- a/homeassistant/components/flo/translations/hu.json +++ b/homeassistant/components/flo/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/flo/translations/ja.json b/homeassistant/components/flo/translations/ja.json new file mode 100644 index 0000000000000..a9d2ddfd3ac5a --- /dev/null +++ b/homeassistant/components/flo/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/tr.json b/homeassistant/components/flo/translations/tr.json index 40c9c39b96772..fb81a4118239d 100644 --- a/homeassistant/components/flo/translations/tr.json +++ b/homeassistant/components/flo/translations/tr.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 7bdd1b33c5b2b..ee89937599aa0 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -1,12 +1,13 @@ """Flock platform for notify component.""" import asyncio +from http import HTTPStatus import logging import async_timeout import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_OK +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -40,11 +41,11 @@ async def async_send_message(self, message, **kwargs): _LOGGER.debug("Attempting to call Flock at %s", self._url) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await self._session.post(self._url, json=payload) result = await response.json() - if response.status != HTTP_OK or "error" in result: + if response.status != HTTPStatus.OK or "error" in result: _LOGGER.error( "Flock service returned HTTP status %d, response %s", response.status, diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 9bdc918be9c17..3ca99a335f231 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -47,13 +47,13 @@ def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): flume_devices = FlumeDeviceList(flume_auth, http_session=http_session) except RequestException as ex: raise ConfigEntryNotReady from ex - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: raise ConfigEntryAuthFailed from ex return flume_auth, flume_devices, http_session -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up flume from a config entry.""" flume_auth, flume_devices, http_session = await hass.async_add_executor_job( @@ -71,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index 1bab8817dbbe3..6d9554f42c008 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -103,10 +103,6 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, user_input): - """Handle import.""" - return await self.async_step_user(user_input) - async def async_step_reauth(self, user_input=None): """Handle reauth.""" self._reauth_unique_id = self.context["unique_id"] diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index a7bb9fbd3c8a4..95236829bd905 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -1,20 +1,53 @@ """The Flume component.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import Platform + DOMAIN = "flume" -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "Flume Sensor" FLUME_TYPE_SENSOR = 2 -FLUME_QUERIES_SENSOR = { - "current_interval": {"friendly_name": "Current", "unit_of_measurement": "gal/m"}, - "month_to_date": {"friendly_name": "Current Month", "unit_of_measurement": "gal"}, - "week_to_date": {"friendly_name": "Current Week", "unit_of_measurement": "gal"}, - "today": {"friendly_name": "Current Day", "unit_of_measurement": "gal"}, - "last_60_min": {"friendly_name": "60 Minutes", "unit_of_measurement": "gal/h"}, - "last_24_hrs": {"friendly_name": "24 Hours", "unit_of_measurement": "gal/d"}, - "last_30_days": {"friendly_name": "30 Days", "unit_of_measurement": "gal/mo"}, -} +FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_interval", + name="Current", + native_unit_of_measurement="gal/m", + ), + SensorEntityDescription( + key="month_to_date", + name="Current Month", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="week_to_date", + name="Current Week", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="today", + name="Current Day", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="last_60_min", + name="60 Minutes", + native_unit_of_measurement="gal/h", + ), + SensorEntityDescription( + key="last_24_hrs", + name="24 Hours", + native_unit_of_measurement="gal/d", + ), + SensorEntityDescription( + key="last_30_days", + name="30 Days", + native_unit_of_measurement="gal/mo", + ), +) FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index d689f5fb17f81..cdad0dd3f0c39 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -2,7 +2,7 @@ "domain": "flume", "name": "Flume", "documentation": "https://www.home-assistant.io/integrations/flume/", - "requirements": ["pyflume==0.5.5"], + "requirements": ["pyflume==0.6.5"], "codeowners": ["@ChrisMandich", "@bdraco"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index d890443d238ed..3f7b0f671fe8e 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -4,18 +4,10 @@ from numbers import Number from pyflume import FlumeData -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, -) -import homeassistant.helpers.config_validation as cv + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -42,25 +34,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) SCAN_INTERVAL = timedelta(minutes=1) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import the platform into a config entry.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Flume sensor.""" @@ -93,16 +66,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = _create_flume_device_coordinator(hass, flume_device) - for flume_query_sensor in FLUME_QUERIES_SENSOR.items(): - flume_entity_list.append( + flume_entity_list.extend( + [ FlumeSensor( coordinator, flume_device, - flume_query_sensor, - f"{device_friendly_name} {flume_query_sensor[1]['friendly_name']}", + device_friendly_name, device_id, + description, ) - ) + for description in FLUME_QUERIES_SENSOR + ] + ) if flume_entity_list: async_add_entities(flume_entity_list) @@ -111,50 +86,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FlumeSensor(CoordinatorEntity, SensorEntity): """Representation of the Flume sensor.""" - def __init__(self, coordinator, flume_device, flume_query_sensor, name, device_id): + def __init__( + self, + coordinator, + flume_device, + name, + device_id, + description: SensorEntityDescription, + ): """Initialize the Flume sensor.""" super().__init__(coordinator) + self.entity_description = description self._flume_device = flume_device - self._flume_query_sensor = flume_query_sensor - self._name = name - self._device_id = device_id - self._state = None - @property - def device_info(self): - """Device info for the flume sensor.""" - return { - "name": self._name, - "identifiers": {(DOMAIN, self._device_id)}, - "manufacturer": "Flume, Inc.", - "model": "Flume Smart Water Monitor", - } - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{description.key}_{device_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer="Flume, Inc.", + model="Flume Smart Water Monitor", + name=self.name, + ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - sensor_key = self._flume_query_sensor[0] + sensor_key = self.entity_description.key if sensor_key not in self._flume_device.values: return None return _format_state_value(self._flume_device.values[sensor_key]) - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - # This is in gallons per SCAN_INTERVAL - return self._flume_query_sensor[1]["unit_of_measurement"] - - @property - def unique_id(self): - """Flume query and Device unique ID.""" - return f"{self._flume_query_sensor[0]}_{self._device_id}" - async def async_added_to_hass(self): """Request an update when added.""" await super().async_added_to_hass() diff --git a/homeassistant/components/flume/translations/bg.json b/homeassistant/components/flume/translations/bg.json new file mode 100644 index 0000000000000..6eca91e8ed24a --- /dev/null +++ b/homeassistant/components/flume/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/ca.json b/homeassistant/components/flume/translations/ca.json index 04a7accf4a5a0..5cd81a00a67d1 100644 --- a/homeassistant/components/flume/translations/ca.json +++ b/homeassistant/components/flume/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/flume/translations/da.json b/homeassistant/components/flume/translations/da.json new file mode 100644 index 0000000000000..7155813152d41 --- /dev/null +++ b/homeassistant/components/flume/translations/da.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "client_id": "Klient-ID" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/de.json b/homeassistant/components/flume/translations/de.json index e229427ea6f60..574763f24cc52 100644 --- a/homeassistant/components/flume/translations/de.json +++ b/homeassistant/components/flume/translations/de.json @@ -13,7 +13,9 @@ "reauth_confirm": { "data": { "password": "Passwort" - } + }, + "description": "Das Passwort f\u00fcr {username} ist nicht mehr g\u00fcltig.", + "title": "Authentifiziere dein Flume-Konto erneut" }, "user": { "data": { @@ -22,8 +24,8 @@ "password": "Passwort", "username": "Benutzername" }, - "description": "Um auf die Flume Personal API zugreifen zu k\u00f6nnen, m\u00fcssen Sie unter https://portal.flumetech.com/settings#token eine 'Client ID' und 'Client Secret' anfordern", - "title": "Stellen Sie eine Verbindung zu Ihrem Flume-Konto her" + "description": "Um auf die Flume Personal API zugreifen zu k\u00f6nnen, musst du unter https://portal.flumetech.com/settings#token eine 'Client ID' und 'Client Secret' anfordern", + "title": "Stelle eine Verbindung zu deinem Flume-Konto her" } } } diff --git a/homeassistant/components/flume/translations/es-419.json b/homeassistant/components/flume/translations/es-419.json index 026875846c697..4b63e326d7f75 100644 --- a/homeassistant/components/flume/translations/es-419.json +++ b/homeassistant/components/flume/translations/es-419.json @@ -9,6 +9,10 @@ "unknown": "Error inesperado" }, "step": { + "reauth_confirm": { + "description": "La contrase\u00f1a de {username} ya no es v\u00e1lida.", + "title": "Vuelva a autenticar su cuenta de Flume" + }, "user": { "data": { "client_id": "Identificaci\u00f3n del cliente", diff --git a/homeassistant/components/flume/translations/fr.json b/homeassistant/components/flume/translations/fr.json index a111d66b9379b..43157234effaf 100644 --- a/homeassistant/components/flume/translations/fr.json +++ b/homeassistant/components/flume/translations/fr.json @@ -1,11 +1,12 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/flume/translations/he.json b/homeassistant/components/flume/translations/he.json index ac90b3264eab3..0dec935b9a264 100644 --- a/homeassistant/components/flume/translations/he.json +++ b/homeassistant/components/flume/translations/he.json @@ -1,6 +1,21 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "description": "\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e2\u05d1\u05d5\u05e8 {username} \u05d0\u05d9\u05e0\u05d4 \u05d7\u05d5\u05e7\u05d9\u05ea \u05e2\u05d5\u05d3." + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/flume/translations/hu.json b/homeassistant/components/flume/translations/hu.json index cc0c820facf29..d1cab31095d70 100644 --- a/homeassistant/components/flume/translations/hu.json +++ b/homeassistant/components/flume/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -9,11 +10,22 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "{username} jelszava m\u00e1r nem \u00e9rv\u00e9nyes.", + "title": "Hiteles\u00edtse \u00fajra Flume-fi\u00f3kj\u00e1t" + }, "user": { "data": { + "client_id": "\u00dcgyf\u00e9lazonos\u00edt\u00f3", + "client_secret": "\u00dcgyf\u00e9l jelszva", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A Flume Personal API el\u00e9r\u00e9s\u00e9hez \u201e\u00dcgyf\u00e9l-azonos\u00edt\u00f3t\u201d \u00e9s \u201e\u00dcgyf\u00e9ltitkot\u201d kell k\u00e9rnie a https://portal.flumetech.com/settings#token c\u00edmen.", + "title": "Csatlakozzon a Flume-fi\u00f3kj\u00e1hoz" } } } diff --git a/homeassistant/components/flume/translations/id.json b/homeassistant/components/flume/translations/id.json index 333afb167e6e8..9aea48afae07d 100644 --- a/homeassistant/components/flume/translations/id.json +++ b/homeassistant/components/flume/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -9,6 +10,13 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Kata sandi untuk {username} tidak lagi berlaku.", + "title": "Autentikasi ulang Akun Flume Anda" + }, "user": { "data": { "client_id": "ID Klien", diff --git a/homeassistant/components/flume/translations/it.json b/homeassistant/components/flume/translations/it.json index 3fdca3a5cb4df..ecf59ad8b06bc 100644 --- a/homeassistant/components/flume/translations/it.json +++ b/homeassistant/components/flume/translations/it.json @@ -15,7 +15,7 @@ "password": "Password" }, "description": "La password per {username} non \u00e8 pi\u00f9 valida.", - "title": "Riautentica il tuo account Flume" + "title": "Autentica nuovamente il tuo account Flume" }, "user": { "data": { diff --git a/homeassistant/components/flume/translations/ja.json b/homeassistant/components/flume/translations/ja.json new file mode 100644 index 0000000000000..f476c0fd35fc2 --- /dev/null +++ b/homeassistant/components/flume/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u7121\u52b9\u306b\u306a\u308a\u307e\u3057\u305f\u3002", + "title": "Flume\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "client_id": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8ID", + "client_secret": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u30b7\u30fc\u30af\u30ec\u30c3\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Flume Personal API\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u306b\u306f\u3001https://portal.flumetech.com/settings#token \u3067\u3001'Client ID' \u3068 'Client Secret' \u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "Flume\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/pl.json b/homeassistant/components/flume/translations/pl.json index f84513f1cb51b..9ea3a63508905 100644 --- a/homeassistant/components/flume/translations/pl.json +++ b/homeassistant/components/flume/translations/pl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", @@ -9,6 +10,13 @@ "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + }, + "description": "Has\u0142o u\u017cytkownika {username} nie jest ju\u017c wa\u017cne.", + "title": "Ponownie uwierzytelnij konto Flume" + }, "user": { "data": { "client_id": "Identyfikator klienta", diff --git a/homeassistant/components/flume/translations/tr.json b/homeassistant/components/flume/translations/tr.json index a83e1936fb4a1..dbdd21a3d4953 100644 --- a/homeassistant/components/flume/translations/tr.json +++ b/homeassistant/components/flume/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -9,11 +10,22 @@ "unknown": "Beklenmeyen hata" }, "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7in \u015fifre art\u0131k ge\u00e7erli de\u011fil.", + "title": "Flume Hesab\u0131n\u0131z\u0131 Yeniden Do\u011frulay\u0131n" + }, "user": { "data": { + "client_id": "\u0130stemci Kimli\u011fi", + "client_secret": "\u0130stemci Anahtar\u0131", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Flume Ki\u015fisel API'sine eri\u015fmek i\u00e7in https://portal.flumetech.com/settings#token adresinden bir 'M\u00fc\u015fteri Kimli\u011fi' ve '\u0130stemci Anahtar\u0131' talep etmeniz gerekir.", + "title": "Flume Hesab\u0131n\u0131za ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/flume/translations/zh-Hans.json b/homeassistant/components/flume/translations/zh-Hans.json index a5f4ff11f09ef..db06c3cf23ad5 100644 --- a/homeassistant/components/flume/translations/zh-Hans.json +++ b/homeassistant/components/flume/translations/zh-Hans.json @@ -1,6 +1,12 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "{username} \u7684\u5bc6\u7801\u5df2\u5931\u6548\u3002" + }, "user": { "data": { "username": "\u7528\u6237\u540d" diff --git a/homeassistant/components/flume/translations/zh-Hant.json b/homeassistant/components/flume/translations/zh-Hant.json index 9aae3792609f0..6e5a3e540bb50 100644 --- a/homeassistant/components/flume/translations/zh-Hant.json +++ b/homeassistant/components/flume/translations/zh-Hant.json @@ -20,11 +20,11 @@ "user": { "data": { "client_id": "\u5ba2\u6236\u7aef ID", - "client_secret": "\u5ba2\u6236\u7aef\u5bc6\u9470", + "client_secret": "\u5ba2\u6236\u7aef\u79c1\u9470", "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u6b32\u5b58\u53d6 Flume \u500b\u4eba API\u3001\u5c07\u9700\u8981\u65bc https://portal.flumetech.com/settings#token \u7372\u5f97\u5ba2\u6236\u7aef ID\uff08Client ID\u300f\u53ca\u5ba2\u6236\u7aef\u5bc6\u9470\uff08Client Secret\uff09", + "description": "\u6b32\u5b58\u53d6 Flume \u500b\u4eba API\u3001\u5c07\u9700\u8981\u65bc https://portal.flumetech.com/settings#token \u7372\u5f97\u5ba2\u6236\u7aef ID\uff08Client ID\u300f\u53ca\u5ba2\u6236\u7aef\u79c1\u9470\uff08Client Secret\uff09", "title": "\u9023\u7dda\u81f3 Flume \u5e33\u865f" } } diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py index 6eb4d54fe4fb3..bb07e9ccc7303 100644 --- a/homeassistant/components/flunearyou/__init__.py +++ b/homeassistant/components/flunearyou/__init__.py @@ -1,62 +1,58 @@ """The flunearyou component.""" +from __future__ import annotations + import asyncio from datetime import timedelta from functools import partial +from typing import Any from pyflunearyou import Client from pyflunearyou.errors import FluNearYouError -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - CATEGORY_CDC_REPORT, - CATEGORY_USER_REPORT, - DATA_COORDINATOR, - DOMAIN, - LOGGER, -) +from .const import CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT, DOMAIN, LOGGER DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) -CONFIG_SCHEMA = cv.deprecated(DOMAIN) - -PLATFORMS = ["sensor"] +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +PLATFORMS = [Platform.SENSOR] -async def async_setup(hass, config): - """Set up the Flu Near You component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}} - return True - -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flu Near You as config entry.""" - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {} - websession = aiohttp_client.async_get_clientsession(hass) - client = Client(websession) + client = Client(session=websession) latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) - async def async_update(api_category): + async def async_update(api_category: str) -> dict[str, Any]: """Get updated date from the API based on category.""" try: if api_category == CATEGORY_CDC_REPORT: - return await client.cdc_reports.status_by_coordinates( + data = await client.cdc_reports.status_by_coordinates( + latitude, longitude + ) + else: + data = await client.user_reports.status_by_coordinates( latitude, longitude ) - return await client.user_reports.status_by_coordinates(latitude, longitude) except FluNearYouError as err: raise UpdateFailed(err) from err + return data + + coordinators = {} data_init_tasks = [] - for api_category in [CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT]: - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - api_category - ] = DataUpdateCoordinator( + + for api_category in (CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT): + coordinator = coordinators[api_category] = DataUpdateCoordinator( hass, LOGGER, name=f"{api_category} ({latitude}, {longitude})", @@ -66,16 +62,18 @@ async def async_update(api_category): data_init_tasks.append(coordinator.async_refresh()) await asyncio.gather(*data_init_tasks) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinators hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an Flu Near You config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/flunearyou/config_flow.py b/homeassistant/components/flunearyou/config_flow.py index a63b6484a61af..0005e0c257ab9 100644 --- a/homeassistant/components/flunearyou/config_flow.py +++ b/homeassistant/components/flunearyou/config_flow.py @@ -1,10 +1,15 @@ """Define a config flow manager for flunearyou.""" +from __future__ import annotations + +from typing import Any + from pyflunearyou import Client from pyflunearyou.errors import FluNearYouError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import DOMAIN, LOGGER @@ -16,7 +21,7 @@ class FluNearYouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 @property - def data_schema(self): + def data_schema(self) -> vol.Schema: """Return the data schema for integration.""" return vol.Schema( { @@ -29,7 +34,9 @@ def data_schema(self): } ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form(step_id="user", data_schema=self.data_schema) @@ -40,7 +47,7 @@ async def async_step_user(self, user_input=None): self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) - client = Client(websession) + client = Client(session=websession) try: await client.cdc_reports.status_by_coordinates( diff --git a/homeassistant/components/flunearyou/const.py b/homeassistant/components/flunearyou/const.py index 96df29aa300db..dc9ac629d92d9 100644 --- a/homeassistant/components/flunearyou/const.py +++ b/homeassistant/components/flunearyou/const.py @@ -4,7 +4,5 @@ DOMAIN = "flunearyou" LOGGER = logging.getLogger(__package__) -DATA_COORDINATOR = "coordinator" - CATEGORY_CDC_REPORT = "cdc_report" CATEGORY_USER_REPORT = "user_report" diff --git a/homeassistant/components/flunearyou/manifest.json b/homeassistant/components/flunearyou/manifest.json index 71f0b49771e82..5fd3eb6638fe4 100644 --- a/homeassistant/components/flunearyou/manifest.json +++ b/homeassistant/components/flunearyou/manifest.json @@ -3,7 +3,7 @@ "name": "Flu Near You", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flunearyou", - "requirements": ["pyflunearyou==1.0.7"], + "requirements": ["pyflunearyou==2.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 066126c390e71..8a232d3d103d7 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,15 +1,25 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_STATE, - CONF_LATITUDE, - CONF_LONGITUDE, +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Union, cast + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_STATE, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) -from homeassistant.core import callback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT, DATA_COORDINATOR, DOMAIN +from .const import CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT, DOMAIN ATTR_CITY = "city" ATTR_REPORTED_DATE = "reported_date" @@ -19,8 +29,6 @@ ATTR_STATE_REPORTS_THIS_WEEK = "state_reports_this_week" ATTR_ZIP_CODE = "zip_code" -DEFAULT_ATTRIBUTION = "Data provided by Flu Near You" - SENSOR_TYPE_CDC_LEVEL = "level" SENSOR_TYPE_CDC_LEVEL2 = "level2" SENSOR_TYPE_USER_CHICK = "chick" @@ -31,20 +39,70 @@ SENSOR_TYPE_USER_SYMPTOMS = "symptoms" SENSOR_TYPE_USER_TOTAL = "total" -CDC_SENSORS = [ - (SENSOR_TYPE_CDC_LEVEL, "CDC Level", "mdi:biohazard", None), - (SENSOR_TYPE_CDC_LEVEL2, "CDC Level 2", "mdi:biohazard", None), -] - -USER_SENSORS = [ - (SENSOR_TYPE_USER_CHICK, "Avian Flu Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_DENGUE, "Dengue Fever Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_FLU, "Flu Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_LEPTO, "Leptospirosis Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_NO_SYMPTOMS, "No Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_SYMPTOMS, "Flu-like Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_TOTAL, "Total Symptoms", "mdi:alert", "reports"), -] +CDC_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_CDC_LEVEL, + name="CDC Level", + icon="mdi:biohazard", + ), + SensorEntityDescription( + key=SENSOR_TYPE_CDC_LEVEL2, + name="CDC Level 2", + icon="mdi:biohazard", + ), +) + +USER_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_USER_CHICK, + name="Avian Flu Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_DENGUE, + name="Dengue Fever Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_FLU, + name="Flu Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_LEPTO, + name="Leptospirosis Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_NO_SYMPTOMS, + name="No Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_SYMPTOMS, + name="Flu-like Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_TOTAL, + name="Total Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + state_class=SensorStateClass.MEASUREMENT, + ), +) EXTENDED_SENSOR_TYPE_MAPPING = { SENSOR_TYPE_USER_FLU: "ili", @@ -53,148 +111,97 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Flu Near You sensors based on a config entry.""" - coordinators = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] - - sensors = [] - - for (sensor_type, name, icon, unit) in CDC_SENSORS: - sensors.append( - CdcSensor( - coordinators[CATEGORY_CDC_REPORT], - config_entry, - sensor_type, - name, - icon, - unit, - ) - ) - - for (sensor_type, name, icon, unit) in USER_SENSORS: - sensors.append( - UserSensor( - coordinators[CATEGORY_USER_REPORT], - config_entry, - sensor_type, - name, - icon, - unit, - ) - ) - + coordinators = hass.data[DOMAIN][entry.entry_id] + + sensors: list[CdcSensor | UserSensor] = [ + CdcSensor(coordinators[CATEGORY_CDC_REPORT], entry, description) + for description in CDC_SENSOR_DESCRIPTIONS + ] + sensors.extend( + [ + UserSensor(coordinators[CATEGORY_USER_REPORT], entry, description) + for description in USER_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Define a base Flu Near You sensor.""" - def __init__(self, coordinator, config_entry, sensor_type, name, icon, unit): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._config_entry = config_entry - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._state = None - self._unit = unit - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def name(self): - """Return the name.""" - return self._name - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return ( - f"{self._config_entry.data[CONF_LATITUDE]}," - f"{self._config_entry.data[CONF_LONGITUDE]}_{self._sensor_type}" + self._attr_unique_id = ( + f"{entry.data[CONF_LATITUDE]}," + f"{entry.data[CONF_LONGITUDE]}_{description.key}" ) - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Register callbacks.""" - await super().async_added_to_hass() - self.update_from_latest_data() - - @callback - def update_from_latest_data(self): - """Update the sensor.""" - raise NotImplementedError + self._entry = entry + self.entity_description = description class CdcSensor(FluNearYouSensor): """Define a sensor for CDC reports.""" - @callback - def update_from_latest_data(self): - """Update the sensor.""" - self._attrs.update( - { - ATTR_REPORTED_DATE: self.coordinator.data["week_date"], - ATTR_STATE: self.coordinator.data["name"], - } + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + return { + ATTR_REPORTED_DATE: self.coordinator.data["week_date"], + ATTR_STATE: self.coordinator.data["name"], + } + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return cast( + Union[str, None], self.coordinator.data[self.entity_description.key] ) - self._state = self.coordinator.data[self._sensor_type] class UserSensor(FluNearYouSensor): """Define a sensor for user reports.""" - @callback - def update_from_latest_data(self): - """Update the sensor.""" - self._attrs.update( - { - ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], - ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], - ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], - ATTR_STATE: self.coordinator.data["state"]["name"], - ATTR_ZIP_CODE: self.coordinator.data["local"]["zip"], - } - ) - - if self._sensor_type in self.coordinator.data["state"]["data"]: - states_key = self._sensor_type - elif self._sensor_type in EXTENDED_SENSOR_TYPE_MAPPING: - states_key = EXTENDED_SENSOR_TYPE_MAPPING[self._sensor_type] - - self._attrs[ATTR_STATE_REPORTS_THIS_WEEK] = self.coordinator.data["state"][ - "data" - ][states_key] - self._attrs[ATTR_STATE_REPORTS_LAST_WEEK] = self.coordinator.data["state"][ + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attrs = { + ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], + ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], + ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], + ATTR_STATE: self.coordinator.data["state"]["name"], + ATTR_ZIP_CODE: self.coordinator.data["local"]["zip"], + } + + if self.entity_description.key in self.coordinator.data["state"]["data"]: + states_key = self.entity_description.key + elif self.entity_description.key in EXTENDED_SENSOR_TYPE_MAPPING: + states_key = EXTENDED_SENSOR_TYPE_MAPPING[self.entity_description.key] + + attrs[ATTR_STATE_REPORTS_THIS_WEEK] = self.coordinator.data["state"]["data"][ + states_key + ] + attrs[ATTR_STATE_REPORTS_LAST_WEEK] = self.coordinator.data["state"][ "last_week_data" ][states_key] - if self._sensor_type == SENSOR_TYPE_USER_TOTAL: - self._state = sum( + return attrs + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if self.entity_description.key == SENSOR_TYPE_USER_TOTAL: + value = sum( v for k, v in self.coordinator.data["local"].items() if k @@ -207,4 +214,6 @@ def update_from_latest_data(self): ) ) else: - self._state = self.coordinator.data["local"][self._sensor_type] + value = self.coordinator.data["local"][self.entity_description.key] + + return cast(int, value) diff --git a/homeassistant/components/flunearyou/translations/de.json b/homeassistant/components/flunearyou/translations/de.json index 585923fb2bf16..61dd2cd4ce7a8 100644 --- a/homeassistant/components/flunearyou/translations/de.json +++ b/homeassistant/components/flunearyou/translations/de.json @@ -12,8 +12,8 @@ "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" }, - "description": "\u00dcberwachen Sie benutzerbasierte und CDC-Berichte f\u00fcr ein Koordinatenpaar.", - "title": "Konfigurieren Sie die Grippe in Ihrer N\u00e4he" + "description": "\u00dcberwache benutzerbasierte und CDC-Berichte f\u00fcr ein Koordinatenpaar.", + "title": "Konfiguriere Grippe in deiner N\u00e4he" } } } diff --git a/homeassistant/components/flunearyou/translations/fi.json b/homeassistant/components/flunearyou/translations/fi.json new file mode 100644 index 0000000000000..b751fda5e4c71 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/fi.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Odottamaton virhe" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/fr.json b/homeassistant/components/flunearyou/translations/fr.json index bd1cc30ca5bd1..a9d8064d86516 100644 --- a/homeassistant/components/flunearyou/translations/fr.json +++ b/homeassistant/components/flunearyou/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "unknown": "Erreur inattendue" diff --git a/homeassistant/components/flunearyou/translations/he.json b/homeassistant/components/flunearyou/translations/he.json index 4c49313d97741..02a79d5fbcc89 100644 --- a/homeassistant/components/flunearyou/translations/he.json +++ b/homeassistant/components/flunearyou/translations/he.json @@ -1,8 +1,15 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, "step": { "user": { "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" } } diff --git a/homeassistant/components/flunearyou/translations/hu.json b/homeassistant/components/flunearyou/translations/hu.json index 4f8cca2a93946..a67bc91a2a1ed 100644 --- a/homeassistant/components/flunearyou/translations/hu.json +++ b/homeassistant/components/flunearyou/translations/hu.json @@ -11,7 +11,9 @@ "data": { "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" - } + }, + "description": "Figyelje a felhaszn\u00e1l\u00f3alap\u00fa \u00e9s a CDC jelent\u00e9seket egy p\u00e1r koordin\u00e1t\u00e1ra.", + "title": "Flu Near You weboldal konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/flunearyou/translations/ja.json b/homeassistant/components/flunearyou/translations/ja.json new file mode 100644 index 0000000000000..23df88d984b47 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + }, + "description": "\u30e6\u30fc\u30b6\u30fc\u30d9\u30fc\u30b9\u306e\u30ec\u30dd\u30fc\u30c8\u3068CDC\u306e\u30ec\u30dd\u30fc\u30c8\u3092\u30da\u30a2\u306b\u3057\u3066\u5ea7\u6a19\u3067\u30e2\u30cb\u30bf\u30fc\u3057\u307e\u3059\u3002", + "title": "\u8fd1\u304f\u306eFlu\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/tr.json b/homeassistant/components/flunearyou/translations/tr.json index 6e749e3c8270f..3a21364502ee7 100644 --- a/homeassistant/components/flunearyou/translations/tr.json +++ b/homeassistant/components/flunearyou/translations/tr.json @@ -11,7 +11,9 @@ "data": { "latitude": "Enlem", "longitude": "Boylam" - } + }, + "description": "Bir \u00e7ift koordinat i\u00e7in kullan\u0131c\u0131 tabanl\u0131 raporlar\u0131 ve CDC raporlar\u0131n\u0131 izleyin.", + "title": "Flu Near You'yu Yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 572d6e3c9833c..81fb9a1ce248f 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -1 +1,138 @@ -"""The flux_led component.""" +"""The Flux LED/MagicLight integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any, Final, cast + +from flux_led import DeviceType +from flux_led.aio import AIOWifiLedBulb +from flux_led.const import ATTR_ID +from flux_led.scanner import FluxLEDDiscovery + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType + +from .const import ( + DISCOVER_SCAN_TIMEOUT, + DOMAIN, + FLUX_LED_DISCOVERY, + FLUX_LED_EXCEPTIONS, + SIGNAL_STATE_UPDATED, + STARTUP_SCAN_TIMEOUT, +) +from .coordinator import FluxLedUpdateCoordinator +from .discovery import ( + async_clear_discovery_cache, + async_discover_device, + async_discover_devices, + async_get_discovery, + async_trigger_discovery, + async_update_entry_from_discovery, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS_BY_TYPE: Final = { + DeviceType.Bulb: [ + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SWITCH, + ], + DeviceType.Switch: [Platform.BUTTON, Platform.SELECT, Platform.SWITCH], +} +DISCOVERY_INTERVAL: Final = timedelta(minutes=15) +REQUEST_REFRESH_DELAY: Final = 1.5 + + +@callback +def async_wifi_bulb_for_host( + host: str, discovery: FluxLEDDiscovery | None +) -> AIOWifiLedBulb: + """Create a AIOWifiLedBulb from a host.""" + return AIOWifiLedBulb(host, discovery=discovery) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the flux_led component.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data[FLUX_LED_DISCOVERY] = await async_discover_devices( + hass, STARTUP_SCAN_TIMEOUT + ) + + async def _async_discovery(*_: Any) -> None: + async_trigger_discovery( + hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) + ) + + async_trigger_discovery(hass, domain_data[FLUX_LED_DISCOVERY]) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) + async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flux LED/MagicLight from a config entry.""" + host = entry.data[CONF_HOST] + directed_discovery = None + if discovery := async_get_discovery(hass, host): + directed_discovery = False + device: AIOWifiLedBulb = async_wifi_bulb_for_host(host, discovery=discovery) + signal = SIGNAL_STATE_UPDATED.format(device.ipaddr) + + @callback + def _async_state_changed(*_: Any) -> None: + _LOGGER.debug("%s: Device state updated: %s", device.ipaddr, device.raw_state) + async_dispatcher_send(hass, signal) + + try: + await device.async_setup(_async_state_changed) + except FLUX_LED_EXCEPTIONS as ex: + raise ConfigEntryNotReady( + str(ex) or f"Timed out trying to connect to {device.ipaddr}" + ) from ex + + # UDP probe after successful connect only + if not discovery and (discovery := await async_discover_device(hass, host)): + directed_discovery = True + + if discovery: + if entry.unique_id: + assert discovery[ATTR_ID] is not None + mac = dr.format_mac(cast(str, discovery[ATTR_ID])) + if mac != entry.unique_id: + # The device is offline and another flux_led device is now using the ip address + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; Expected {entry.unique_id}, found {mac}" + ) + if directed_discovery: + # Only update the entry once we have verified the unique id + # is either missing or we have verified it matches + async_update_entry_from_discovery(hass, entry, discovery) + device.discovery = discovery + + coordinator = FluxLedUpdateCoordinator(hass, device, entry) + hass.data[DOMAIN][entry.entry_id] = coordinator + platforms = PLATFORMS_BY_TYPE[device.device_type] + hass.config_entries.async_setup_platforms(entry, platforms) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + device: AIOWifiLedBulb = hass.data[DOMAIN][entry.entry_id].device + platforms = PLATFORMS_BY_TYPE[device.device_type] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): + # Make sure we probe the device again in case something has changed externally + async_clear_discovery_cache(hass, entry.data[CONF_HOST]) + del hass.data[DOMAIN][entry.entry_id] + await device.async_stop() + return unload_ok diff --git a/homeassistant/components/flux_led/button.py b/homeassistant/components/flux_led/button.py new file mode 100644 index 0000000000000..c0600e0db6a00 --- /dev/null +++ b/homeassistant/components/flux_led/button.py @@ -0,0 +1,47 @@ +"""Support for Magic home button.""" +from __future__ import annotations + +from flux_led.aio import AIOWifiLedBulb + +from homeassistant import config_entries +from homeassistant.components.button import ButtonEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import FluxLedUpdateCoordinator +from .entity import FluxBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Magic Home button based on a config entry.""" + coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([FluxRestartButton(coordinator.device, entry)]) + + +class FluxRestartButton(FluxBaseEntity, ButtonEntity): + """Representation of a Flux restart button.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + device: AIOWifiLedBulb, + entry: config_entries.ConfigEntry, + ) -> None: + """Initialize the reboot button.""" + super().__init__(device, entry) + self._attr_name = f"{entry.data[CONF_NAME]} Restart" + if entry.unique_id: + self._attr_unique_id = f"{entry.unique_id}_restart" + + async def async_press(self) -> None: + """Send out a restart command.""" + await self._device.async_reboot() diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py new file mode 100644 index 0000000000000..101e3a55d1784 --- /dev/null +++ b/homeassistant/components/flux_led/config_flow.py @@ -0,0 +1,287 @@ +"""Config flow for Flux LED/MagicLight.""" +from __future__ import annotations + +from typing import Any, Final, cast + +from flux_led.const import ATTR_ID, ATTR_IPADDR, ATTR_MODEL, ATTR_MODEL_DESCRIPTION +from flux_led.scanner import FluxLEDDiscovery +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import async_wifi_bulb_for_host +from .const import ( + CONF_CUSTOM_EFFECT_COLORS, + CONF_CUSTOM_EFFECT_SPEED_PCT, + CONF_CUSTOM_EFFECT_TRANSITION, + DEFAULT_EFFECT_SPEED, + DISCOVER_SCAN_TIMEOUT, + DOMAIN, + FLUX_LED_EXCEPTIONS, + TRANSITION_GRADUAL, + TRANSITION_JUMP, + TRANSITION_STROBE, +) +from .discovery import ( + async_discover_device, + async_discover_devices, + async_name_from_discovery, + async_populate_data_from_discovery, + async_update_entry_from_discovery, +) + +CONF_DEVICE: Final = "device" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Magic Home Integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, FluxLEDDiscovery] = {} + self._discovered_device: FluxLEDDiscovery | None = None + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> OptionsFlow: + """Get the options flow for the Flux LED component.""" + return OptionsFlow(config_entry) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle discovery via dhcp.""" + self._discovered_device = FluxLEDDiscovery( + ipaddr=discovery_info.ip, + model=None, + id=discovery_info.macaddress.replace(":", ""), + model_num=None, + version_num=None, + firmware_date=None, + model_info=None, + model_description=None, + remote_access_enabled=None, + remote_access_host=None, + remote_access_port=None, + ) + return await self._async_handle_discovery() + + async def async_step_discovery( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle discovery.""" + self._discovered_device = cast(FluxLEDDiscovery, discovery_info) + return await self._async_handle_discovery() + + async def _async_handle_discovery(self) -> FlowResult: + """Handle any discovery.""" + device = self._discovered_device + assert device is not None + mac_address = device[ATTR_ID] + assert mac_address is not None + mac = dr.format_mac(mac_address) + host = device[ATTR_IPADDR] + await self.async_set_unique_id(mac) + for entry in self._async_current_entries(include_ignore=False): + if entry.unique_id == mac or entry.data[CONF_HOST] == host: + if async_update_entry_from_discovery(self.hass, entry, device): + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + return self.async_abort(reason="already_configured") + self.context[CONF_HOST] = host + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == host: + return self.async_abort(reason="already_in_progress") + if not device[ATTR_MODEL_DESCRIPTION]: + try: + device = await self._async_try_connect( + host, device[ATTR_ID], device[ATTR_MODEL] + ) + except FLUX_LED_EXCEPTIONS: + return self.async_abort(reason="cannot_connect") + else: + if device[ATTR_MODEL_DESCRIPTION]: + self._discovered_device = device + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + device = self._discovered_device + mac_address = device[ATTR_ID] + assert mac_address is not None + if user_input is not None: + return self._async_create_entry_from_device(self._discovered_device) + + self._set_confirm_only() + placeholders = { + "model": device[ATTR_MODEL_DESCRIPTION] or device[ATTR_MODEL], + "id": mac_address[-6:], + "ipaddr": device[ATTR_IPADDR], + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_confirm", description_placeholders=placeholders + ) + + @callback + def _async_create_entry_from_device(self, device: FluxLEDDiscovery) -> FlowResult: + """Create a config entry from a device.""" + self._async_abort_entries_match({CONF_HOST: device[ATTR_IPADDR]}) + name = async_name_from_discovery(device) + data: dict[str, Any] = { + CONF_HOST: device[ATTR_IPADDR], + CONF_NAME: name, + } + async_populate_data_from_discovery(data, data, device) + return self.async_create_entry( + title=name, + data=data, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + if not (host := user_input[CONF_HOST]): + return await self.async_step_pick_device() + try: + device = await self._async_try_connect(host, None, None) + except FLUX_LED_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + mac_address = device[ATTR_ID] + if mac_address is not None: + await self.async_set_unique_id( + dr.format_mac(mac_address), raise_on_progress=False + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + return self._async_create_entry_from_device(device) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Optional(CONF_HOST, default=""): str}), + errors=errors, + ) + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the step to pick discovered device.""" + if user_input is not None: + mac = user_input[CONF_DEVICE] + await self.async_set_unique_id(mac, raise_on_progress=False) + return self._async_create_entry_from_device(self._discovered_devices[mac]) + + current_unique_ids = self._async_current_ids() + current_hosts = { + entry.data[CONF_HOST] + for entry in self._async_current_entries(include_ignore=False) + } + discovered_devices = await async_discover_devices( + self.hass, DISCOVER_SCAN_TIMEOUT + ) + self._discovered_devices = {} + for device in discovered_devices: + mac_address = device[ATTR_ID] + assert mac_address is not None + self._discovered_devices[dr.format_mac(mac_address)] = device + devices_name = { + mac: f"{async_name_from_discovery(device)} ({device[ATTR_IPADDR]})" + for mac, device in self._discovered_devices.items() + if mac not in current_unique_ids + and device[ATTR_IPADDR] not in current_hosts + } + # Check if there is at least one device + if not devices_name: + return self.async_abort(reason="no_devices_found") + return self.async_show_form( + step_id="pick_device", + data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), + ) + + async def _async_try_connect( + self, host: str, mac_address: str | None, model: str | None + ) -> FluxLEDDiscovery: + """Try to connect.""" + self._async_abort_entries_match({CONF_HOST: host}) + if (device := await async_discover_device(self.hass, host)) and device[ + ATTR_MODEL_DESCRIPTION + ]: + # Older models do not return enough information + # to build the model description via UDP so we have + # to fallback to making a tcp connection to avoid + # identifying the device as the chip model number + # AKA `HF-LPB100-ZJ200` + return device + bulb = async_wifi_bulb_for_host(host, discovery=device) + try: + await bulb.async_setup(lambda: None) + finally: + await bulb.async_stop() + return FluxLEDDiscovery( + ipaddr=host, + model=model, + id=mac_address, + model_num=bulb.model_num, + version_num=None, # This is the minor version number + firmware_date=None, + model_info=None, + model_description=bulb.model_data.description, + remote_access_enabled=None, + remote_access_host=None, + remote_access_port=None, + ) + + +class OptionsFlow(config_entries.OptionsFlow): + """Handle flux_led options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize the flux_led options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Configure the options.""" + errors: dict[str, str] = {} + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = self._config_entry.options + options_schema = vol.Schema( + { + vol.Optional( + CONF_CUSTOM_EFFECT_COLORS, + default=options.get(CONF_CUSTOM_EFFECT_COLORS, ""), + ): str, + vol.Optional( + CONF_CUSTOM_EFFECT_SPEED_PCT, + default=options.get( + CONF_CUSTOM_EFFECT_SPEED_PCT, DEFAULT_EFFECT_SPEED + ), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=100)), + vol.Optional( + CONF_CUSTOM_EFFECT_TRANSITION, + default=options.get( + CONF_CUSTOM_EFFECT_TRANSITION, TRANSITION_GRADUAL + ), + ): vol.In([TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE]), + } + ) + + return self.async_show_form( + step_id="init", data_schema=options_schema, errors=errors + ) diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py new file mode 100644 index 0000000000000..430c5a0e38a76 --- /dev/null +++ b/homeassistant/components/flux_led/const.py @@ -0,0 +1,73 @@ +"""Constants of the FluxLed/MagicHome Integration.""" + +import asyncio +import socket +from typing import Final + +from flux_led.const import ( + COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, + COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB, + COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, + COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW, +) + +from homeassistant.components.light import ( + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, +) + +DOMAIN: Final = "flux_led" + + +FLUX_COLOR_MODE_TO_HASS: Final = { + FLUX_COLOR_MODE_RGB: COLOR_MODE_RGB, + FLUX_COLOR_MODE_RGBW: COLOR_MODE_RGBW, + FLUX_COLOR_MODE_RGBWW: COLOR_MODE_RGBWW, + FLUX_COLOR_MODE_CCT: COLOR_MODE_COLOR_TEMP, +} + + +API: Final = "flux_api" + +SIGNAL_STATE_UPDATED = "flux_led_{}_state_updated" + +DEFAULT_NETWORK_SCAN_INTERVAL: Final = 120 +DEFAULT_SCAN_INTERVAL: Final = 5 +DEFAULT_EFFECT_SPEED: Final = 50 + +FLUX_LED_DISCOVERY: Final = "flux_led_discovery" + +FLUX_LED_EXCEPTIONS: Final = ( + asyncio.TimeoutError, + socket.error, + RuntimeError, + BrokenPipeError, +) + +STARTUP_SCAN_TIMEOUT: Final = 5 +DISCOVER_SCAN_TIMEOUT: Final = 10 + +CONF_MODEL: Final = "model" +CONF_MINOR_VERSION: Final = "minor_version" +CONF_REMOTE_ACCESS_ENABLED: Final = "remote_access_enabled" +CONF_REMOTE_ACCESS_HOST: Final = "remote_access_host" +CONF_REMOTE_ACCESS_PORT: Final = "remote_access_port" + +TRANSITION_GRADUAL: Final = "gradual" +TRANSITION_JUMP: Final = "jump" +TRANSITION_STROBE: Final = "strobe" + +CONF_COLORS: Final = "colors" +CONF_SPEED_PCT: Final = "speed_pct" +CONF_TRANSITION: Final = "transition" +CONF_EFFECT: Final = "effect" + + +EFFECT_SPEED_SUPPORT_MODES: Final = {COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW} + + +CONF_CUSTOM_EFFECT_COLORS: Final = "custom_effect_colors" +CONF_CUSTOM_EFFECT_SPEED_PCT: Final = "custom_effect_speed_pct" +CONF_CUSTOM_EFFECT_TRANSITION: Final = "custom_effect_transition" diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py new file mode 100644 index 0000000000000..dc2f9ca0fcef7 --- /dev/null +++ b/homeassistant/components/flux_led/coordinator.py @@ -0,0 +1,49 @@ +"""The Flux LED/MagicLight integration coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from flux_led.aio import AIOWifiLedBulb + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import FLUX_LED_EXCEPTIONS + +_LOGGER = logging.getLogger(__name__) + + +REQUEST_REFRESH_DELAY: Final = 1.5 + + +class FluxLedUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for a specific flux_led device.""" + + def __init__( + self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: ConfigEntry + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific device.""" + self.device = device + self.entry = entry + super().__init__( + hass, + _LOGGER, + name=self.device.ipaddr, + update_interval=timedelta(seconds=10), + # We don't want an immediate refresh since the device + # takes a moment to reflect the state change + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def _async_update_data(self) -> None: + """Fetch all device and sensor data from api.""" + try: + await self.device.async_update() + except FLUX_LED_EXCEPTIONS as ex: + raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/flux_led/discovery.py b/homeassistant/components/flux_led/discovery.py new file mode 100644 index 0000000000000..d707af8ac9e20 --- /dev/null +++ b/homeassistant/components/flux_led/discovery.py @@ -0,0 +1,180 @@ +"""The Flux LED/MagicLight integration discovery.""" +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +import logging +from typing import Any, Final + +from flux_led.aioscanner import AIOBulbScanner +from flux_led.const import ( + ATTR_ID, + ATTR_IPADDR, + ATTR_MODEL, + ATTR_MODEL_DESCRIPTION, + ATTR_REMOTE_ACCESS_ENABLED, + ATTR_REMOTE_ACCESS_HOST, + ATTR_REMOTE_ACCESS_PORT, + ATTR_VERSION_NUM, +) +from flux_led.scanner import FluxLEDDiscovery + +from homeassistant import config_entries +from homeassistant.components import network +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.util.network import is_ip_address + +from .const import ( + CONF_MINOR_VERSION, + CONF_MODEL, + CONF_REMOTE_ACCESS_ENABLED, + CONF_REMOTE_ACCESS_HOST, + CONF_REMOTE_ACCESS_PORT, + DISCOVER_SCAN_TIMEOUT, + DOMAIN, + FLUX_LED_DISCOVERY, +) + +_LOGGER = logging.getLogger(__name__) + + +CONF_TO_DISCOVERY: Final = { + CONF_HOST: ATTR_IPADDR, + CONF_REMOTE_ACCESS_ENABLED: ATTR_REMOTE_ACCESS_ENABLED, + CONF_REMOTE_ACCESS_HOST: ATTR_REMOTE_ACCESS_HOST, + CONF_REMOTE_ACCESS_PORT: ATTR_REMOTE_ACCESS_PORT, + CONF_MINOR_VERSION: ATTR_VERSION_NUM, + CONF_MODEL: ATTR_MODEL, +} + + +@callback +def async_name_from_discovery(device: FluxLEDDiscovery) -> str: + """Convert a flux_led discovery to a human readable name.""" + mac_address = device[ATTR_ID] + if mac_address is None: + return device[ATTR_IPADDR] + short_mac = mac_address[-6:] + if device[ATTR_MODEL_DESCRIPTION]: + return f"{device[ATTR_MODEL_DESCRIPTION]} {short_mac}" + return f"{device[ATTR_MODEL]} {short_mac}" + + +@callback +def async_populate_data_from_discovery( + current_data: Mapping[str, Any], + data_updates: dict[str, Any], + device: FluxLEDDiscovery, +) -> None: + """Copy discovery data into config entry data.""" + for conf_key, discovery_key in CONF_TO_DISCOVERY.items(): + if ( + device.get(discovery_key) is not None + and current_data.get(conf_key) != device[discovery_key] # type: ignore[misc] + ): + data_updates[conf_key] = device[discovery_key] # type: ignore[misc] + + +@callback +def async_update_entry_from_discovery( + hass: HomeAssistant, entry: config_entries.ConfigEntry, device: FluxLEDDiscovery +) -> bool: + """Update a config entry from a flux_led discovery.""" + data_updates: dict[str, Any] = {} + mac_address = device[ATTR_ID] + assert mac_address is not None + updates: dict[str, Any] = {} + if not entry.unique_id: + updates["unique_id"] = dr.format_mac(mac_address) + async_populate_data_from_discovery(entry.data, data_updates, device) + if not entry.data.get(CONF_NAME) or is_ip_address(entry.data[CONF_NAME]): + updates["title"] = data_updates[CONF_NAME] = async_name_from_discovery(device) + if data_updates: + updates["data"] = {**entry.data, **data_updates} + if updates: + return hass.config_entries.async_update_entry(entry, **updates) + return False + + +@callback +def async_get_discovery(hass: HomeAssistant, host: str) -> FluxLEDDiscovery | None: + """Check if a device was already discovered via a broadcast discovery.""" + discoveries: list[FluxLEDDiscovery] = hass.data[DOMAIN][FLUX_LED_DISCOVERY] + for discovery in discoveries: + if discovery[ATTR_IPADDR] == host: + return discovery + return None + + +@callback +def async_clear_discovery_cache(hass: HomeAssistant, host: str) -> None: + """Clear the host from the discovery cache.""" + domain_data = hass.data[DOMAIN] + discoveries: list[FluxLEDDiscovery] = domain_data[FLUX_LED_DISCOVERY] + domain_data[FLUX_LED_DISCOVERY] = [ + discovery for discovery in discoveries if discovery[ATTR_IPADDR] != host + ] + + +async def async_discover_devices( + hass: HomeAssistant, timeout: int, address: str | None = None +) -> list[FluxLEDDiscovery]: + """Discover flux led devices.""" + if address: + targets = [address] + else: + targets = [ + str(address) + for address in await network.async_get_ipv4_broadcast_addresses(hass) + ] + + scanner = AIOBulbScanner() + for idx, discovered in enumerate( + await asyncio.gather( + *[ + scanner.async_scan(timeout=timeout, address=address) + for address in targets + ], + return_exceptions=True, + ) + ): + if isinstance(discovered, Exception): + _LOGGER.debug("Scanning %s failed with error: %s", targets[idx], discovered) + continue + + if not address: + return scanner.getBulbInfo() + + return [ + device for device in scanner.getBulbInfo() if device[ATTR_IPADDR] == address + ] + + +async def async_discover_device( + hass: HomeAssistant, host: str +) -> FluxLEDDiscovery | None: + """Direct discovery at a single ip instead of broadcast.""" + # If we are missing the unique_id we should be able to fetch it + # from the device by doing a directed discovery at the host only + for device in await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT, host): + if device[ATTR_IPADDR] == host: + return device + return None + + +@callback +def async_trigger_discovery( + hass: HomeAssistant, + discovered_devices: list[FluxLEDDiscovery], +) -> None: + """Trigger config flows for discovered devices.""" + for device in discovered_devices: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={**device}, + ) + ) diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py new file mode 100644 index 0000000000000..6296f11b21aa8 --- /dev/null +++ b/homeassistant/components/flux_led/entity.py @@ -0,0 +1,130 @@ +"""Support for Magic Home lights.""" +from __future__ import annotations + +from abc import abstractmethod +from typing import Any + +from flux_led.aiodevice import AIOWifiLedBulb + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_MINOR_VERSION, CONF_MODEL, SIGNAL_STATE_UPDATED +from .coordinator import FluxLedUpdateCoordinator + + +def _async_device_info( + unique_id: str, device: AIOWifiLedBulb, entry: config_entries.ConfigEntry +) -> DeviceInfo: + version_num = device.version_num + if minor_version := entry.data.get(CONF_MINOR_VERSION): + sw_version = version_num + int(hex(minor_version)[2:]) / 100 + sw_version_str = f"{sw_version:0.3f}" + else: + sw_version_str = str(device.version_num) + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, unique_id)}, + manufacturer="Zengge", + model=device.model, + name=entry.data[CONF_NAME], + sw_version=sw_version_str, + hw_version=entry.data.get(CONF_MODEL), + ) + + +class FluxBaseEntity(Entity): + """Representation of a Flux entity without a coordinator.""" + + def __init__( + self, + device: AIOWifiLedBulb, + entry: config_entries.ConfigEntry, + ) -> None: + """Initialize the light.""" + self._device: AIOWifiLedBulb = device + self.entry = entry + if entry.unique_id: + self._attr_device_info = _async_device_info( + entry.unique_id, self._device, entry + ) + + +class FluxEntity(CoordinatorEntity): + """Representation of a Flux entity with a coordinator.""" + + coordinator: FluxLedUpdateCoordinator + + def __init__( + self, + coordinator: FluxLedUpdateCoordinator, + unique_id: str | None, + name: str, + ) -> None: + """Initialize the light.""" + super().__init__(coordinator) + self._device: AIOWifiLedBulb = coordinator.device + self._responding = True + self._attr_name = name + self._attr_unique_id = unique_id + if unique_id: + self._attr_device_info = _async_device_info( + unique_id, self._device, coordinator.entry + ) + + async def _async_ensure_device_on(self) -> None: + """Turn the device on if it needs to be turned on before a command.""" + if self._device.requires_turn_on and not self._device.is_on: + await self._device.async_turn_on() + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the attributes.""" + return {"ip_address": self._device.ipaddr} + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.last_update_success != self._responding: + self.async_write_ha_state() + self._responding = self.coordinator.last_update_success + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_STATE_UPDATED.format(self._device.ipaddr), + self.async_write_ha_state, + ) + ) + await super().async_added_to_hass() + + +class FluxOnOffEntity(FluxEntity): + """Representation of a Flux entity that supports on/off.""" + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the specified device on.""" + await self._async_turn_on(**kwargs) + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + @abstractmethod + async def _async_turn_on(self, **kwargs: Any) -> None: + """Turn the specified device on.""" + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the specified device off.""" + await self._device.async_turn_off() + self.async_write_ha_state() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 2f8d2cc553694..a3c54365071a0 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -1,374 +1,383 @@ -"""Support for Flux lights.""" -import logging -import random +"""Support for Magic Home lights.""" +from __future__ import annotations -from flux_led import BulbScanner, WifiLedBulb +import ast +import logging +from typing import Any, Final + +from flux_led.const import MultiColorEffects +from flux_led.protocol import MusicMode +from flux_led.utils import ( + color_temp_to_white_levels, + rgbcw_brightness, + rgbcw_to_rgbwc, + rgbw_brightness, +) import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, - ATTR_HS_COLOR, - ATTR_WHITE_VALUE, - EFFECT_COLORLOOP, - EFFECT_RANDOM, - PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_WHITE, + COLOR_MODE_RGBWW, SUPPORT_EFFECT, - SUPPORT_WHITE_VALUE, + SUPPORT_TRANSITION, LightEntity, ) -from homeassistant.const import ATTR_MODE, CONF_DEVICES, CONF_NAME, CONF_PROTOCOL +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -import homeassistant.util.color as color_util - -_LOGGER = logging.getLogger(__name__) - -CONF_AUTOMATIC_ADD = "automatic_add" -CONF_CUSTOM_EFFECT = "custom_effect" -CONF_COLORS = "colors" -CONF_SPEED_PCT = "speed_pct" -CONF_TRANSITION = "transition" +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) -DOMAIN = "flux_led" +from .const import ( + CONF_COLORS, + CONF_CUSTOM_EFFECT_COLORS, + CONF_CUSTOM_EFFECT_SPEED_PCT, + CONF_CUSTOM_EFFECT_TRANSITION, + CONF_EFFECT, + CONF_SPEED_PCT, + CONF_TRANSITION, + DEFAULT_EFFECT_SPEED, + DOMAIN, + TRANSITION_GRADUAL, + TRANSITION_JUMP, + TRANSITION_STROBE, +) +from .coordinator import FluxLedUpdateCoordinator +from .entity import FluxOnOffEntity +from .util import ( + _effect_brightness, + _flux_color_mode_to_hass, + _hass_color_modes, + _str_to_multi_color_effect, +) -SUPPORT_FLUX_LED = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_COLOR +_LOGGER = logging.getLogger(__name__) -MODE_RGB = "rgb" -MODE_RGBW = "rgbw" +MODE_ATTRS = { + ATTR_EFFECT, + ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_WHITE, +} -# This mode enables white value to be controlled by brightness. -# RGB value is ignored when this mode is specified. -MODE_WHITE = "w" +ATTR_FOREGROUND_COLOR: Final = "foreground_color" +ATTR_BACKGROUND_COLOR: Final = "background_color" +ATTR_SENSITIVITY: Final = "sensitivity" +ATTR_LIGHT_SCREEN: Final = "light_screen" # Constant color temp values for 2 flux_led special modes # Warm-white and Cool-white modes -COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF = 285 - -# List of supported effects which aren't already declared in LIGHT -EFFECT_RED_FADE = "red_fade" -EFFECT_GREEN_FADE = "green_fade" -EFFECT_BLUE_FADE = "blue_fade" -EFFECT_YELLOW_FADE = "yellow_fade" -EFFECT_CYAN_FADE = "cyan_fade" -EFFECT_PURPLE_FADE = "purple_fade" -EFFECT_WHITE_FADE = "white_fade" -EFFECT_RED_GREEN_CROSS_FADE = "rg_cross_fade" -EFFECT_RED_BLUE_CROSS_FADE = "rb_cross_fade" -EFFECT_GREEN_BLUE_CROSS_FADE = "gb_cross_fade" -EFFECT_COLORSTROBE = "colorstrobe" -EFFECT_RED_STROBE = "red_strobe" -EFFECT_GREEN_STROBE = "green_strobe" -EFFECT_BLUE_STROBE = "blue_strobe" -EFFECT_YELLOW_STROBE = "yellow_strobe" -EFFECT_CYAN_STROBE = "cyan_strobe" -EFFECT_PURPLE_STROBE = "purple_strobe" -EFFECT_WHITE_STROBE = "white_strobe" -EFFECT_COLORJUMP = "colorjump" -EFFECT_CUSTOM = "custom" - -EFFECT_MAP = { - EFFECT_COLORLOOP: 0x25, - EFFECT_RED_FADE: 0x26, - EFFECT_GREEN_FADE: 0x27, - EFFECT_BLUE_FADE: 0x28, - EFFECT_YELLOW_FADE: 0x29, - EFFECT_CYAN_FADE: 0x2A, - EFFECT_PURPLE_FADE: 0x2B, - EFFECT_WHITE_FADE: 0x2C, - EFFECT_RED_GREEN_CROSS_FADE: 0x2D, - EFFECT_RED_BLUE_CROSS_FADE: 0x2E, - EFFECT_GREEN_BLUE_CROSS_FADE: 0x2F, - EFFECT_COLORSTROBE: 0x30, - EFFECT_RED_STROBE: 0x31, - EFFECT_GREEN_STROBE: 0x32, - EFFECT_BLUE_STROBE: 0x33, - EFFECT_YELLOW_STROBE: 0x34, - EFFECT_CYAN_STROBE: 0x35, - EFFECT_PURPLE_STROBE: 0x36, - EFFECT_WHITE_STROBE: 0x37, - EFFECT_COLORJUMP: 0x38, +COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: Final = 285 + +EFFECT_CUSTOM: Final = "custom" + +SERVICE_CUSTOM_EFFECT: Final = "set_custom_effect" +SERVICE_SET_ZONES: Final = "set_zones" +SERVICE_SET_MUSIC_MODE: Final = "set_music_mode" + +CUSTOM_EFFECT_DICT: Final = { + vol.Required(CONF_COLORS): vol.All( + cv.ensure_list, + vol.Length(min=1, max=16), + [vol.All(vol.Coerce(tuple), vol.ExactSequence((cv.byte, cv.byte, cv.byte)))], + ), + vol.Optional(CONF_SPEED_PCT, default=50): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_TRANSITION, default=TRANSITION_GRADUAL): vol.All( + cv.string, vol.In([TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE]) + ), } -EFFECT_CUSTOM_CODE = 0x60 - -TRANSITION_GRADUAL = "gradual" -TRANSITION_JUMP = "jump" -TRANSITION_STROBE = "strobe" - -FLUX_EFFECT_LIST = sorted(EFFECT_MAP) + [EFFECT_RANDOM] - -CUSTOM_EFFECT_SCHEMA = vol.Schema( - { - vol.Required(CONF_COLORS): vol.All( - cv.ensure_list, - vol.Length(min=1, max=16), - [ - vol.All( - vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple) - ) - ], - ), - vol.Optional(CONF_SPEED_PCT, default=50): vol.All( - vol.Range(min=0, max=100), vol.Coerce(int) - ), - vol.Optional(CONF_TRANSITION, default=TRANSITION_GRADUAL): vol.All( - cv.string, vol.In([TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE]) - ), - } -) -DEVICE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(ATTR_MODE, default=MODE_RGBW): vol.All( - cv.string, vol.In([MODE_RGBW, MODE_RGB, MODE_WHITE]) - ), - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(["ledenet"])), - vol.Optional(CONF_CUSTOM_EFFECT): CUSTOM_EFFECT_SCHEMA, - } -) +SET_MUSIC_MODE_DICT: Final = { + vol.Optional(ATTR_SENSITIVITY, default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(ATTR_BRIGHTNESS, default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(ATTR_EFFECT, default=1): vol.All( + vol.Coerce(int), vol.Range(min=1, max=16) + ), + vol.Optional(ATTR_LIGHT_SCREEN, default=False): bool, + vol.Optional(ATTR_FOREGROUND_COLOR): vol.All( + vol.Coerce(tuple), vol.ExactSequence((cv.byte,) * 3) + ), + vol.Optional(ATTR_BACKGROUND_COLOR): vol.All( + vol.Coerce(tuple), vol.ExactSequence((cv.byte,) * 3) + ), +} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, - vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, - } -) +SET_ZONES_DICT: Final = { + vol.Required(CONF_COLORS): vol.All( + cv.ensure_list, + vol.Length(min=1, max=2048), + [vol.All(vol.Coerce(tuple), vol.ExactSequence((cv.byte, cv.byte, cv.byte)))], + ), + vol.Optional(CONF_SPEED_PCT, default=50): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_EFFECT, default=MultiColorEffects.STATIC.name.lower()): vol.All( + cv.string, vol.In([effect.name.lower() for effect in MultiColorEffects]) + ), +} -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Flux lights.""" - lights = [] - light_ips = [] - - for ipaddr, device_config in config.get(CONF_DEVICES, {}).items(): - device = {} - device["name"] = device_config[CONF_NAME] - device["ipaddr"] = ipaddr - device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL) - device[ATTR_MODE] = device_config[ATTR_MODE] - device[CONF_CUSTOM_EFFECT] = device_config.get(CONF_CUSTOM_EFFECT) - light = FluxLight(device) - lights.append(light) - light_ips.append(ipaddr) - - if not config.get(CONF_AUTOMATIC_ADD, False): - add_entities(lights, True) - return - - # Find the bulbs on the LAN - scanner = BulbScanner() - scanner.scan(timeout=10) - for device in scanner.getBulbInfo(): - ipaddr = device["ipaddr"] - if ipaddr in light_ips: - continue - device["name"] = f"{device['id']} {ipaddr}" - device[ATTR_MODE] = None - device[CONF_PROTOCOL] = None - device[CONF_CUSTOM_EFFECT] = None - light = FluxLight(device) - lights.append(light) - - add_entities(lights, True) - - -class FluxLight(LightEntity): - """Representation of a Flux light.""" - - def __init__(self, device): - """Initialize the light.""" - self._name = device["name"] - self._ipaddr = device["ipaddr"] - self._protocol = device[CONF_PROTOCOL] - self._mode = device[ATTR_MODE] - self._custom_effect = device[CONF_CUSTOM_EFFECT] - self._bulb = None - self._error_reported = False - - def _connect(self): - """Connect to Flux light.""" - - self._bulb = WifiLedBulb(self._ipaddr, timeout=5) - if self._protocol: - self._bulb.setProtocol(self._protocol) - - # After bulb object is created the status is updated. We can - # now set the correct mode if it was not explicitly defined. - if not self._mode: - if self._bulb.rgbwcapable: - self._mode = MODE_RGBW - else: - self._mode = MODE_RGB - - def _disconnect(self): - """Disconnect from Flux light.""" - self._bulb = None + coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_CUSTOM_EFFECT, + CUSTOM_EFFECT_DICT, + "async_set_custom_effect", + ) + platform.async_register_entity_service( + SERVICE_SET_ZONES, + SET_ZONES_DICT, + "async_set_zones", + ) + platform.async_register_entity_service( + SERVICE_SET_MUSIC_MODE, + SET_MUSIC_MODE_DICT, + "async_set_music_mode", + ) + options = entry.options + + try: + custom_effect_colors = ast.literal_eval( + options.get(CONF_CUSTOM_EFFECT_COLORS) or "[]" + ) + except (ValueError, TypeError, SyntaxError, MemoryError) as ex: + _LOGGER.warning( + "Could not parse custom effect colors for %s: %s", entry.unique_id, ex + ) + custom_effect_colors = [] + + async_add_entities( + [ + FluxLight( + coordinator, + entry.unique_id, + entry.data[CONF_NAME], + list(custom_effect_colors), + options.get(CONF_CUSTOM_EFFECT_SPEED_PCT, DEFAULT_EFFECT_SPEED), + options.get(CONF_CUSTOM_EFFECT_TRANSITION, TRANSITION_GRADUAL), + ) + ] + ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._bulb is not None - @property - def name(self): - """Return the name of the device if any.""" - return self._name +class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity): + """Representation of a Flux light.""" - @property - def is_on(self): - """Return true if device is on.""" - return self._bulb.isOn() + _attr_supported_features = SUPPORT_TRANSITION | SUPPORT_EFFECT + + def __init__( + self, + coordinator: FluxLedUpdateCoordinator, + unique_id: str | None, + name: str, + custom_effect_colors: list[tuple[int, int, int]], + custom_effect_speed_pct: int, + custom_effect_transition: str, + ) -> None: + """Initialize the light.""" + super().__init__(coordinator, unique_id, name) + self._attr_min_mireds = ( + color_temperature_kelvin_to_mired(self._device.max_temp) + 1 + ) # for rounding + self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp) + self._attr_supported_color_modes = _hass_color_modes(self._device) + custom_effects: list[str] = [] + if custom_effect_colors: + custom_effects.append(EFFECT_CUSTOM) + self._attr_effect_list = [*self._device.effect_list, *custom_effects] + self._custom_effect_colors = custom_effect_colors + self._custom_effect_speed_pct = custom_effect_speed_pct + self._custom_effect_transition = custom_effect_transition @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - if self._mode == MODE_WHITE: - return self.white_value - - return self._bulb.brightness + return self._device.brightness @property - def hs_color(self): - """Return the color property.""" - return color_util.color_RGB_to_hs(*self._bulb.getRgb()) + def color_temp(self) -> int: + """Return the kelvin value of this light in mired.""" + return color_temperature_kelvin_to_mired(self._device.color_temp) @property - def supported_features(self): - """Flag supported features.""" - if self._mode == MODE_RGBW: - return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE | SUPPORT_COLOR_TEMP - - if self._mode == MODE_WHITE: - return SUPPORT_BRIGHTNESS - - return SUPPORT_FLUX_LED + def rgb_color(self) -> tuple[int, int, int]: + """Return the rgb color value.""" + return self._device.rgb_unscaled @property - def white_value(self): - """Return the white value of this light between 0..255.""" - return self._bulb.getRgbw()[3] + def rgbw_color(self) -> tuple[int, int, int, int]: + """Return the rgbw color value.""" + return self._device.rgbw @property - def effect_list(self): - """Return the list of supported effects.""" - if self._custom_effect: - return FLUX_EFFECT_LIST + [EFFECT_CUSTOM] + def rgbww_color(self) -> tuple[int, int, int, int, int]: + """Return the rgbww aka rgbcw color value.""" + return self._device.rgbcw - return FLUX_EFFECT_LIST + @property + def color_mode(self) -> str: + """Return the color mode of the light.""" + return _flux_color_mode_to_hass( + self._device.color_mode, self._device.color_modes + ) @property - def effect(self): + def effect(self) -> str | None: """Return the current effect.""" - current_mode = self._bulb.raw_state[3] - - if current_mode == EFFECT_CUSTOM_CODE: - return EFFECT_CUSTOM + return self._device.effect - for effect, code in EFFECT_MAP.items(): - if current_mode == code: - return effect - - return None - - def turn_on(self, **kwargs): + async def _async_turn_on(self, **kwargs: Any) -> None: """Turn the specified or all lights on.""" - if not self.is_on: - self._bulb.turnOn() - - hs_color = kwargs.get(ATTR_HS_COLOR) - - if hs_color: - rgb = color_util.color_hs_to_RGB(*hs_color) - else: - rgb = None - - brightness = kwargs.get(ATTR_BRIGHTNESS) - effect = kwargs.get(ATTR_EFFECT) - white = kwargs.get(ATTR_WHITE_VALUE) - color_temp = kwargs.get(ATTR_COLOR_TEMP) - - # handle special modes - if color_temp is not None: - if brightness is None: - brightness = self.brightness - if color_temp > COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: - self._bulb.setRgbw(w=brightness) - else: - self._bulb.setRgbw(w2=brightness) - return - - # Show warning if effect set with rgb, brightness, or white level - if effect and (brightness or white or rgb): - _LOGGER.warning( - "RGB, brightness and white level are ignored when" - " an effect is specified for a flux bulb" - ) + if self._device.requires_turn_on or not kwargs: + if not self.is_on: + await self._device.async_turn_on() + if not kwargs: + return - # Random color effect - if effect == EFFECT_RANDOM: - self._bulb.setRgb( - random.randint(0, 255), random.randint(0, 255), random.randint(0, 255) - ) + if MODE_ATTRS.intersection(kwargs): + await self._async_set_mode(**kwargs) return + await self._device.async_set_brightness(self._async_brightness(**kwargs)) + async def _async_set_effect(self, effect: str, brightness: int) -> None: + """Set an effect.""" + # Custom effect if effect == EFFECT_CUSTOM: - if self._custom_effect: - self._bulb.setCustomPattern( - self._custom_effect[CONF_COLORS], - self._custom_effect[CONF_SPEED_PCT], - self._custom_effect[CONF_TRANSITION], + if self._custom_effect_colors: + await self._device.async_set_custom_pattern( + self._custom_effect_colors, + self._custom_effect_speed_pct, + self._custom_effect_transition, ) return - - # Effect selection - if effect in EFFECT_MAP: - self._bulb.setPresetPattern(EFFECT_MAP[effect], 50) - return - - # Preserve current brightness on color/white level change - if brightness is None: + await self._device.async_set_effect( + effect, + self._device.speed or DEFAULT_EFFECT_SPEED, + _effect_brightness(brightness), + ) + + @callback + def _async_brightness(self, **kwargs: Any) -> int: + """Determine brightness from kwargs or current value.""" + if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None: brightness = self.brightness - - # Preserve color on brightness/white level change - if rgb is None: - rgb = self._bulb.getRgb() - - if white is None and self._mode == MODE_RGBW: - white = self.white_value - - # handle W only mode (use brightness instead of white value) - if self._mode == MODE_WHITE: - self._bulb.setRgbw(0, 0, 0, w=brightness) - - # handle RGBW mode - elif self._mode == MODE_RGBW: - self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) - - # handle RGB mode - else: - self._bulb.setRgb(*tuple(rgb), brightness=brightness) - - def turn_off(self, **kwargs): - """Turn the specified or all lights off.""" - self._bulb.turnOff() - - def update(self): - """Synchronize state with bulb.""" - if not self.available: - try: - self._connect() - self._error_reported = False - except OSError: - self._disconnect() - if not self._error_reported: - _LOGGER.warning( - "Failed to connect to bulb %s, %s", self._ipaddr, self._name - ) - self._error_reported = True + if not brightness: + # If the brightness was previously 0, the light + # will not turn on unless brightness is at least 1 + # If the device was on and brightness was not + # set, it means it was masked by an effect + brightness = 255 if self.is_on else 1 + return brightness + + async def _async_set_mode(self, **kwargs: Any) -> None: + """Set an effect or color mode.""" + brightness = self._async_brightness(**kwargs) + # Handle switch to Effect Mode + if effect := kwargs.get(ATTR_EFFECT): + await self._async_set_effect(effect, brightness) + return + # Handle switch to CCT Color Mode + if color_temp_mired := kwargs.get(ATTR_COLOR_TEMP): + color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) + if self.color_mode != COLOR_MODE_RGBWW: + await self._device.async_set_white_temp(color_temp_kelvin, brightness) return - self._bulb.update_state(retry=2) + # When switching to color temp from RGBWW mode, + # we do not want the overall brightness, we only + # want the brightness of the white channels + brightness = kwargs.get( + ATTR_BRIGHTNESS, self._device.getWhiteTemperature()[1] + ) + channels = color_temp_to_white_levels(color_temp_kelvin, brightness) + warm = channels.warm_white + cold = channels.cool_white + await self._device.async_set_levels(r=0, b=0, g=0, w=warm, w2=cold) + return + # Handle switch to RGB Color Mode + if rgb := kwargs.get(ATTR_RGB_COLOR): + red, green, blue = rgb + await self._device.async_set_levels(red, green, blue, brightness=brightness) + return + # Handle switch to RGBW Color Mode + if rgbw := kwargs.get(ATTR_RGBW_COLOR): + if ATTR_BRIGHTNESS in kwargs: + rgbw = rgbw_brightness(rgbw, brightness) + await self._device.async_set_levels(*rgbw) + return + # Handle switch to RGBWW Color Mode + if rgbcw := kwargs.get(ATTR_RGBWW_COLOR): + if ATTR_BRIGHTNESS in kwargs: + rgbcw = rgbcw_brightness(kwargs[ATTR_RGBWW_COLOR], brightness) + await self._device.async_set_levels(*rgbcw_to_rgbwc(rgbcw)) + return + if (white := kwargs.get(ATTR_WHITE)) is not None: + await self._device.async_set_levels(w=white) + return + + async def async_set_custom_effect( + self, colors: list[tuple[int, int, int]], speed_pct: int, transition: str + ) -> None: + """Set a custom effect on the bulb.""" + await self._device.async_set_custom_pattern( + colors, + speed_pct, + transition, + ) + + async def async_set_zones( + self, colors: list[tuple[int, int, int]], speed_pct: int, effect: str + ) -> None: + """Set a colors for zones.""" + await self._device.async_set_zones( + colors, + speed_pct, + _str_to_multi_color_effect(effect), + ) + + async def async_set_music_mode( + self, + sensitivity: int, + brightness: int, + effect: int, + light_screen: bool, + foreground_color: tuple[int, int, int] | None = None, + background_color: tuple[int, int, int] | None = None, + ) -> None: + """Configure music mode.""" + await self._async_ensure_device_on() + await self._device.async_set_music_mode( + sensitivity=sensitivity, + brightness=brightness, + mode=MusicMode.LIGHT_SCREEN.value if light_screen else None, + effect=effect, + foreground_color=foreground_color, + background_color=background_color, + ) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 0c6d8ae8db161..ab8b6b64d2d3d 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -1,8 +1,49 @@ { "domain": "flux_led", - "name": "Flux LED/MagicLight", + "name": "Magic Home", + "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.22"], - "codeowners": [], - "iot_class": "local_polling" + "requirements": ["flux_led==0.27.13"], + "quality_scale": "platinum", + "codeowners": ["@icemanch"], + "iot_class": "local_push", + "dhcp": [ + { + "macaddress": "18B905*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "249494*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "7CB94C*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "ACCF23*", + "hostname": "[hba][flk]*" + }, + { + "macaddress": "B4E842*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "F0FE6B*", + "hostname": "[hba][flk]*" + }, + { + "macaddress": "8CCE4E*", + "hostname": "lwip*" + }, + { + "hostname": "zengge_[0-9a-f][0-9a-f]_*" + }, + { + "macaddress": "C82E47*", + "hostname": "sta*" + } + ] } + diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py new file mode 100644 index 0000000000000..b19e6b0e04866 --- /dev/null +++ b/homeassistant/components/flux_led/number.py @@ -0,0 +1,80 @@ +"""Support for LED numbers.""" +from __future__ import annotations + +from typing import cast + +from homeassistant import config_entries +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, EFFECT_SPEED_SUPPORT_MODES +from .coordinator import FluxLedUpdateCoordinator +from .entity import FluxEntity +from .util import _effect_brightness, _hass_color_modes + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Flux lights.""" + coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + color_modes = _hass_color_modes(coordinator.device) + if not color_modes.intersection(EFFECT_SPEED_SUPPORT_MODES): + return + + async_add_entities( + [ + FluxNumber( + coordinator, + entry.unique_id, + entry.data[CONF_NAME], + ) + ] + ) + + +class FluxNumber(FluxEntity, CoordinatorEntity, NumberEntity): + """Defines a flux_led speed number.""" + + _attr_min_value = 1 + _attr_max_value = 100 + _attr_step = 1 + _attr_mode = NumberMode.SLIDER + _attr_icon = "mdi:speedometer" + + def __init__( + self, + coordinator: FluxLedUpdateCoordinator, + unique_id: str | None, + name: str, + ) -> None: + """Initialize the flux number.""" + super().__init__(coordinator, unique_id, name) + self._attr_name = f"{name} Effect Speed" + + @property + def value(self) -> float: + """Return the effect speed.""" + return cast(float, self._device.speed) + + async def async_set_value(self, value: float) -> None: + """Set the flux speed value.""" + current_effect = self._device.effect + new_speed = int(value) + if not current_effect: + raise HomeAssistantError( + "Speed can only be adjusted when an effect is active" + ) + if not self._device.speed_adjust_off and not self._device.is_on: + raise HomeAssistantError("Speed can only be adjusted when the light is on") + await self._device.async_set_effect( + current_effect, new_speed, _effect_brightness(self._device.brightness) + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py new file mode 100644 index 0000000000000..92b5b936784e9 --- /dev/null +++ b/homeassistant/components/flux_led/select.py @@ -0,0 +1,67 @@ +"""Support for Magic Home select.""" +from __future__ import annotations + +from flux_led.aio import AIOWifiLedBulb +from flux_led.protocol import PowerRestoreState + +from homeassistant import config_entries +from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import FluxLedUpdateCoordinator +from .entity import FluxBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Flux selects.""" + coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([FluxPowerState(coordinator.device, entry)]) + + +def _human_readable_option(const_option: str) -> str: + return const_option.replace("_", " ").title() + + +class FluxPowerState(FluxBaseEntity, SelectEntity): + """Representation of a Flux power restore state option.""" + + _attr_should_poll = False + + def __init__( + self, + device: AIOWifiLedBulb, + entry: config_entries.ConfigEntry, + ) -> None: + """Initialize the power state select.""" + super().__init__(device, entry) + self._attr_entity_category = EntityCategory.CONFIG + self._attr_name = f"{entry.data[CONF_NAME]} Power Restored" + if entry.unique_id: + self._attr_unique_id = f"{entry.unique_id}_power_restored" + self._name_to_state = { + _human_readable_option(option.name): option for option in PowerRestoreState + } + self._attr_options = list(self._name_to_state) + self._async_set_current_option_from_device() + + @callback + def _async_set_current_option_from_device(self) -> None: + """Set the option from the current power state.""" + restore_states = self._device.power_restore_states + assert restore_states is not None + assert restore_states.channel1 is not None + self._attr_current_option = _human_readable_option(restore_states.channel1.name) + + async def async_select_option(self, option: str) -> None: + """Change the power state.""" + await self._device.async_set_power_restore(channel1=self._name_to_state[option]) + self._async_set_current_option_from_device() + self.async_write_ha_state() diff --git a/homeassistant/components/flux_led/services.yaml b/homeassistant/components/flux_led/services.yaml new file mode 100644 index 0000000000000..8a16f4563112e --- /dev/null +++ b/homeassistant/components/flux_led/services.yaml @@ -0,0 +1,136 @@ +set_custom_effect: + description: Set a custom light effect. + target: + entity: + integration: flux_led + domain: light + fields: + colors: + description: List of colors for the custom effect (RGB). (Max 16 Colors) + example: | + - [255,0,0] + - [0,255,0] + - [0,0,255] + required: true + selector: + object: + speed_pct: + description: Effect speed for the custom effect (0-100). + example: 80 + default: 50 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 + unit_of_measurement: "%" + transition: + description: Effect transition. + example: 'jump' + default: 'gradual' + required: false + selector: + select: + options: + - "gradual" + - "jump" + - "strobe" +set_zones: + description: Set strip zones for Addressable v3 controllers (0xA3). + target: + entity: + integration: flux_led + domain: light + fields: + colors: + description: List of colors for each zone (RGB). The length of each zone is the number of pixels per segment divided by the number of colors. (Max 2048 Colors) + example: | + - [255,0,0] + - [0,255,0] + - [0,0,255] + - [255,255,255] + required: true + selector: + object: + speed_pct: + description: Effect speed for the custom effect (0-100) + example: 80 + default: 50 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 + unit_of_measurement: "%" + effect: + description: Effect + example: 'running_water' + default: 'static' + required: false + selector: + select: + options: + - "static" + - "running_water" + - "strobe" + - "jump" + - "breathing" +set_music_mode: + description: Configure music mode on Controller RGB with MIC (0x08), Addressable v2 (0xA2), and Addressable v3 (0xA3) devices that have a built-in microphone. + target: + entity: + integration: flux_led + domain: light + fields: + sensitivity: + description: Microphone sensitivity (0-100) + example: 80 + default: 100 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 + unit_of_measurement: "%" + brightness: + description: Light brightness (0-100) + example: 80 + default: 100 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 + unit_of_measurement: "%" + light_screen: + description: Light screen mode for 2 dimensional pixels (Addressable models only) + default: false + required: false + selector: + boolean: + effect: + description: Effect (1-16 on Addressable models, 0-3 on RGB with MIC models) + example: 1 + default: 1 + required: false + selector: + number: + min: 0 + step: 1 + max: 16 + foreground_color: + description: The foreground RGB color + example: "[255, 100, 100]" + required: false + selector: + object: + background_color: + description: The background RGB color (Addressable models only) + example: "[255, 100, 100]" + required: false + selector: + object: diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json new file mode 100644 index 0000000000000..f311f55958988 --- /dev/null +++ b/homeassistant/components/flux_led/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "user": { + "description": "If you leave the host empty, discovery will be used to find devices.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {model} {id} ({ipaddr})?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "The chosen brightness mode.", + "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_transition": "Custom Effect: Type of transition between the colors." + } + } + } + } +} diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py new file mode 100644 index 0000000000000..8acb5e8861a66 --- /dev/null +++ b/homeassistant/components/flux_led/switch.py @@ -0,0 +1,148 @@ +"""Support for Magic Home switches.""" +from __future__ import annotations + +from typing import Any + +from flux_led import DeviceType +from flux_led.aio import AIOWifiLedBulb +from flux_led.const import MODE_MUSIC + +from homeassistant import config_entries +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_REMOTE_ACCESS_ENABLED, + CONF_REMOTE_ACCESS_HOST, + CONF_REMOTE_ACCESS_PORT, + DOMAIN, +) +from .coordinator import FluxLedUpdateCoordinator +from .discovery import async_clear_discovery_cache +from .entity import FluxBaseEntity, FluxEntity, FluxOnOffEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Flux lights.""" + coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = [] + unique_id = entry.unique_id + name = entry.data[CONF_NAME] + + if coordinator.device.device_type == DeviceType.Switch: + entities.append(FluxSwitch(coordinator, unique_id, name)) + + if entry.data.get(CONF_REMOTE_ACCESS_HOST): + entities.append(FluxRemoteAccessSwitch(coordinator.device, entry)) + + if coordinator.device.microphone: + entities.append(FluxMusicSwitch(coordinator, unique_id, name)) + + if entities: + async_add_entities(entities) + + +class FluxSwitch(FluxOnOffEntity, CoordinatorEntity, SwitchEntity): + """Representation of a Flux switch.""" + + async def _async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + if not self.is_on: + await self._device.async_turn_on() + + +class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity): + """Representation of a Flux remote access switch.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + device: AIOWifiLedBulb, + entry: config_entries.ConfigEntry, + ) -> None: + """Initialize the light.""" + super().__init__(device, entry) + self._attr_name = f"{entry.data[CONF_NAME]} Remote Access" + if entry.unique_id: + self._attr_unique_id = f"{entry.unique_id}_remote_access" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the remote access on.""" + await self._device.async_enable_remote_access( + self.entry.data[CONF_REMOTE_ACCESS_HOST], + self.entry.data[CONF_REMOTE_ACCESS_PORT], + ) + await self._async_update_entry(True) + + async def _async_update_entry(self, new_state: bool) -> None: + """Update the entry with the new state on success.""" + async_clear_discovery_cache(self.hass, self._device.ipaddr) + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONF_REMOTE_ACCESS_ENABLED: new_state}, + ) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the remote access off.""" + await self._device.async_disable_remote_access() + await self._async_update_entry(False) + + @property + def is_on(self) -> bool: + """Return true if remote access is enabled.""" + return bool(self.entry.data[CONF_REMOTE_ACCESS_ENABLED]) + + @property + def icon(self) -> str: + """Return icon based on state.""" + return "mdi:cloud-outline" if self.is_on else "mdi:cloud-off-outline" + + +class FluxMusicSwitch(FluxEntity, SwitchEntity): + """Representation of a Flux music switch.""" + + def __init__( + self, + coordinator: FluxLedUpdateCoordinator, + unique_id: str | None, + name: str, + ) -> None: + """Initialize the flux music switch.""" + super().__init__(coordinator, unique_id, name) + self._attr_name = f"{name} Music" + if unique_id: + self._attr_unique_id = f"{unique_id}_music" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the microphone on.""" + await self._async_ensure_device_on() + await self._device.async_set_music_mode() + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the microphone off.""" + await self._device.async_set_levels(*self._device.rgb, brightness=255) + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + @property + def is_on(self) -> bool: + """Return true if microphone is is on.""" + return self._device.is_on and self._device.effect == MODE_MUSIC + + @property + def icon(self) -> str: + """Return icon based on state.""" + return "mdi:microphone" if self.is_on else "mdi:microphone-off" diff --git a/homeassistant/components/flux_led/translations/bg.json b/homeassistant/components/flux_led/translations/bg.json new file mode 100644 index 0000000000000..462548016e5df --- /dev/null +++ b/homeassistant/components/flux_led/translations/bg.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0410\u043a\u043e \u043e\u0441\u0442\u0430\u0432\u0438\u0442\u0435 \u0445\u043e\u0441\u0442\u0430 \u043f\u0440\u0430\u0437\u0435\u043d, \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435\u0442\u043e \u0449\u0435 \u0441\u0435 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u0437\u0430 \u043d\u0430\u043c\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d \u0435\u0444\u0435\u043a\u0442: \u0421\u043f\u0438\u0441\u044a\u043a \u043e\u0442 1 \u0434\u043e 16 [R,G,B] \u0446\u0432\u044f\u0442\u0430. \u041f\u0440\u0438\u043c\u0435\u0440: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d \u0435\u0444\u0435\u043a\u0442: \u0421\u043a\u043e\u0440\u043e\u0441\u0442 \u0432 \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u0438 \u0437\u0430 \u0435\u0444\u0435\u043a\u0442\u0430, \u043a\u043e\u0439\u0442\u043e \u043f\u0440\u0435\u0432\u043a\u043b\u044e\u0447\u0432\u0430 \u0446\u0432\u0435\u0442\u043e\u0432\u0435\u0442\u0435.", + "custom_effect_transition": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d \u0435\u0444\u0435\u043a\u0442: \u0422\u0438\u043f \u043f\u0440\u0435\u0445\u043e\u0434 \u043c\u0435\u0436\u0434\u0443 \u0446\u0432\u0435\u0442\u043e\u0432\u0435\u0442\u0435.", + "mode": "\u0418\u0437\u0431\u0440\u0430\u043d\u0438\u044f\u0442 \u0440\u0435\u0436\u0438\u043c \u043d\u0430 \u044f\u0440\u043a\u043e\u0441\u0442." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/ca.json b/homeassistant/components/flux_led/translations/ca.json new file mode 100644 index 0000000000000..25314edc1b812 --- /dev/null +++ b/homeassistant/components/flux_led/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'han trobat dispositius a la xarxa" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Vols configurar {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Si deixes l'amfitri\u00f3 buit, s'utilitzar\u00e0 el descobriment per cercar dispositius." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efecte personalitzat: llista d'1 a 16 colors [R,G,B]. Exemple: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efecte personalitzat: velocitat en percentatges de l'efecte de canvi de color.", + "custom_effect_transition": "Efecte personalitzat: tipus de transici\u00f3 entre colors.", + "mode": "Mode de brillantor escollit." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/cs.json b/homeassistant/components/flux_led/translations/cs.json new file mode 100644 index 0000000000000..542a503a36020 --- /dev/null +++ b/homeassistant/components/flux_led/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1", + "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/de.json b/homeassistant/components/flux_led/translations/de.json new file mode 100644 index 0000000000000..f036e7bd91322 --- /dev/null +++ b/homeassistant/components/flux_led/translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "M\u00f6chtest du {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Wenn du den Host leer l\u00e4sst, wird die Erkennung verwendet, um Ger\u00e4te zu finden." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Benutzerdefinierter Effekt: Liste mit 1 bis 16 [R,G,B]-Farben. Beispiel: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Benutzerdefinierter Effekt: Geschwindigkeit in Prozent f\u00fcr den Effekt, der die Farbe wechselt.", + "custom_effect_transition": "Benutzerdefinierter Effekt: Art des \u00dcbergangs zwischen den Farben.", + "mode": "Der gew\u00e4hlte Helligkeitsmodus." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/en.json b/homeassistant/components/flux_led/translations/en.json new file mode 100644 index 0000000000000..9a988408c304c --- /dev/null +++ b/homeassistant/components/flux_led/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Do you want to setup {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "If you leave the host empty, discovery will be used to find devices." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_transition": "Custom Effect: Type of transition between the colors.", + "mode": "The chosen brightness mode." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/et.json b/homeassistant/components/flux_led/translations/et.json new file mode 100644 index 0000000000000..0c2e1f444cbfa --- /dev/null +++ b/homeassistant/components/flux_led/translations/et.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Kas seadistada {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Kui j\u00e4tad hosti t\u00fchjaks kasutatakse seadmete leidmiseks avastamist." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Kohandatud efekt: Loetelu 1 kuni 16 [R,G,B] v\u00e4rvist. N\u00e4ide: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Kohandatud efekt: v\u00e4rvide vahetamise efekti kiirus protsentides.", + "custom_effect_transition": "Kohandatud efekt: v\u00e4rvide vahelise \u00fclemineku t\u00fc\u00fcp.", + "mode": "Valitud heleduse re\u017eiim." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/fr.json b/homeassistant/components/flux_led/translations/fr.json new file mode 100644 index 0000000000000..c2177a0cb1f21 --- /dev/null +++ b/homeassistant/components/flux_led/translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "flow_title": "{model} {id} ( {ipaddr} )", + "step": { + "discovery_confirm": { + "description": "Voulez-vous configurer {model} {id} ( {ipaddr} )\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Si vous laissez l'h\u00f4te vide, la d\u00e9couverte sera utilis\u00e9e pour trouver des p\u00e9riph\u00e9riques." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Effet personnalis\u00e9 : liste de 1 \u00e0 16 couleurs [R, V, B]. Exemple\u00a0: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Effet personnalis\u00e9\u00a0: vitesse en pourcentage pour l'effet qui change les couleurs.", + "custom_effect_transition": "Effet personnalis\u00e9 : Type de transition entre les couleurs.", + "mode": "Le mode de luminosit\u00e9 choisi." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/he.json b/homeassistant/components/flux_led/translations/he.json new file mode 100644 index 0000000000000..aa2d7877791bf --- /dev/null +++ b/homeassistant/components/flux_led/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/hu.json b/homeassistant/components/flux_led/translations/hu.json new file mode 100644 index 0000000000000..1208f87fe70a6 --- /dev/null +++ b/homeassistant/components/flux_led/translations/hu.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {model} {id} ({ipaddr}) ?" + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Egy\u00e9ni effektus: 1-16 [R,G,B] sz\u00edn list\u00e1ja. P\u00e9lda: [255,0,255], [60,128,0]", + "custom_effect_speed_pct": "Egy\u00e9ni effektus: A sz\u00edneket v\u00e1lt\u00f3 hat\u00e1s sz\u00e1zal\u00e9kos ar\u00e1nya.", + "custom_effect_transition": "Egy\u00e9ni hat\u00e1s: A sz\u00ednek k\u00f6z\u00f6tti \u00e1tmenet t\u00edpusa.", + "mode": "A v\u00e1lasztott f\u00e9nyer\u0151 m\u00f3d." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/id.json b/homeassistant/components/flux_led/translations/id.json new file mode 100644 index 0000000000000..84c993365ac92 --- /dev/null +++ b/homeassistant/components/flux_led/translations/id.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Ingin menyiapkan {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Jika host dibiarkan kosong, proses penemuan akan digunakan untuk menemukan perangkat." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efek Khusus: Daftar berisi 1 hingga 16 warna [R,G,B]. Contoh: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efek Khusus: Kecepatan dalam persen untuk efek perubahan warna.", + "custom_effect_transition": "Efek Khusus: Jenis transisi antara warna.", + "mode": "Mode kecerahan yang dipilih." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/is.json b/homeassistant/components/flux_led/translations/is.json new file mode 100644 index 0000000000000..89f72dd4362c3 --- /dev/null +++ b/homeassistant/components/flux_led/translations/is.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "T\u00e6ki n\u00fa \u00feegar stillt", + "no_devices_found": "Engin t\u00e6ki fundust \u00e1 netinu" + }, + "error": { + "cannot_connect": "Tenging mist\u00f3kst" + }, + "flow_title": "{model} {id} ( {ipaddr} )", + "step": { + "discovery_confirm": { + "description": "Viltu setja upp {model} {id} ( {ipaddr} )?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Ef \u00fe\u00fa skilur host eftir autt ver\u00f0ur leit notu\u00f0 til a\u00f0 finna t\u00e6ki" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "S\u00e9rsni\u00f0in \u00e1hrif: Listi yfir 1 til 16 [R, G, B] liti. D\u00e6mi: [255,0,255], [60,128,0]", + "custom_effect_speed_pct": "S\u00e9rsni\u00f0in \u00e1hrif: Hra\u00f0i \u00ed pr\u00f3sentum fyrir \u00e1hrifin sem skipta um liti.", + "custom_effect_transition": "S\u00e9rsni\u00f0in \u00e1hrif: Ger\u00f0 bl\u00f6ndunar \u00e1 milli litanna.", + "mode": "Valinn birtuhamur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/it.json b/homeassistant/components/flux_led/translations/it.json new file mode 100644 index 0000000000000..13b522906ba46 --- /dev/null +++ b/homeassistant/components/flux_led/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo trovato sulla rete" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Vuoi configurare {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Se lasci vuoto l'host, il rilevamento sar\u00e0 utilizzato per trovare i dispositivi." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Effetto personalizzato: Lista da 1 a 16 colori [R,G,B]. Esempio: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Effetto personalizzato: Velocit\u00e0 in percentuale per l'effetto che cambia colore.", + "custom_effect_transition": "Effetto personalizzato: Tipo di transizione tra i colori.", + "mode": "La modalit\u00e0 di luminosit\u00e0 scelta." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/ja.json b/homeassistant/components/flux_led/translations/ja.json new file mode 100644 index 0000000000000..3b6a34d7e5b20 --- /dev/null +++ b/homeassistant/components/flux_led/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "{model} {id} ({ipaddr}) \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30db\u30b9\u30c8\u3092\u7a7a\u306b\u3057\u3066\u304a\u304f\u3068\u3001\u30c7\u30a3\u30b9\u30ab\u30d0\u30ea\u30fc\u3092\u4f7f\u3063\u3066\u30c7\u30d0\u30a4\u30b9\u3092\u691c\u7d22\u3057\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u30ab\u30b9\u30bf\u30e0\u30a8\u30d5\u30a7\u30af\u30c8: 1\uff5e16\u8272[R,G,B]\u306e\u30ea\u30b9\u30c8\u3002\u4f8b: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u30ab\u30b9\u30bf\u30e0\u30a8\u30d5\u30a7\u30af\u30c8: \u8272\u3092\u5207\u308a\u66ff\u3048\u308b\u30a8\u30d5\u30a7\u30af\u30c8\u306e\u901f\u5ea6\u3092\u30d1\u30fc\u30bb\u30f3\u30c6\u30fc\u30b8\u3067\u8868\u793a\u3002", + "custom_effect_transition": "\u30ab\u30b9\u30bf\u30e0\u30a8\u30d5\u30a7\u30af\u30c8: \u8272\u3068\u8272\u306e\u9593\u3067\u306e\u9077\u79fb(\u30c8\u30e9\u30f3\u30b8\u30b7\u30e7\u30f3)\u306e\u7a2e\u985e\u3002", + "mode": "\u9078\u629e\u3057\u305f\u660e\u308b\u3055\u30e2\u30fc\u30c9\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/nl.json b/homeassistant/components/flux_led/translations/nl.json new file mode 100644 index 0000000000000..fd9e04bd475c4 --- /dev/null +++ b/homeassistant/components/flux_led/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Wilt u {model} {id} ( {ipaddr} ) instellen?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Als u de host leeg laat, wordt detectie gebruikt om apparaten te vinden." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Aangepast effect: Lijst van 1 tot 16 [R,G,B] kleuren. Voorbeeld: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Aangepast effect: snelheid in procenten voor het effect dat van kleur verandert.", + "custom_effect_transition": "Aangepast effect: Type overgang tussen de kleuren.", + "mode": "De gekozen helderheidsstand." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/no.json b/homeassistant/components/flux_led/translations/no.json new file mode 100644 index 0000000000000..ec105c1ac14e0 --- /dev/null +++ b/homeassistant/components/flux_led/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{model} {id} ( {ipaddr} )", + "step": { + "discovery_confirm": { + "description": "Vil du konfigurere {model} {id} ( {ipaddr} )?" + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Hvis du lar verten st\u00e5 tom, brukes automatisk oppdagelse til \u00e5 finne enheter" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Egendefinert effekt: Liste med farger fra 1 til 16 [R,G,B]. Eksempel: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Egendefinert effekt: Hastighet i prosent for effekten som bytter farger.", + "custom_effect_transition": "Egendefinert effekt: Overgangstype mellom fargene.", + "mode": "Den valgte lysstyrkemodusen." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/pl.json b/homeassistant/components/flux_led/translations/pl.json new file mode 100644 index 0000000000000..14cf6055a7415 --- /dev/null +++ b/homeassistant/components/flux_led/translations/pl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "description": "Je\u015bli nie podasz IP lub nazwy hosta, zostanie u\u017cyte wykrywanie do odnalezienia urz\u0105dze\u0144." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efekt niestandardowy: Lista kolor\u00f3w od 1 do 16 [R,G,B]. Przyk\u0142ad: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efekt niestandardowy: szybko\u015b\u0107 efektu zmiany kolor\u00f3w (w procentach).", + "custom_effect_transition": "Efekt niestandardowy: rodzaj przej\u015bcia mi\u0119dzy kolorami.", + "mode": "Wybrany tryb jasno\u015bci." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/ru.json b/homeassistant/components/flux_led/translations/ru.json new file mode 100644 index 0000000000000..e0f7a73baab1a --- /dev/null +++ b/homeassistant/components/flux_led/translations/ru.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0415\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0441\u043f\u0438\u0441\u043e\u043a \u043e\u0442 1 \u0434\u043e 16 [R,G,B] \u0446\u0432\u0435\u0442\u043e\u0432. \u041d\u0430\u043f\u0440\u0438\u043c\u0435\u0440: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442\u043e\u0432 (\u0432 \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u0430\u0445).", + "custom_effect_transition": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0442\u0438\u043f \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430 \u043c\u0435\u0436\u0434\u0443 \u0446\u0432\u0435\u0442\u0430\u043c\u0438.", + "mode": "\u0412\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u044f\u0440\u043a\u043e\u0441\u0442\u0438." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/sl.json b/homeassistant/components/flux_led/translations/sl.json new file mode 100644 index 0000000000000..67c1d62491162 --- /dev/null +++ b/homeassistant/components/flux_led/translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "no_devices_found": "V omre\u017eju ni mogo\u010de najti nobene naprave" + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_speed_pct": "U\u010dinek po meri: Hitrost v odstotkih za u\u010dinek, ki spreminja barve.", + "custom_effect_transition": "U\u010dinek po meri: Vrsta prehoda med barvami.", + "mode": "Izbrani na\u010din svetlosti." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/tr.json b/homeassistant/components/flux_led/translations/tr.json new file mode 100644 index 0000000000000..01bdbe368493b --- /dev/null +++ b/homeassistant/components/flux_led/translations/tr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "{model} {id} ( {ipaddr} ) kurulumu yapmak istiyor musunuz?" + }, + "user": { + "data": { + "host": "Sunucu" + }, + "description": "Ana bilgisayar\u0131 bo\u015f b\u0131rak\u0131rsan\u0131z, cihazlar\u0131 bulmak i\u00e7in ke\u015fif kullan\u0131lacakt\u0131r." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u00d6zel Efekt: 1 ila 16 [R,G,B] renk listesi. \u00d6rnek: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u00d6zel Efekt: Renkleri de\u011fi\u015ftiren efekt i\u00e7in y\u00fczde cinsinden h\u0131z.", + "custom_effect_transition": "\u00d6zel Efekt: Renkler aras\u0131ndaki ge\u00e7i\u015f t\u00fcr\u00fc.", + "mode": "Se\u00e7ilen parlakl\u0131k modu." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/zh-Hant.json b/homeassistant/components/flux_led/translations/zh-Hant.json new file mode 100644 index 0000000000000..4e14b58ff18f6 --- /dev/null +++ b/homeassistant/components/flux_led/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {model} {id} ({ipaddr})\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u5047\u5982\u4e3b\u6a5f\u7aef\u4f4d\u5740\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u63a2\u7d22\u6240\u6709\u53ef\u7528\u88dd\u7f6e\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u81ea\u8a02\u7279\u6548\uff1a1 \u5230 16 \u7a2e [R,G,B] \u984f\u8272\u3002\u4f8b\u5982\uff1a[255,0,255]\u3001[60,128,0]", + "custom_effect_speed_pct": "\u81ea\u8a02\u7279\u6548\uff1a\u984f\u8272\u5207\u63db\u7684\u901f\u5ea6\u767e\u5206\u6bd4\u3002", + "custom_effect_transition": "\u81ea\u8a02\u7279\u6548\uff1a\u984f\u8272\u9593\u7684\u8f49\u63db\u985e\u578b\u3002", + "mode": "\u9078\u64c7\u4eae\u5ea6\u6a21\u5f0f\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/util.py b/homeassistant/components/flux_led/util.py new file mode 100644 index 0000000000000..8e1e387cafbc2 --- /dev/null +++ b/homeassistant/components/flux_led/util.py @@ -0,0 +1,45 @@ +"""Utils for Magic Home.""" +from __future__ import annotations + +from flux_led.aio import AIOWifiLedBulb +from flux_led.const import COLOR_MODE_DIM as FLUX_COLOR_MODE_DIM, MultiColorEffects + +from homeassistant.components.light import ( + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_ONOFF, + COLOR_MODE_WHITE, +) + +from .const import FLUX_COLOR_MODE_TO_HASS + + +def _hass_color_modes(device: AIOWifiLedBulb) -> set[str]: + color_modes = device.color_modes + return {_flux_color_mode_to_hass(mode, color_modes) for mode in color_modes} + + +def _flux_color_mode_to_hass( + flux_color_mode: str | None, flux_color_modes: set[str] +) -> str: + """Map the flux color mode to Home Assistant color mode.""" + if flux_color_mode is None: + return COLOR_MODE_ONOFF + if flux_color_mode == FLUX_COLOR_MODE_DIM: + if len(flux_color_modes) > 1: + return COLOR_MODE_WHITE + return COLOR_MODE_BRIGHTNESS + return FLUX_COLOR_MODE_TO_HASS.get(flux_color_mode, COLOR_MODE_ONOFF) + + +def _effect_brightness(brightness: int) -> int: + """Convert hass brightness to effect brightness.""" + return round(brightness / 255 * 100) + + +def _str_to_multi_color_effect(effect_str: str) -> MultiColorEffects: + """Convert an multicolor effect string to MultiColorEffects.""" + for effect in MultiColorEffects: + if effect.name.lower() == effect_str: + return effect + # unreachable due to schema validation + assert False # pragma: no cover diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 707f22f98ba76..c7257d4023714 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -79,7 +79,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" decimals = 2 size_mb = round(self._size / 1e6, decimals) @@ -102,6 +102,6 @@ def extra_state_attributes(self): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 828d925ddbd0b..c243c0d45c82a 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==2.1.0"], + "requirements": ["watchdog==2.1.6"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index 996ac1b104917..5b252716d92b0 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,4 +1,6 @@ """Support for the Foobot indoor air quality monitor.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,7 +9,11 @@ from foobot_async import FoobotClient import voluptuous as vol -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, @@ -35,19 +41,49 @@ ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC" ATTR_FOOBOT_INDEX = "index" -SENSOR_TYPES = { - "time": [ATTR_TIME, TIME_SECONDS], - "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"], - "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], - "hum": [ATTR_HUMIDITY, PERCENTAGE, "mdi:water-percent"], - "co2": [ATTR_CARBON_DIOXIDE, CONCENTRATION_PARTS_PER_MILLION, "mdi:molecule-co2"], - "voc": [ - ATTR_VOLATILE_ORGANIC_COMPOUNDS, - CONCENTRATION_PARTS_PER_BILLION, - "mdi:cloud", - ], - "allpollu": [ATTR_FOOBOT_INDEX, PERCENTAGE, "mdi:percent"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="time", + name=ATTR_TIME, + native_unit_of_measurement=TIME_SECONDS, + ), + SensorEntityDescription( + key="pm", + name=ATTR_PM2_5, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:cloud", + ), + SensorEntityDescription( + key="tmp", + name=ATTR_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="hum", + name=ATTR_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="co2", + name=ATTR_CARBON_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:molecule-co2", + ), + SensorEntityDescription( + key="voc", + name=ATTR_VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + icon="mdi:cloud", + ), + SensorEntityDescription( + key="allpollu", + name=ATTR_FOOBOT_INDEX, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), +) SCAN_INTERVAL = timedelta(minutes=10) PARALLEL_UPDATES = 1 @@ -67,17 +103,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= client = FoobotClient( token, username, async_get_clientsession(hass), timeout=TIMEOUT ) - dev = [] + entities = [] try: devices = await client.get_devices() _LOGGER.debug("The following devices were found: %s", devices) for device in devices: foobot_data = FoobotData(client, device["uuid"]) - for sensor_type in SENSOR_TYPES: - if sensor_type == "time": - continue - foobot_sensor = FoobotSensor(foobot_data, device, sensor_type) - dev.append(foobot_sensor) + entities.extend( + [ + FoobotSensor(foobot_data, device, description) + for description in SENSOR_TYPES + if description.key != "time" + ] + ) except ( aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError, @@ -89,49 +127,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= except FoobotClient.ClientError: _LOGGER.error("Failed to fetch data from foobot servers") return - async_add_entities(dev, True) + async_add_entities(entities, True) class FoobotSensor(SensorEntity): """Implementation of a Foobot sensor.""" - def __init__(self, data, device, sensor_type): + def __init__(self, data, device, description: SensorEntityDescription): """Initialize the sensor.""" - self._uuid = device["uuid"] + self.entity_description = description self.foobot_data = data - self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}" - self.type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"Foobot {device['name']} {description.name}" + self._attr_unique_id = f"{device['uuid']}_{description.key}" @property - def icon(self): - """Icon to use in the frontend.""" - return SENSOR_TYPES[self.type][2] - - @property - def state(self): + def native_value(self): """Return the state of the device.""" try: - data = self.foobot_data.data[self.type] + data = self.foobot_data.data[self.entity_description.key] except (KeyError, TypeError): data = None return data - @property - def unique_id(self): - """Return the unique id of this entity.""" - return f"{self._uuid}_{self.type}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest data.""" await self.foobot_data.async_update() diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py new file mode 100644 index 0000000000000..760ad04af9874 --- /dev/null +++ b/homeassistant/components/forecast_solar/__init__.py @@ -0,0 +1,79 @@ +"""The Forecast.Solar integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from forecast_solar import ForecastSolar + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_MODULES_POWER, + DOMAIN, +) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Forecast.Solar from a config entry.""" + # Our option flow may cause it to be an empty string, + # this if statement is here to catch that. + api_key = entry.options.get(CONF_API_KEY) or None + + session = async_get_clientsession(hass) + forecast = ForecastSolar( + api_key=api_key, + session=session, + latitude=entry.data[CONF_LATITUDE], + longitude=entry.data[CONF_LONGITUDE], + declination=entry.options[CONF_DECLINATION], + azimuth=(entry.options[CONF_AZIMUTH] - 180), + kwp=(entry.options[CONF_MODULES_POWER] / 1000), + damping=entry.options.get(CONF_DAMPING, 0), + ) + + # Free account have a resolution of 1 hour, using that as the default + # update interval. Using a higher value for accounts with an API key. + update_interval = timedelta(hours=1) + if api_key is not None: + update_interval = timedelta(minutes=30) + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=forecast.estimate, + update_interval=update_interval, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py new file mode 100644 index 0000000000000..e7f41777062aa --- /dev/null +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow for Forecast.Solar integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_MODULES_POWER, + DOMAIN, +) + + +class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Forecast.Solar.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> ForecastSolarOptionFlowHandler: + """Get the options flow for this handler.""" + return ForecastSolarOptionFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if user_input is not None: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + }, + options={ + CONF_AZIMUTH: user_input[CONF_AZIMUTH], + CONF_DECLINATION: user_input[CONF_DECLINATION], + CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=self.hass.config.location_name + ): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Required(CONF_DECLINATION, default=25): vol.All( + vol.Coerce(int), vol.Range(min=0, max=90) + ), + vol.Required(CONF_AZIMUTH, default=180): vol.All( + vol.Coerce(int), vol.Range(min=0, max=360) + ), + vol.Required(CONF_MODULES_POWER): vol.Coerce(int), + } + ), + ) + + +class ForecastSolarOptionFlowHandler(OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_API_KEY, + description={ + "suggested_value": self.config_entry.options.get( + CONF_API_KEY + ) + }, + ): str, + vol.Required( + CONF_DECLINATION, + default=self.config_entry.options[CONF_DECLINATION], + ): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), + vol.Required( + CONF_AZIMUTH, + default=self.config_entry.options.get(CONF_AZIMUTH), + ): vol.All(vol.Coerce(int), vol.Range(min=-0, max=360)), + vol.Required( + CONF_MODULES_POWER, + default=self.config_entry.options[CONF_MODULES_POWER], + ): vol.Coerce(int), + vol.Optional( + CONF_DAMPING, + default=self.config_entry.options.get(CONF_DAMPING, 0.0), + ): vol.Coerce(float), + } + ), + ) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py new file mode 100644 index 0000000000000..63d5bd1008480 --- /dev/null +++ b/homeassistant/components/forecast_solar/const.py @@ -0,0 +1,95 @@ +"""Constants for the Forecast.Solar integration.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT + +from .models import ForecastSolarSensorEntityDescription + +DOMAIN = "forecast_solar" + +CONF_DECLINATION = "declination" +CONF_AZIMUTH = "azimuth" +CONF_MODULES_POWER = "modules power" +CONF_DAMPING = "damping" + +SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( + ForecastSolarSensorEntityDescription( + key="energy_production_today", + name="Estimated Energy Production - Today", + state=lambda estimate: estimate.energy_production_today / 1000, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + ForecastSolarSensorEntityDescription( + key="energy_production_tomorrow", + name="Estimated Energy Production - Tomorrow", + state=lambda estimate: estimate.energy_production_tomorrow / 1000, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + ForecastSolarSensorEntityDescription( + key="power_highest_peak_time_today", + name="Highest Power Peak Time - Today", + device_class=SensorDeviceClass.TIMESTAMP, + ), + ForecastSolarSensorEntityDescription( + key="power_highest_peak_time_tomorrow", + name="Highest Power Peak Time - Tomorrow", + device_class=SensorDeviceClass.TIMESTAMP, + ), + ForecastSolarSensorEntityDescription( + key="power_production_now", + name="Estimated Power Production - Now", + device_class=SensorDeviceClass.POWER, + state=lambda estimate: estimate.power_production_now, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensorEntityDescription( + key="power_production_next_hour", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=1) + ), + name="Estimated Power Production - Next Hour", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + native_unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensorEntityDescription( + key="power_production_next_12hours", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=12) + ), + name="Estimated Power Production - Next 12 Hours", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + native_unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensorEntityDescription( + key="power_production_next_24hours", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=24) + ), + name="Estimated Power Production - Next 24 Hours", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + native_unit_of_measurement=POWER_WATT, + ), + ForecastSolarSensorEntityDescription( + key="energy_current_hour", + name="Estimated Energy Production - This Hour", + state=lambda estimate: estimate.energy_current_hour / 1000, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + ForecastSolarSensorEntityDescription( + key="energy_next_hour", + state=lambda estimate: estimate.sum_energy_production(1) / 1000, + name="Estimated Energy Production - Next Hour", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), +) diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py new file mode 100644 index 0000000000000..335373963307e --- /dev/null +++ b/homeassistant/components/forecast_solar/energy.py @@ -0,0 +1,21 @@ +"""Energy platform.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_solar_forecast( + hass: HomeAssistant, config_entry_id: str +) -> dict[str, dict[str, float | int]] | None: + """Get solar forecast for a config entry ID.""" + if (coordinator := hass.data[DOMAIN].get(config_entry_id)) is None: + return None + + return { + "wh_hours": { + timestamp.isoformat(): val + for timestamp, val in coordinator.data.wh_hours.items() + } + } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json new file mode 100644 index 0000000000000..dc4b88d160c95 --- /dev/null +++ b/homeassistant/components/forecast_solar/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "forecast_solar", + "name": "Forecast.Solar", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/forecast_solar", + "requirements": ["forecast_solar==2.1.0"], + "codeowners": ["@klaasnicolaas", "@frenck"], + "quality_scale": "platinum", + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py new file mode 100644 index 0000000000000..af9b6125713b5 --- /dev/null +++ b/homeassistant/components/forecast_solar/models.py @@ -0,0 +1,17 @@ +"""Models for the Forecast.Solar integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from forecast_solar.models import Estimate + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class ForecastSolarSensorEntityDescription(SensorEntityDescription): + """Describes a Forecast.Solar Sensor.""" + + state: Callable[[Estimate], Any] | None = None diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py new file mode 100644 index 0000000000000..6088da6e64579 --- /dev/null +++ b/homeassistant/components/forecast_solar/sensor.py @@ -0,0 +1,75 @@ +"""Support for the Forecast.Solar sensor service.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, SENSORS +from .models import ForecastSolarSensorEntityDescription + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ForecastSolarSensorEntity( + entry_id=entry.entry_id, + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in SENSORS + ) + + +class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): + """Defines a Forcast.Solar sensor.""" + + entity_description: ForecastSolarSensorEntityDescription + + def __init__( + self, + *, + entry_id: str, + coordinator: DataUpdateCoordinator, + entity_description: ForecastSolarSensorEntityDescription, + ) -> None: + """Initialize Forcast.Solar sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = entity_description + self.entity_id = f"{SENSOR_DOMAIN}.{entity_description.key}" + self._attr_unique_id = f"{entry_id}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Forecast.Solar", + model=coordinator.data.account_type.value, + name="Solar Production Forecast", + configuration_url="https://forecast.solar", + ) + + @property + def native_value(self) -> datetime | StateType: + """Return the state of the sensor.""" + if self.entity_description.state is None: + state: StateType | datetime = getattr( + self.coordinator.data, self.entity_description.key + ) + else: + state = self.entity_description.state(self.coordinator.data) + + return state diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json new file mode 100644 index 0000000000000..e1ae451a04fd8 --- /dev/null +++ b/homeassistant/components/forecast_solar/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear.", + "data": { + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "modules power": "Total Watt peak power of your solar modules", + "name": "[%key:common::config_flow::data::name%]" + } + } + } + }, + "options": { + "step": { + "init": { + "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation if a field is unclear.", + "data": { + "api_key": "Forecast.Solar API Key (optional)", + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "damping": "Damping factor: adjusts the results in the morning and evening", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "modules power": "Total Watt peak power of your solar modules" + } + } + } + } +} diff --git a/homeassistant/components/forecast_solar/translations/ar.json b/homeassistant/components/forecast_solar/translations/ar.json new file mode 100644 index 0000000000000..1c2131499888d --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ar.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "modules power": "\u0625\u062c\u0645\u0627\u0644\u064a \u0637\u0627\u0642\u0629 \u0630\u0631\u0648\u0629 \u0648\u0627\u0637 \u0644\u0648\u062d\u062f\u0627\u062a \u0627\u0644\u0637\u0627\u0642\u0629 \u0627\u0644\u0634\u0645\u0633\u064a\u0629 \u0627\u0644\u062e\u0627\u0635\u0629 \u0628\u0643" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/bg.json b/homeassistant/components/forecast_solar/translations/bg.json new file mode 100644 index 0000000000000..289146783a4f3 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u0410\u0437\u0438\u043c\u0443\u0442 (360 \u0433\u0440\u0430\u0434\u0443\u0441\u0430, 0 = \u0421\u0435\u0432\u0435\u0440, 90 = \u0418\u0437\u0442\u043e\u043a, 180 = \u042e\u0433, 270 = \u0417\u0430\u043f\u0430\u0434)", + "declination": "\u0414\u0435\u043a\u043b\u0438\u043d\u0430\u0446\u0438\u044f (0 = \u0445\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u043d\u043e, 90 = \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u043d\u043e)", + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "azimuth": "\u0410\u0437\u0438\u043c\u0443\u0442 (360 \u0433\u0440\u0430\u0434\u0443\u0441\u0430, 0 = \u0421\u0435\u0432\u0435\u0440, 90 = \u0418\u0437\u0442\u043e\u043a, 180 = \u042e\u0433, 270 = \u0417\u0430\u043f\u0430\u0434)", + "declination": "\u0414\u0435\u043a\u043b\u0438\u043d\u0430\u0446\u0438\u044f (0 = \u0445\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u043d\u043e, 90 = \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u043d\u043e)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/ca.json b/homeassistant/components/forecast_solar/translations/ca.json new file mode 100644 index 0000000000000..7bd318280808e --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 graus, 0 = nord, 90 = est, 180 = sud, 270 = oest)", + "declination": "Inclinaci\u00f3 (0 = horitzontal, 90 = vertical)", + "latitude": "Latitud", + "longitude": "Longitud", + "modules power": "Pot\u00e8ncia m\u00e0xima total dels panells solars", + "name": "Nom" + }, + "description": "Introdueix les dades dels teus panells solars. Consulta la documentaci\u00f3 si tens dubtes en algun camp." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Clau API de Forecast.Solar (opcional)", + "azimuth": "Azimut (360 graus, 0 = nord, 90 = est, 180 = sud, 270 = oest)", + "damping": "Factor d'amortiment: ajusta els resultats al mat\u00ed i al vespre", + "declination": "Inclinaci\u00f3 (0 = horitzontal, 90 = vertical)", + "modules power": "Pot\u00e8ncia m\u00e0xima total dels panells solars" + }, + "description": "Aquests valors permeten ajustar els resultats de Solar.Forecast. Consulta la documentaci\u00f3 si tens dubtes sobre algun camp." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/cs.json b/homeassistant/components/forecast_solar/translations/cs.json new file mode 100644 index 0000000000000..0b970643bbef2 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/de.json b/homeassistant/components/forecast_solar/translations/de.json new file mode 100644 index 0000000000000..43b60424cf192 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 Grad, 0 = Norden, 90 = Osten, 180 = S\u00fcden, 270 = Westen)", + "declination": "Deklination (0 = Horizontal, 90 = Vertikal)", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "modules power": "Gesamt-Watt-Spitzenleistung deiner Solarmodule", + "name": "Name" + }, + "description": "Gib die Daten deiner Solarmodule ein. Wenn ein Feld unklar ist, schlage bitte in der Dokumentation nach." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API-Schl\u00fcssel (optional)", + "azimuth": "Azimut (360 Grad, 0 = Norden, 90 = Osten, 180 = S\u00fcden, 270 = Westen)", + "damping": "D\u00e4mpfungsfaktor: passt die Ergebnisse morgens und abends an", + "declination": "Deklination (0 = Horizontal, 90 = Vertikal)", + "modules power": "Gesamt-Watt-Spitzenleistung deiner Solarmodule" + }, + "description": "Mit diesen Werten kann das Solar.Forecast-Ergebnis angepasst werden. Wenn ein Feld unklar ist, lies bitte in der Dokumentation nach." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/en.json b/homeassistant/components/forecast_solar/translations/en.json new file mode 100644 index 0000000000000..f9eef2b5c0a59 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "latitude": "Latitude", + "longitude": "Longitude", + "modules power": "Total Watt peak power of your solar modules", + "name": "Name" + }, + "description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API Key (optional)", + "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "damping": "Damping factor: adjusts the results in the morning and evening", + "declination": "Declination (0 = Horizontal, 90 = Vertical)", + "modules power": "Total Watt peak power of your solar modules" + }, + "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation if a field is unclear." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/es-419.json b/homeassistant/components/forecast_solar/translations/es-419.json new file mode 100644 index 0000000000000..e2b71af40de82 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de sus m\u00f3dulos solares" + }, + "description": "Complete los datos de sus paneles solares. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Clave de API Forecast.Solar (opcional)", + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de sus m\u00f3dulos solares" + }, + "description": "Estos valores permiten modificar el resultado de Solar.Forecast. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/es.json b/homeassistant/components/forecast_solar/translations/es.json new file mode 100644 index 0000000000000..d688c57702456 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Acimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "latitude": "Latitud", + "longitude": "Longitud", + "modules power": "Potencia total en vatios pico de sus m\u00f3dulos solares", + "name": "Nombre" + }, + "description": "Rellene los datos de sus paneles solares. Consulte la documentaci\u00f3n si alg\u00fan campo no est\u00e1 claro." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Clave API de Forecast.Solar (opcional)", + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de tus m\u00f3dulos solares" + }, + "description": "Estos valores permiten ajustar el resultado de Solar.Forecast. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/et.json b/homeassistant/components/forecast_solar/translations/et.json new file mode 100644 index 0000000000000..7aa87f4cf58c7 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/et.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Asimuut (360 kraadi, 0 = p\u00f5hi, 90 = ida, 180 = l\u00f5una, 270 = l\u00e4\u00e4s)", + "declination": "Deklinatsioon (0 = horisontaalne, 90 = vertikaalne)", + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad", + "modules power": "P\u00e4ikesemoodulite koguv\u00f5imsus vattides", + "name": "Nimi" + }, + "description": "Sisesta oma p\u00e4ikesepaneelide andmed. Kui v\u00e4li on ebaselge, loe dokumentatsiooni." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API v\u00f5ti (valikuline)", + "azimuth": "Asimuut (360 kraadi, 0 = p\u00f5hi, 90 = ida, 180 = l\u00f5una, 270 = l\u00e4\u00e4s)", + "damping": "Summutustegur: reguleerib tulemusi hommikul ja \u00f5htul", + "declination": "Deklinatsioon (0 = horisontaalne, 90 = vertikaalne)", + "modules power": "P\u00e4ikesemoodulite koguv\u00f5imsus vattides" + }, + "description": "Need v\u00e4\u00e4rtused v\u00f5imaldavad muuta Solar.Forecast tulemust. Vaata dokumentatsiooni kui asi on ebaselge." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/fr.json b/homeassistant/components/forecast_solar/translations/fr.json new file mode 100644 index 0000000000000..efd9f7be3a6d1 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 degr\u00e9s, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ouest)", + "declination": "D\u00e9clinaison (0 = horizontale, 90 = verticale)", + "latitude": "Latitude", + "longitude": "Longitude", + "modules power": "Puissance de cr\u00eate totale en watts de vos modules solaires", + "name": "Nom" + }, + "description": "Remplissez les donn\u00e9es de vos panneaux solaires. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation si un champ n'est pas clair." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Cl\u00e9 API Forecast.Solar (facultatif)", + "azimuth": "Azimut (360 degr\u00e9s, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ouest)", + "damping": "Facteur d'amortissement : ajuste les r\u00e9sultats matin et soir", + "declination": "D\u00e9clinaison (0 = horizontale, 90 = verticale)", + "modules power": "Puissance de cr\u00eate totale en watts de vos modules solaires" + }, + "description": "Ces valeurs permettent de peaufiner le r\u00e9sultat Solar.Forecast. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation si un champ n'est pas clair." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/he.json b/homeassistant/components/forecast_solar/translations/he.json new file mode 100644 index 0000000000000..99eeb837dc38d --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/he.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/hu.json b/homeassistant/components/forecast_solar/translations/hu.json new file mode 100644 index 0000000000000..0bd814f16be5f --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/hu.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 fok, 0 = \u00e9szak, 90 = keleti, 180 = d\u00e9li, 270 = nyugati)", + "declination": "Deklin\u00e1ci\u00f3 (0 = v\u00edzszintes, 90 = f\u00fcgg\u0151leges)", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "modules power": "A napelemmodulok teljes cs\u00facsteljes\u00edtm\u00e9nye (Watt)", + "name": "N\u00e9v" + }, + "description": "T\u00f6ltse ki a napelemek adatait. K\u00e9rj\u00fck, olvassa el a dokument\u00e1ci\u00f3t, ha egy mez\u0151 nem egy\u00e9rtelm\u0171." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API kulcs (opcion\u00e1lis)", + "azimuth": "Azimut (360 fok, 0 = \u00e9szak, 90 = keleti, 180 = d\u00e9li, 270 = nyugati)", + "damping": "Csillap\u00edt\u00e1si t\u00e9nyez\u0151: be\u00e1ll\u00edtja az eredm\u00e9nyeket reggelre \u00e9s est\u00e9re", + "declination": "Deklin\u00e1ci\u00f3 (0 = v\u00edzszintes, 90 = f\u00fcgg\u0151leges)", + "modules power": "A napelemmodulok teljes cs\u00facsteljes\u00edtm\u00e9nye (Watt)" + }, + "description": "Ezek az \u00e9rt\u00e9kek lehet\u0151v\u00e9 teszik a Solar.Forecast eredm\u00e9ny m\u00f3dos\u00edt\u00e1s\u00e1t. K\u00e9rj\u00fck, olvassa el a dokument\u00e1ci\u00f3t, ha egy mez\u0151 nem egy\u00e9rtelm\u0171." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/id.json b/homeassistant/components/forecast_solar/translations/id.json new file mode 100644 index 0000000000000..27ef16e026680 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/id.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimuth (360 derajat, 0 = Utara, 90 = Timur, 180 = Selatan, 270 = Barat)", + "declination": "Deklinasi (0 = Horizontal, 90 = Vertikal)", + "latitude": "Lintang", + "longitude": "Bujur", + "modules power": "Total daya puncak modul surya Anda dalam Watt", + "name": "Nama" + }, + "description": "Isi data panel surya Anda. Rujuk ke dokumentasi jika bidang isian tidak jelas." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Kunci API Forecast.Solar (opsional)", + "azimuth": "Azimuth (360 derajat, 0 = Utara, 90 = Timur, 180 = Selatan, 270 = Barat)", + "damping": "Faktor redaman: menyesuaikan hasil di pagi dan sore hari", + "declination": "Deklinasi (0 = Horizontal, 90 = Vertikal)", + "modules power": "Total daya puncak modul surya Anda dalam Watt" + }, + "description": "Nilai-nilai ini memungkinkan penyesuaian hasil Solar.Forecast. Rujuk ke dokumentasi jika bidang isian tidak jelas." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/it.json b/homeassistant/components/forecast_solar/translations/it.json new file mode 100644 index 0000000000000..7920eee43ebcb --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 gradi, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ovest)", + "declination": "Declinazione (0 = Orizzontale, 90 = Verticale)", + "latitude": "Latitudine", + "longitude": "Logitudine", + "modules power": "Potenza di picco totale in Watt dei tuoi moduli solari", + "name": "Nome" + }, + "description": "Compila i dati dei tuoi pannelli solari. Fare riferimento alla documentazione se un campo non \u00e8 chiaro." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Chiave API Forecast.Solar (opzionale)", + "azimuth": "Azimut (360 gradi, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ovest)", + "damping": "Fattore di smorzamento: regola i risultati al mattino e alla sera", + "declination": "Declinazione (0 = Orizzontale, 90 = Verticale)", + "modules power": "Potenza di picco totale in Watt dei tuoi moduli solari" + }, + "description": "Questi valori consentono di modificare il risultato di Solar.Forecast. Fai riferimento alla documentazione se un campo non \u00e8 chiaro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/ja.json b/homeassistant/components/forecast_solar/translations/ja.json new file mode 100644 index 0000000000000..62090376beda3 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u65b9\u4f4d\u89d2(360\u5ea6\u30010=\u5317\u300190=\u6771\u3001180=\u5357\u3001270=\u897f)", + "declination": "\u504f\u89d2(0\uff1d\u6c34\u5e73\u300190\uff1d\u5782\u76f4)", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "modules power": "\u30bd\u30fc\u30e9\u30fc\u30e2\u30b8\u30e5\u30fc\u30eb\u306e\u7dcf\u30ef\u30c3\u30c8\u30d4\u30fc\u30af\u96fb\u529b", + "name": "\u540d\u524d" + }, + "description": "\u30bd\u30fc\u30e9\u30fc\u30d1\u30cd\u30eb\u306e\u30c7\u30fc\u30bf\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4e0d\u660e\u306a\u5834\u5408\u306f\u3001\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API\u30ad\u30fc(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "azimuth": "\u65b9\u4f4d\u89d2(360\u5ea6\u30010=\u5317\u300190=\u6771\u3001180=\u5357\u3001270=\u897f)", + "damping": "\u6e1b\u8870\u4fc2\u6570(\u30c0\u30f3\u30d4\u30f3\u30b0\u30d5\u30a1\u30af\u30bf\u30fc): \u671d\u3068\u5915\u65b9\u306e\u7d50\u679c\u3092\u8abf\u6574\u3059\u308b", + "declination": "\u504f\u89d2(0\uff1d\u6c34\u5e73\u300190\uff1d\u5782\u76f4)", + "modules power": "\u30bd\u30fc\u30e9\u30fc\u30e2\u30b8\u30e5\u30fc\u30eb\u306e\u7dcf\u30ef\u30c3\u30c8\u30d4\u30fc\u30af\u96fb\u529b" + }, + "description": "\u3053\u308c\u3089\u306e\u5024\u306b\u3088\u308a\u3001Solar.Forecast\u306e\u7d50\u679c\u3092\u5fae\u8abf\u6574\u3067\u304d\u307e\u3059\u3002\u30d5\u30a3\u30fc\u30eb\u30c9\u304c\u4e0d\u660e\u306a\u5834\u5408\u306f\u3001\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/nl.json b/homeassistant/components/forecast_solar/translations/nl.json new file mode 100644 index 0000000000000..c66d272782dbf --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 graden, 0 = Noord, 90 = Oost, 180 = Zuid, 270 = West)", + "declination": "Declinatie (0 = Horizontaal, 90 = Verticaal)", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "modules power": "Totaal Watt piekvermogen van uw zonnepanelen", + "name": "Naam" + }, + "description": "Vul de gegevens van uw zonnepanelen in. Raadpleeg de documentatie als een veld niet duidelijk is." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API-sleutel (optioneel)", + "azimuth": "Azimut (360 graden, 0 = Noord, 90 = Oost, 180 = Zuid, 270 = West)", + "damping": "Dempingsfactor: past de resultaten 's ochtends en 's avonds aan", + "declination": "Declinatie (0 = Horizontaal, 90 = Verticaal)", + "modules power": "Totaal Watt piekvermogen van uw zonnepanelen" + }, + "description": "Met deze waarden kan het resultaat van Solar.Forecast worden aangepast. Raadpleeg de documentatie als een veld onduidelijk is." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/no.json b/homeassistant/components/forecast_solar/translations/no.json new file mode 100644 index 0000000000000..1504727c1aeb7 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 grader, 0 = Nord, 90 = \u00d8st, 180 = S\u00f8r, 270 = Vest)", + "declination": "Deklinasjon (0 = horisontal, 90 = vertikal)", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "modules power": "Total Watt-toppeffekt i solcellemodulene dine", + "name": "Navn" + }, + "description": "Fyll ut dataene til solcellepanelene. Se dokumentasjonen hvis et felt er uklart." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API-n\u00f8kkel (valgfritt)", + "azimuth": "Azimut (360 grader, 0 = Nord, 90 = \u00d8st, 180 = S\u00f8r, 270 = Vest)", + "damping": "Dempingsfaktor: justerer resultatene om morgenen og kvelden", + "declination": "Deklinasjon (0 = horisontal, 90 = vertikal)", + "modules power": "Total Watt-toppeffekt i solcellemodulene dine" + }, + "description": "Disse verdiene tillater justering av Solar.Forecast -resultatet. Se dokumentasjonen hvis et felt er uklart." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/pl.json b/homeassistant/components/forecast_solar/translations/pl.json new file mode 100644 index 0000000000000..3fc782fe7c323 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azymut (360 stopni, 0 = P\u00f3\u0142noc, 90 = Wsch\u00f3d, 180 = Po\u0142udnie, 270 = Zach\u00f3d)", + "declination": "Deklinacja (0 = Poziomo, 90 = Pionowo)", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "modules power": "Ca\u0142kowita moc szczytowa modu\u0142\u00f3w fotowoltaicznych w watach", + "name": "Nazwa" + }, + "description": "Wpisz dane swoich paneli s\u0142onecznych. Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105, je\u015bli pole jest niejasne." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Klucz API dla Forecast.Solar (opcjonalnie)", + "azimuth": "Azymut (360 stopni, 0 = P\u00f3\u0142noc, 90 = Wsch\u00f3d, 180 = Po\u0142udnie, 270 = Zach\u00f3d)", + "damping": "Wsp\u00f3\u0142czynnik t\u0142umienia: dostosowuje wyniki rano i wieczorem", + "declination": "Deklinacja (0 = Poziomo, 90 = Pionowo)", + "modules power": "Ca\u0142kowita moc szczytowa modu\u0142\u00f3w fotowoltaicznych w watach" + }, + "description": "Te warto\u015bci pozwalaj\u0105 dostosowa\u0107 wyniki dla Solar.Forecast. Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105, je\u015bli pole jest niejasne." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/ru.json b/homeassistant/components/forecast_solar/translations/ru.json new file mode 100644 index 0000000000000..9cf8e87a8e2a0 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ru.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u0410\u0437\u0438\u043c\u0443\u0442 (360 \u0433\u0440\u0430\u0434\u0443\u0441\u043e\u0432, 0 = \u0441\u0435\u0432\u0435\u0440, 90 = \u0432\u043e\u0441\u0442\u043e\u043a, 180 = \u044e\u0433, 270 = \u0437\u0430\u043f\u0430\u0434)", + "declination": "\u0421\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u0435 (0 = \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435, 90 = \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435)", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "modules power": "\u041e\u0431\u0449\u0430\u044f \u043f\u0438\u043a\u043e\u0432\u0430\u044f \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c \u0412\u0430\u0448\u0438\u0445 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 (\u0432 \u0412\u0430\u0442\u0442\u0430\u0445)", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Forecast.Solar." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API Forecast.Solar (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "azimuth": "\u0410\u0437\u0438\u043c\u0443\u0442 (360 \u0433\u0440\u0430\u0434\u0443\u0441\u043e\u0432, 0 = \u0441\u0435\u0432\u0435\u0440, 90 = \u0432\u043e\u0441\u0442\u043e\u043a, 180 = \u044e\u0433, 270 = \u0437\u0430\u043f\u0430\u0434)", + "damping": "\u0424\u0430\u043a\u0442\u043e\u0440 \u0437\u0430\u0442\u0443\u0445\u0430\u043d\u0438\u044f: \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u0438\u0440\u0443\u0435\u0442 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u044b \u0443\u0442\u0440\u043e\u043c \u0438 \u0432\u0435\u0447\u0435\u0440\u043e\u043c", + "declination": "\u0421\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u0435 (0 = \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435, 90 = \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435)", + "modules power": "\u041e\u0431\u0449\u0430\u044f \u043f\u0438\u043a\u043e\u0432\u0430\u044f \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c \u0412\u0430\u0448\u0438\u0445 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 (\u0432 \u0412\u0430\u0442\u0442\u0430\u0445)" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Forecast.Solar." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/tr.json b/homeassistant/components/forecast_solar/translations/tr.json new file mode 100644 index 0000000000000..fecd8d7889ae2 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 derece, 0 = Kuzey, 90 = Do\u011fu, 180 = G\u00fcney, 270 = Bat\u0131)", + "declination": "Sapma (0 = Yatay, 90 = Dikey)", + "latitude": "Enlem", + "longitude": "Boylam", + "modules power": "Solar mod\u00fcllerinizin toplam en y\u00fcksek Watt g\u00fcc\u00fc", + "name": "Ad" + }, + "description": "G\u00fcne\u015f panellerinizin verilerini doldurun. Bir alan net de\u011filse l\u00fctfen belgelere bak\u0131n." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API Anahtar\u0131 (iste\u011fe ba\u011fl\u0131)", + "azimuth": "Azimut (360 derece, 0 = Kuzey, 90 = Do\u011fu, 180 = G\u00fcney, 270 = Bat\u0131)", + "damping": "S\u00f6n\u00fcmleme fakt\u00f6r\u00fc: sonu\u00e7lar\u0131 sabah ve ak\u015fam ayarlar", + "declination": "Sapma (0 = Yatay, 90 = Dikey)", + "modules power": "Solar mod\u00fcllerinizin toplam en y\u00fcksek Watt g\u00fcc\u00fc" + }, + "description": "Bu de\u011ferler Solar.Forecast sonucunun ayarlanmas\u0131na izin verir. Bir alan net de\u011filse l\u00fctfen belgelere bak\u0131n." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/zh-Hans.json b/homeassistant/components/forecast_solar/translations/zh-Hans.json new file mode 100644 index 0000000000000..8a667cf9260e6 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/zh-Hans.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u65b9\u4f4d\u89d2\uff08360 \u5ea6\uff0c\u4ee5 0 \u4e3a\u5317\uff0c90 \u4e3a\u4e1c\uff0c180 \u4e3a\u5357\uff0c270 \u4e3a\u897f\uff09", + "declination": "\u503e\u89d2\uff08\u4ee5 0 \u4e3a\u6c34\u5e73\uff0c90 \u4e3a\u5782\u76f4\uff09", + "latitude": "\u7eac\u5ea6", + "longitude": "\u7ecf\u5ea6", + "modules power": "\u5149\u4f0f\u53d1\u7535\u6a21\u7ec4\u7684\u603b\u5cf0\u503c\u529f\u7387(W)", + "name": "\u540d\u79f0" + }, + "description": "\u8bf7\u586b\u5199\u60a8\u7684\u592a\u9633\u80fd\u677f\u7684\u53c2\u6570\u3002\u5bf9\u4e8e\u4e0d\u6e05\u695a\u7684\u5b57\u6bb5\uff0c\u8bf7\u53c2\u9605\u6709\u5173\u6587\u6863\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API \u5bc6\u94a5\uff08\u53ef\u9009\uff09", + "azimuth": "\u65b9\u4f4d\u89d2\uff08360 \u5ea6\uff0c\u4ee5 0 \u4e3a\u5317\uff0c90 \u4e3a\u4e1c\uff0c180 \u4e3a\u5357\uff0c270 \u4e3a\u897f\uff09", + "damping": "\u963b\u5c3c\u7cfb\u6570\uff1a\u8c03\u8282\u65e9\u95f4\u548c\u665a\u95f4\u7684\u7ed3\u679c", + "declination": "\u503e\u89d2\uff08\u4ee5 0 \u4e3a\u6c34\u5e73\uff0c90 \u4e3a\u5782\u76f4\uff09", + "modules power": "\u5149\u4f0f\u53d1\u7535\u6a21\u7ec4\u7684\u603b\u5cf0\u503c\u529f\u7387(W)" + }, + "description": "\u8fd9\u4e9b\u503c\u7528\u4e8e\u8c03\u8282 Solar.Forecast \u7ed3\u679c\u3002\u5bf9\u4e8e\u4e0d\u6e05\u695a\u7684\u5b57\u6bb5\uff0c\u8bf7\u53c2\u9605\u6587\u6863\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/zh-Hant.json b/homeassistant/components/forecast_solar/translations/zh-Hant.json new file mode 100644 index 0000000000000..fca97b9da0114 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u65b9\u4f4d\u89d2\uff08360 \u5ea6\u55ae\u4f4d\u30020 = \u5317\u300190 = \u6771\u3001180 = \u5357\u3001270 = \u897f\uff09", + "declination": "\u504f\u89d2\uff080 = \u6c34\u5e73\u300190 = \u5782\u76f4\uff09", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "modules power": "\u592a\u967d\u80fd\u6a21\u7d44\u7e3d\u5cf0\u503c\u529f\u7387", + "name": "\u540d\u7a31" + }, + "description": "\u586b\u5beb\u592a\u967d\u80fd\u677f\u8cc7\u6599\u3002\u5982\u679c\u6709\u4e0d\u6e05\u695a\u7684\u5730\u65b9\uff0c\u8acb\u53c3\u8003\u6587\u4ef6\u8aaa\u660e\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API \u91d1\u9470\uff08\u9078\u9805\uff09", + "azimuth": "\u65b9\u4f4d\u89d2\uff08360 \u5ea6\u55ae\u4f4d\u30020 = \u5317\u300190 = \u6771\u3001180 = \u5357\u3001270 = \u897f\uff09", + "damping": "\u963b\u5c3c\u56e0\u7d20\uff1a\u8abf\u6574\u6e05\u6668\u8207\u508d\u665a\u7d50\u679c", + "declination": "\u504f\u89d2\uff080 = \u6c34\u5e73\u300190 = \u5782\u76f4\uff09", + "modules power": "\u7e3d\u5cf0\u503c\u529f\u7387" + }, + "description": "\u6b64\u4e9b\u6578\u503c\u5141\u8a31\u5fae\u8abf Solar.Forecast \u7d50\u679c\u3002\u5982\u679c\u6709\u4e0d\u6e05\u695a\u7684\u5730\u65b9\u3001\u8acb\u53c3\u8003\u6587\u4ef6\u8aaa\u660e\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index fc67d78d5edd9..ea2d678aab530 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -1,9 +1,9 @@ """The forked_daapd component.""" -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.const import Platform from .const import DOMAIN, HASS_DATA_REMOVE_LISTENERS_KEY, HASS_DATA_UPDATER_KEY -PLATFORMS = [MP_DOMAIN] +PLATFORMS = [Platform.MEDIA_PLAYER] async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 1d01218b776b4..e3cf6fc7c1d1e 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -6,8 +6,10 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( @@ -133,9 +135,7 @@ async def async_step_user(self, user_input=None): """ if user_input is not None: # check for any entries with same host, abort if found - for entry in self._async_current_entries(): - if entry.data.get(CONF_HOST) == user_input[CONF_HOST]: - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) validate_result = await self.validate_input(user_input) if validate_result[0] == "ok": # success _LOGGER.debug("Connected successfully. Creating entry") @@ -155,35 +155,36 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=vol.Schema(DATA_SCHEMA_DICT), errors={} ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered forked-daapd device.""" version_num = 0 - if discovery_info.get("properties") and discovery_info["properties"].get( - "Machine Name" - ): + zeroconf_properties = discovery_info.properties + if zeroconf_properties.get("Machine Name"): with suppress(ValueError): version_num = int( - discovery_info["properties"].get("mtd-version", "0").split(".")[0] + zeroconf_properties.get("mtd-version", "0").split(".")[0] ) if version_num < 27: return self.async_abort(reason="not_forked_daapd") - await self.async_set_unique_id(discovery_info["properties"]["Machine Name"]) + await self.async_set_unique_id(zeroconf_properties["Machine Name"]) self._abort_if_unique_id_configured() # Update title and abort if we already have an entry for this host for entry in self._async_current_entries(): - if entry.data.get(CONF_HOST) != discovery_info["host"]: + if entry.data.get(CONF_HOST) != discovery_info.host: continue self.hass.config_entries.async_update_entry( entry, - title=discovery_info["properties"]["Machine Name"], + title=zeroconf_properties["Machine Name"], ) return self.async_abort(reason="already_configured") zeroconf_data = { - CONF_HOST: discovery_info["host"], - CONF_PORT: int(discovery_info["port"]), - CONF_NAME: discovery_info["properties"]["Machine Name"], + CONF_HOST: discovery_info.host, + CONF_PORT: discovery_info.port, + CONF_NAME: zeroconf_properties["Machine Name"], } self.discovery_schema = vol.Schema(fill_in_schema_dict(zeroconf_data)) self.context.update({"title_placeholders": zeroconf_data}) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 724db80fabdac..f19209a0b2d96 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -621,8 +621,7 @@ async def async_set_shuffle(self, shuffle): @property def media_image_url(self): """Image url of current playing media.""" - url = self._track_info.get("artwork_url") - if url: + if url := self._track_info.get("artwork_url"): url = self._api.full_url(url) return url @@ -769,11 +768,10 @@ def __init__(self, hass, api, entry_id): async def async_init(self): """Perform async portion of class initialization.""" server_config = await self._api.get_request("config") - websocket_port = server_config.get("websocket_port") - if websocket_port: + if websocket_port := server_config.get("websocket_port"): self.websocket_handler = asyncio.create_task( self._api.start_websocket_handler( - server_config["websocket_port"], + websocket_port, WS_NOTIFY_EVENT_TYPES, self._update, WEBSOCKET_RECONNECT_TIME, @@ -799,8 +797,7 @@ async def _update(self, update_types): if ( "queue" in update_types ): # update queue, queue before player for async_play_media - queue = await self._api.get_request("queue") - if queue: + if queue := await self._api.get_request("queue"): update_events["queue"] = asyncio.Event() async_dispatcher_send( self.hass, @@ -810,8 +807,7 @@ async def _update(self, update_types): ) # order of below don't matter if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs - outputs = await self._api.get_request("outputs") - if outputs: + if outputs := await self._api.get_request("outputs"): outputs = outputs["outputs"] update_events[ "outputs" @@ -840,8 +836,7 @@ async def _update(self, update_types): if not {"player", "options", "volume"}.isdisjoint( update_types ): # update player - player = await self._api.get_request("player") - if player: + if player := await self._api.get_request("player"): update_events["player"] = asyncio.Event() if update_events.get("queue"): await update_events[ diff --git a/homeassistant/components/forked_daapd/translations/bg.json b/homeassistant/components/forked_daapd/translations/bg.json new file mode 100644 index 0000000000000..5a415dfbee2f3 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/bg.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "unknown_error": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430", + "wrong_host_or_port": "\u041d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435. \u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430.", + "wrong_password": "\u0413\u0440\u0435\u0448\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430." + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041f\u0440\u0438\u044f\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "password": "API \u043f\u0430\u0440\u043e\u043b\u0430 (\u043e\u0441\u0442\u0430\u0432\u0435\u0442\u0435 \u043f\u0440\u0430\u0437\u043d\u043e, \u0430\u043a\u043e \u043d\u044f\u043c\u0430 \u043f\u0430\u0440\u043e\u043b\u0430)", + "port": "API \u043f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/ca.json b/homeassistant/components/forked_daapd/translations/ca.json index fb778199efab0..f84b0376dd638 100644 --- a/homeassistant/components/forked_daapd/translations/ca.json +++ b/homeassistant/components/forked_daapd/translations/ca.json @@ -12,7 +12,7 @@ "wrong_password": "Contrasenya incorrecta.", "wrong_server_type": "La integraci\u00f3 forked-daapd necessita un servidor forked-daapd amb versi\u00f3 >= 27.0." }, - "flow_title": "Servidor forked-daapd: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/de.json b/homeassistant/components/forked_daapd/translations/de.json index 0001157ce415c..51fd312fd6d61 100644 --- a/homeassistant/components/forked_daapd/translations/de.json +++ b/homeassistant/components/forked_daapd/translations/de.json @@ -5,14 +5,14 @@ "not_forked_daapd": "Das Ger\u00e4t ist kein Forked-Daapd-Server." }, "error": { - "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfen Sie Ihre forked-daapd-Netzwerkberechtigungen.", - "unknown_error": "Unbekannter Fehler", + "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfe deine forked-daapd-Netzwerkberechtigungen.", + "unknown_error": "Unerwarteter Fehler", "websocket_not_enabled": "Forked-Daapd-Server-Websocket nicht aktiviert.", "wrong_host_or_port": "Verbindung konnte nicht hergestellt werden. Bitte Host und Port pr\u00fcfen.", "wrong_password": "Ung\u00fcltiges Passwort", "wrong_server_type": "F\u00fcr die forked-daapd Integration ist ein forked-daapd Server mit der Version > = 27.0 erforderlich." }, - "flow_title": "Forked-Daapd-Server: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/es-419.json b/homeassistant/components/forked_daapd/translations/es-419.json new file mode 100644 index 0000000000000..c62c18892842e --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/es-419.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "not_forked_daapd": "El dispositivo no es un servidor daapd bifurcado." + }, + "error": { + "forbidden": "No puede conectarse. Verifique sus permisos de red bifurcados-daapd.", + "websocket_not_enabled": "El websocket del servidor forked-daapd no est\u00e1 habilitado." + }, + "step": { + "user": { + "data": { + "port": "Puerto API" + }, + "title": "Configurar dispositivo bifurcado-daapd" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "Puerto para control de tuber\u00edas librespot-java (si se usa)", + "max_playlists": "N\u00famero m\u00e1ximo de listas de reproducci\u00f3n utilizadas como fuentes", + "tts_pause_time": "Segundos para pausar antes y despu\u00e9s de TTS", + "tts_volume": "Volumen de TTS (flotante en el rango [0,1])" + }, + "description": "Configure varias opciones para la integraci\u00f3n bifurcada-daapd.", + "title": "Configurar las opciones bifurcadas-daapd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/et.json b/homeassistant/components/forked_daapd/translations/et.json index 8e2096e821c83..a9413cf0cea42 100644 --- a/homeassistant/components/forked_daapd/translations/et.json +++ b/homeassistant/components/forked_daapd/translations/et.json @@ -12,7 +12,7 @@ "wrong_password": "Vale salas\u00f5na.", "wrong_server_type": "Forked-daapd sidumine n\u00f5uab forked-daapd serveri versioon >= 27.0." }, - "flow_title": "forked-daapd server: {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/fr.json b/homeassistant/components/forked_daapd/translations/fr.json index 2e20c75d33f1e..11951f50a9564 100644 --- a/homeassistant/components/forked_daapd/translations/fr.json +++ b/homeassistant/components/forked_daapd/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "not_forked_daapd": "Le p\u00e9riph\u00e9rique n'est pas un serveur forked-daapd." }, "error": { "forbidden": "Impossible de se connecter. Veuillez v\u00e9rifier vos autorisations r\u00e9seau forked-daapd.", - "unknown_error": "Erreur inconnue", + "unknown_error": "Erreur inattendue", "websocket_not_enabled": "le socket web du serveur forked-daapd n'est pas activ\u00e9.", "wrong_host_or_port": "Impossible de se connecter. Veuillez v\u00e9rifier l'h\u00f4te et le port.", "wrong_password": "Mot de passe incorrect.", "wrong_server_type": "L'int\u00e9gration forked-daapd n\u00e9cessite un serveur forked-daapd avec la version > = 27.0." }, - "flow_title": "serveur forked-daapd: {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/he.json b/homeassistant/components/forked_daapd/translations/he.json new file mode 100644 index 0000000000000..39bd36133d5c8 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "unknown_error": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", + "wrong_host_or_port": "\u05d0\u05d9\u05df \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8. \u05e0\u05d0 \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05d4\u05de\u05d0\u05e8\u05d7 \u05d5\u05d0\u05ea \u05d4\u05d9\u05e6\u05d9\u05d0\u05d4.", + "wrong_password": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d2\u05d5\u05d9\u05d4." + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05ea API (\u05d4\u05e9\u05d0\u05e8 \u05e8\u05d9\u05e7 \u05d0\u05dd \u05d0\u05d9\u05df \u05e1\u05d9\u05e1\u05de\u05d4)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index ca90fad3048fd..2058bbd1cbe6f 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -1,17 +1,41 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "not_forked_daapd": "Az eszk\u00f6z nem forked-daapd kiszolg\u00e1l\u00f3." }, "error": { "forbidden": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a forked-daapd h\u00e1l\u00f3zati enged\u00e9lyeket.", - "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "websocket_not_enabled": "forked-daapd szerver websocket nincs enged\u00e9lyezve.", + "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a c\u00edmet \u00e9s a portot.", + "wrong_password": "Helytelen jelsz\u00f3.", + "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja legal\u00e1bb 27.0." }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt" - } + "host": "C\u00edm", + "name": "Megjelen\u00edt\u00e9si n\u00e9v", + "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", + "port": "API port" + }, + "title": "\u00c1ll\u00edtsa be a forked-daapd eszk\u00f6zt" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "Port librespot-java cs\u0151 vez\u00e9rl\u00e9s (ha van)", + "max_playlists": "Forr\u00e1sk\u00e9nt haszn\u00e1lt lej\u00e1tsz\u00e1si list\u00e1k maxim\u00e1lis sz\u00e1ma", + "tts_pause_time": "M\u00e1sodpercek a TTS el\u0151tti \u00e9s ut\u00e1ni sz\u00fcnethez", + "tts_volume": "TTS hanger\u0151 (lebeg\u0151 a [0,1] tartom\u00e1nyban)" + }, + "description": "A forked-daapd integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sai.", + "title": "A forked-daapd be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/forked_daapd/translations/id.json b/homeassistant/components/forked_daapd/translations/id.json index 76787e2a19bf3..f57a8fb856695 100644 --- a/homeassistant/components/forked_daapd/translations/id.json +++ b/homeassistant/components/forked_daapd/translations/id.json @@ -12,7 +12,7 @@ "wrong_password": "Kata sandi salah.", "wrong_server_type": "Integrasi forked-daapd membutuhkan server forked-daapd dengan versi >= 27.0." }, - "flow_title": "forked-daapd server: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/it.json b/homeassistant/components/forked_daapd/translations/it.json index d4303f6dba631..a5a1d0922815c 100644 --- a/homeassistant/components/forked_daapd/translations/it.json +++ b/homeassistant/components/forked_daapd/translations/it.json @@ -5,23 +5,23 @@ "not_forked_daapd": "Il dispositivo non \u00e8 un server forked-daapd." }, "error": { - "forbidden": "Impossibile connettersi. Si prega di controllare i permessi di rete forked-daapd.", + "forbidden": "Impossibile connettersi. Controlla i permessi di rete forked-daapd.", "unknown_error": "Errore imprevisto", "websocket_not_enabled": "websocket del server forked-daapd non abilitato.", - "wrong_host_or_port": "Impossibile connettersi. Si prega di controllare host e porta.", + "wrong_host_or_port": "Impossibile connettersi. Controlla host e porta.", "wrong_password": "Password errata", "wrong_server_type": "L'integrazione forked-daapd richiede un server forked-daapd con versione >= 27.0." }, - "flow_title": "server forked-daapd: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { "host": "Host", "name": "Nome descrittivo", - "password": "Password API (lasciare vuota se non c'\u00e8 password)", + "password": "Password API (lascia vuota se non c'\u00e8 password)", "port": "Porta API" }, - "title": "Configurare il dispositivo forked-daapd" + "title": "Configura il dispositivo forked-daapd" } } }, @@ -34,7 +34,7 @@ "tts_pause_time": "Secondi di pausa prima e dopo il TTS", "tts_volume": "Volume TTS (variabile nell'intervallo [0,1])" }, - "description": "Impostare le varie opzioni per l'integrazione forked-daapd.", + "description": "Imposta le varie opzioni per l'integrazione forked-daapd.", "title": "Configura le opzioni forked-daapd" } } diff --git a/homeassistant/components/forked_daapd/translations/ja.json b/homeassistant/components/forked_daapd/translations/ja.json new file mode 100644 index 0000000000000..692b7ca8346f3 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/ja.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "not_forked_daapd": "\u30c7\u30d0\u30a4\u30b9\u306f\u3001forked-daapd server\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "forbidden": "\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3002forked-daapd network\u306e\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30d1\u30fc\u30df\u30c3\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown_error": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "websocket_not_enabled": "forked-daapd server\u306eWebSocket\u304c\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u305b\u3093\u3002", + "wrong_host_or_port": "\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3002\u30db\u30b9\u30c8\u3068\u30dd\u30fc\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "wrong_password": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002", + "wrong_server_type": "forked-daapd \u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306b\u306f\u3001\u30d0\u30fc\u30b8\u30e7\u30f3 >= 27.0 \u306eforked-daapd\u30b5\u30fc\u30d0\u30fc\u304c\u5fc5\u8981\u3067\u3059\u3002" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u5206\u304b\u308a\u3084\u3059\u3044\u540d\u524d(Friendly name)", + "password": "API\u30d1\u30b9\u30ef\u30fc\u30c9(\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u306a\u3044\u5834\u5408\u306f\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059)", + "port": "API\u30dd\u30fc\u30c8" + }, + "title": "forked-daapd\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "librespot-java\u30d1\u30a4\u30d7\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u7528\u30dd\u30fc\u30c8(\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u5834\u5408)", + "max_playlists": "\u30bd\u30fc\u30b9\u3068\u3057\u3066\u4f7f\u7528\u3055\u308c\u308b\u30d7\u30ec\u30a4\u30ea\u30b9\u30c8\u306e\u6700\u5927\u6570", + "tts_pause_time": "TTS\u306e\u524d\u5f8c\u3067\u4e00\u6642\u505c\u6b62\u3059\u308b\u79d2\u6570", + "tts_volume": "TTS\u30dc\u30ea\u30e5\u30fc\u30e0(\u7bc4\u56f2\u306f\u3001[0,1]\u306e\u5c0f\u6570\u70b9)" + }, + "description": "forked-daapd\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u3055\u307e\u3056\u307e\u306a\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002", + "title": "forked-daapd\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/nl.json b/homeassistant/components/forked_daapd/translations/nl.json index 7eec6a34571ed..1dfb4c56eab72 100644 --- a/homeassistant/components/forked_daapd/translations/nl.json +++ b/homeassistant/components/forked_daapd/translations/nl.json @@ -12,7 +12,7 @@ "wrong_password": "Onjuist wachtwoord.", "wrong_server_type": "De forked-daapd-integratie vereist een forked-daapd-server met versie >= 27.0." }, - "flow_title": "forked-daapd server: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/no.json b/homeassistant/components/forked_daapd/translations/no.json index da260c9a019bc..4461101cbc235 100644 --- a/homeassistant/components/forked_daapd/translations/no.json +++ b/homeassistant/components/forked_daapd/translations/no.json @@ -12,7 +12,7 @@ "wrong_password": "Feil passord.", "wrong_server_type": "Forked-daapd integrasjon krever en gaffel-daapd server med versjon \"= 27.0." }, - "flow_title": "", + "flow_title": "{name} ( {host} )", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/pl.json b/homeassistant/components/forked_daapd/translations/pl.json index 781dd9fca6b95..bb20159bd1a75 100644 --- a/homeassistant/components/forked_daapd/translations/pl.json +++ b/homeassistant/components/forked_daapd/translations/pl.json @@ -12,7 +12,7 @@ "wrong_password": "Nieprawid\u0142owe has\u0142o", "wrong_server_type": "Integracja forked-daapd wymaga serwera forked-daapd w wersji >= 27.0" }, - "flow_title": "Serwer forked-daapd: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/ru.json b/homeassistant/components/forked_daapd/translations/ru.json index 58d574b405403..3850c895353a1 100644 --- a/homeassistant/components/forked_daapd/translations/ru.json +++ b/homeassistant/components/forked_daapd/translations/ru.json @@ -12,7 +12,7 @@ "wrong_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c.", "wrong_server_type": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0441\u0435\u0440\u0432\u0435\u0440 forked-daapd \u0432\u0435\u0440\u0441\u0438\u0438 27.0 \u0438\u043b\u0438 \u0432\u044b\u0448\u0435." }, - "flow_title": "\u0421\u0435\u0440\u0432\u0435\u0440 forked-daapd: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/forked_daapd/translations/tr.json b/homeassistant/components/forked_daapd/translations/tr.json index cf354c5c87f54..6c838e9305569 100644 --- a/homeassistant/components/forked_daapd/translations/tr.json +++ b/homeassistant/components/forked_daapd/translations/tr.json @@ -1,20 +1,41 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "not_forked_daapd": "Cihaz, forked-daapd sunucusu de\u011fil." }, "error": { + "forbidden": "Ba\u011flan\u0131lam\u0131yor. L\u00fctfen forked-daapd a\u011f izinlerinizi kontrol edin.", "unknown_error": "Beklenmeyen hata", - "wrong_password": "Yanl\u0131\u015f parola." + "websocket_not_enabled": "forked-daapd sunucu websocket etkin de\u011fil.", + "wrong_host_or_port": "Ba\u011flan\u0131lam\u0131yor. L\u00fctfen ana bilgisayar\u0131 ve ba\u011flant\u0131 noktas\u0131n\u0131 kontrol edin.", + "wrong_password": "Yanl\u0131\u015f parola.", + "wrong_server_type": "> = 27.0 s\u00fcr\u00fcm\u00fcne sahip bir forked-daapd sunucusu gerektirir." }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "name": "Kolay ad", + "host": "Sunucu", + "name": "Kolay Ad\u0131", "password": "API parolas\u0131 (parola yoksa bo\u015f b\u0131rak\u0131n)", - "port": "API ba\u011flant\u0131 noktas\u0131" - } + "port": "API Port" + }, + "title": "Forked-daapd cihaz\u0131n\u0131 kurun" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "librespot-java boru kontrol\u00fc i\u00e7in ba\u011flant\u0131 noktas\u0131 (kullan\u0131l\u0131yorsa)", + "max_playlists": "Kaynak olarak kullan\u0131lan maksimum oynatma listesi say\u0131s\u0131", + "tts_pause_time": "TTS'den \u00f6nce ve sonra duraklatmak i\u00e7in saniyeler", + "tts_volume": "TTS ses seviyesi (aral\u0131k [0,1])" + }, + "description": "Forked-daapd entegrasyonu i\u00e7in \u00e7e\u015fitli se\u00e7enekleri ayarlay\u0131n.", + "title": "Forked-daapd se\u00e7eneklerini yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/forked_daapd/translations/zh-Hans.json b/homeassistant/components/forked_daapd/translations/zh-Hans.json new file mode 100644 index 0000000000000..9b2bd98139754 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/zh-Hans.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "not_forked_daapd": "\u6b64\u8bbe\u5907\u4e0d\u662f\u4e00\u4e2a forked-daapd \u670d\u52a1\u5668\u3002" + }, + "error": { + "forbidden": "\u65e0\u6cd5\u8fde\u63a5\u3002\u8bf7\u68c0\u67e5\u60a8\u7684 forked-daapd \u7f51\u7edc\u6743\u9650\u3002", + "websocket_not_enabled": "\u672a\u542f\u7528 forked-daapd \u670d\u52a1\u5668\u7684 Websocket \u529f\u80fd\u3002", + "wrong_server_type": "forked-daapd \u96c6\u6210\u9700\u8981 forked-daapd \u670d\u52a1\u5668\u7248\u672c\u53f7\u81f3\u5c11\u5927\u4e8e\u6216\u7b49\u4e8e 27.0 \u3002" + }, + "step": { + "user": { + "title": "\u8bbe\u7f6e forked-daapd \u8bbe\u5907" + } + } + }, + "options": { + "step": { + "init": { + "description": "\u4e3a forked-daapd \u96c6\u6210\u8bbe\u7f6e\u5404\u79cd\u9009\u9879\u3002", + "title": "\u914d\u7f6e forked-daapd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/zh-Hant.json b/homeassistant/components/forked_daapd/translations/zh-Hant.json index 17839b6074899..9d91fb9303317 100644 --- a/homeassistant/components/forked_daapd/translations/zh-Hant.json +++ b/homeassistant/components/forked_daapd/translations/zh-Hant.json @@ -12,7 +12,7 @@ "wrong_password": "\u5bc6\u78bc\u932f\u8aa4\u3002", "wrong_server_type": "forked-daapd \u6574\u5408\u9700\u8981\u7248\u6b21 >= 27.0 \u7248\u4e4b forked-daapd \u4f3a\u670d\u5668\u3002" }, - "flow_title": "forked-daapd \u4f3a\u670d\u5668\uff1a{name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 2b2d14f60e04f..e160268e3fc05 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -5,6 +5,7 @@ """ import logging +from awesomeversion import AwesomeVersion from fortiosapi import FortiOSAPI import voluptuous as vol @@ -46,6 +47,18 @@ def get_scanner(hass, config): _LOGGER.error("Failed to login to FortiOS API: %s", ex) return None + status_json = fgt.monitor("system/status", "") + + current_version = AwesomeVersion(status_json["version"]) + minimum_version = AwesomeVersion("6.4.3") + if current_version < minimum_version: + _LOGGER.error( + "Unsupported FortiOS version: %s. Version %s and newer are supported", + current_version, + minimum_version, + ) + return None + return FortiOSDeviceScanner(fgt) @@ -60,15 +73,18 @@ def __init__(self, fgt) -> None: def update(self): """Update clients from the device.""" - clients_json = self._fgt.monitor("user/device/select", "") + clients_json = self._fgt.monitor("user/device/query", "") self._clients_json = clients_json self._clients = [] if clients_json: - for client in clients_json["results"]: - if client["last_seen"] < 180: - self._clients.append(client["mac"].upper()) + try: + for client in clients_json["results"]: + if client["is_online"]: + self._clients.append(client["mac"].upper()) + except KeyError as kex: + _LOGGER.error("Key not found in clients: %s", kex) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -81,20 +97,22 @@ def get_device_name(self, device): device = device.lower() - data = self._clients_json - - if data == 0: + if (data := self._clients_json) == 0: _LOGGER.error("No json results to get device names") return None for client in data["results"]: if client["mac"] == device: try: - name = client["host"]["name"] + name = client["hostname"] _LOGGER.debug("Getting device name=%s", name) return name except KeyError as kex: - _LOGGER.error("Name not found in client data: %s", kex) - return None + _LOGGER.debug( + "No hostname found for %s in client data: %s", + device, + kex, + ) + return device.replace(":", "_") return None diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json index 251cb900adc65..cc351441cdd56 100644 --- a/homeassistant/components/fortios/manifest.json +++ b/homeassistant/components/fortios/manifest.json @@ -2,7 +2,7 @@ "domain": "fortios", "name": "FortiOS", "documentation": "https://www.home-assistant.io/integrations/fortios/", - "requirements": ["fortiosapi==0.10.8"], + "requirements": ["fortiosapi==1.0.5"], "codeowners": ["@kimfrellsen"], "iot_class": "local_polling" } diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 308b1a3cc9ff1..380e1d1828056 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -3,27 +3,32 @@ from libpyfoscam import FoscamCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import async_migrate_entries from .config_flow import DEFAULT_RTSP_PORT from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET -PLATFORMS = ["camera"] +PLATFORMS = [Platform.CAMERA] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up foscam from a config entry.""" hass.config_entries.async_setup_platforms(entry, PLATFORMS) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = entry.data + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -36,26 +41,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def async_migrate_entry(hass, config_entry: ConfigEntry): +async def async_migrate_entry(hass, entry: ConfigEntry): """Migrate old entry.""" - LOGGER.debug("Migrating from version %s", config_entry.version) + LOGGER.debug("Migrating from version %s", entry.version) - if config_entry.version == 1: + if entry.version == 1: # Change unique id @callback def update_unique_id(entry): - return {"new_unique_id": config_entry.entry_id} + return {"new_unique_id": entry.entry_id} - await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + await async_migrate_entries(hass, entry.entry_id, update_unique_id) - config_entry.unique_id = None + entry.unique_id = None # Get RTSP port from the camera or use the fallback one and store it in data camera = FoscamCamera( - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], verbose=False, ) @@ -66,11 +71,11 @@ def update_unique_id(entry): if ret != 0: rtsp_port = response.get("rtspPort") or response.get("mediaPort") - config_entry.data = {**config_entry.data, CONF_RTSP_PORT: rtsp_port} + entry.data = {**entry.data, CONF_RTSP_PORT: rtsp_port} # Change entry version - config_entry.version = 2 + entry.version = 2 - LOGGER.info("Migration to version %s successful", config_entry.version) + LOGGER.info("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 31ac8c2cad904..4fd3d1d63be9b 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,39 +1,16 @@ """This component provides basic support for Foscam IP cameras.""" +from __future__ import annotations + import asyncio from libpyfoscam import FoscamCamera import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) +from homeassistant.components.camera import SUPPORT_STREAM, Camera +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.helpers import config_validation as cv, entity_platform -from .const import ( - CONF_RTSP_PORT, - CONF_STREAM, - DOMAIN, - LOGGER, - SERVICE_PTZ, - SERVICE_PTZ_PRESET, -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required("ip"): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_NAME, default="Foscam Camera"): cv.string, - vol.Optional(CONF_PORT, default=88): cv.port, - vol.Optional(CONF_RTSP_PORT): cv.port, - } -) +from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET DIR_UP = "up" DIR_DOWN = "down" @@ -65,29 +42,6 @@ PTZ_GOTO_PRESET_COMMAND = "ptz_goto_preset" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up a Foscam IP Camera.""" - LOGGER.warning( - "Loading foscam via platform config is deprecated, it will be automatically imported; Please remove it afterwards" - ) - - config_new = { - CONF_NAME: config[CONF_NAME], - CONF_HOST: config["ip"], - CONF_PORT: config[CONF_PORT], - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_STREAM: "Main", - CONF_RTSP_PORT: config.get(CONF_RTSP_PORT, 554), - } - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config_new - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Add a Foscam IP camera from a config entry.""" platform = entity_platform.async_get_current_platform() @@ -172,7 +126,9 @@ def unique_id(self): """Return the entity unique ID.""" return self._unique_id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 23d5b335edcd9..8d19220130d75 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -47,13 +47,9 @@ async def _validate_and_create(self, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - - for entry in self.hass.config_entries.async_entries(DOMAIN): - if ( - entry.data[CONF_HOST] == data[CONF_HOST] - and entry.data[CONF_PORT] == data[CONF_PORT] - ): - raise AbortFlow("already_configured") + self._async_abort_entries_match( + {CONF_HOST: data[CONF_HOST], CONF_PORT: data[CONF_PORT]} + ) camera = FoscamCamera( data[CONF_HOST], @@ -120,34 +116,6 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_config): - """Handle config import from yaml.""" - try: - return await self._validate_and_create(import_config) - - except CannotConnect: - LOGGER.error("Error importing foscam platform config: cannot connect") - return self.async_abort(reason="cannot_connect") - - except InvalidAuth: - LOGGER.error("Error importing foscam platform config: invalid auth") - return self.async_abort(reason="invalid_auth") - - except InvalidResponse: - LOGGER.exception( - "Error importing foscam platform config: invalid response from camera" - ) - return self.async_abort(reason="invalid_response") - - except AbortFlow: - raise - - except Exception: # pylint: disable=broad-except - LOGGER.exception( - "Error importing foscam platform config: unexpected exception" - ) - return self.async_abort(reason="unknown") - class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/foscam/services.yaml b/homeassistant/components/foscam/services.yaml index 41563635f68a0..04dba8ccbaa07 100644 --- a/homeassistant/components/foscam/services.yaml +++ b/homeassistant/components/foscam/services.yaml @@ -1,22 +1,46 @@ ptz: + name: PTZ description: Pan/Tilt service for Foscam camera. + target: + entity: + integration: foscam + domain: camera fields: - entity_id: - description: Name(s) of entities to move. - example: "camera.living_room_camera" movement: - description: "Direction of the movement. Allowed values: up, down, left, right, top_left, top_right, bottom_left, bottom_right." - example: "up" + description: "Direction of the movement." + required: true + selector: + select: + options: + - 'bottom_left' + - 'bottom_right' + - 'down' + - 'left' + - 'right' + - 'top_left' + - 'top_right' + - 'up' travel_time: - description: "(Optional) Travel time in seconds. Allowed values: float from 0 to 1. Default: 0.125" - example: 0.125 + description: "Travel time in seconds." + default: 0.125 + selector: + number: + min: 0 + max: 1 + step: 0.005 + unit_of_measurement: seconds ptz_preset: + name: PTZ preset description: PTZ Preset service for Foscam camera. + target: + entity: + integration: foscam + domain: camera fields: - entity_id: - description: Name(s) of entities to move. - example: "camera.living_room_camera" preset_name: - description: "The name of the preset to move to. Presets can be created from within the official Foscam apps." + description: "The name of the preset to move to. Presets can be created from within the official Foscam apps." + required: true example: "TopMost" + selector: + text: diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 5c0622af9d17b..14aa88b79526e 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -1,5 +1,4 @@ { - "title": "Foscam", "config": { "step": { "user": { diff --git a/homeassistant/components/foscam/translations/de.json b/homeassistant/components/foscam/translations/de.json index d87044b579a80..bc1c12ea13058 100644 --- a/homeassistant/components/foscam/translations/de.json +++ b/homeassistant/components/foscam/translations/de.json @@ -16,6 +16,7 @@ "password": "Passwort", "port": "Port", "rtsp_port": "RTSP-Port", + "stream": "Stream", "username": "Benutzername" } } diff --git a/homeassistant/components/foscam/translations/es-419.json b/homeassistant/components/foscam/translations/es-419.json new file mode 100644 index 0000000000000..39027bdf91485 --- /dev/null +++ b/homeassistant/components/foscam/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_response": "Respuesta no v\u00e1lida del dispositivo" + }, + "step": { + "user": { + "data": { + "rtsp_port": "Puerto RTSP", + "stream": "Stream" + } + } + } + }, + "title": "Foscam" +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/fr.json b/homeassistant/components/foscam/translations/fr.json index 1424c22ad6121..7c0bb8398da55 100644 --- a/homeassistant/components/foscam/translations/fr.json +++ b/homeassistant/components/foscam/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connection", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "invalid_response": "R\u00e9ponse invalide de l\u2019appareil", "unknown": "Erreur inattendue" diff --git a/homeassistant/components/foscam/translations/he.json b/homeassistant/components/foscam/translations/he.json new file mode 100644 index 0000000000000..4f3eeb63e8c46 --- /dev/null +++ b/homeassistant/components/foscam/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/hu.json b/homeassistant/components/foscam/translations/hu.json index 63ea95210ffa7..b303db792bbfc 100644 --- a/homeassistant/components/foscam/translations/hu.json +++ b/homeassistant/components/foscam/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "rtsp_port": "RTSP port", diff --git a/homeassistant/components/foscam/translations/ja.json b/homeassistant/components/foscam/translations/ja.json new file mode 100644 index 0000000000000..5a02ae5f4463d --- /dev/null +++ b/homeassistant/components/foscam/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_response": "\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u306e\u7121\u52b9\u306a\u5fdc\u7b54", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "rtsp_port": "RTSP\u30dd\u30fc\u30c8", + "stream": "\u30b9\u30c8\u30ea\u30fc\u30e0", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "title": "Foscam" +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/tr.json b/homeassistant/components/foscam/translations/tr.json index b3e964ae08eda..e6e5adc434c24 100644 --- a/homeassistant/components/foscam/translations/tr.json +++ b/homeassistant/components/foscam/translations/tr.json @@ -6,14 +6,16 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "unknown": "Beklenmeyen Hata" + "invalid_response": "Cihazdan ge\u00e7ersiz yan\u0131t", + "unknown": "Beklenmeyen hata" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "password": "\u015eifre", + "host": "Sunucu", + "password": "Parola", "port": "Port", + "rtsp_port": "RTSP port", "stream": "Ak\u0131\u015f", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 6f33c9ff5912f..59f3811a14b1c 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -1,16 +1,12 @@ """Support for the Foursquare (Swarm) API.""" +from http import HTTPStatus import logging import requests import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - HTTP_BAD_REQUEST, - HTTP_CREATED, - HTTP_OK, -) +from homeassistant.const import CONF_ACCESS_TOKEN import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -60,7 +56,7 @@ def checkin_user(call): url = f"https://api.foursquare.com/v2/checkins/add?oauth_token={config[CONF_ACCESS_TOKEN]}&v=20160802&m=swarm" response = requests.post(url, data=call.data, timeout=10) - if response.status_code not in (HTTP_OK, HTTP_CREATED): + if response.status_code not in (HTTPStatus.OK, HTTPStatus.CREATED): _LOGGER.exception( "Error checking in user. Response %d: %s:", response.status_code, @@ -95,7 +91,7 @@ async def post(self, request): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) secret = data.pop("secret", None) @@ -105,6 +101,6 @@ async def post(self, request): _LOGGER.error( "Received Foursquare push with invalid push secret: %s", secret ) - return self.json_message("Incorrect secret", HTTP_BAD_REQUEST) + return self.json_message("Incorrect secret", HTTPStatus.BAD_REQUEST) request.app["hass"].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/foursquare/services.yaml b/homeassistant/components/foursquare/services.yaml index 0fcc077c7d3cc..5e103caeb015e 100644 --- a/homeassistant/components/foursquare/services.yaml +++ b/homeassistant/components/foursquare/services.yaml @@ -1,33 +1,52 @@ checkin: + name: Check in description: Check a user into a Foursquare venue. fields: alt: - description: Altitude of the user's location, in meters. [Optional] + name: Altitude + description: Altitude of the user's location, in meters. example: 0 + selector: + text: altAcc: + name: Altitude accuracy description: Vertical accuracy of the user's location, in meters. example: 1 + selector: + text: broadcast: + name: Broadcast description: >- Who to broadcast this check-in to. Accepts a comma-delimited list of values: private (off the grid) or public (share with friends), facebook share on facebook, twitter share on twitter, followers share with followers (celebrity mode users only), If no valid value is found, the default is public. - [Optional] example: "public,twitter" + selector: + text: eventId: - description: The event the user is checking in to. [Optional] + name: Event ID + description: The event the user is checking in to. example: UHR8THISVNT + selector: + text: ll: + name: Latitude/Longitude description: >- Latitude and longitude of the user's location. Only specify this field if you have a GPS or other device reported location for the user - at the time of check-in. [Optional] + at the time of check-in. example: "33.7,44.2" + selector: + text: llAcc: - description: Accuracy of the user's latitude and longitude, in meters. [Optional] + name: Latitude/Longitude accuracy + description: Accuracy of the user's latitude and longitude, in meters. example: 1 + selector: + text: mentions: + name: Mentions description: >- Mentions in your check-in. This parameter is a semicolon-delimited list of mentions. A single mention is of the form "start,end,userid", where @@ -37,9 +56,18 @@ checkin: "fbu-", this indicates a Facebook userid that is being mention. Character indices in shouts are 0-based. [Optional] example: "5,10,HZXXY3Y;15,20,GZYYZ3Z;25,30,fbu-GZXY13Y" + selector: + text: shout: - description: A message about your check-in. The maximum length of this field is 140 characters. [Optional] + name: Shout + description: A message about your check-in. The maximum length of this field is 140 characters. example: There are crayons! Crayons! + selector: + text: venueId: - description: The Foursquare venue where the user is checking in. [Required] + name: Venue ID + description: The Foursquare venue where the user is checking in. + required: true example: IHR8THISVNU + selector: + text: diff --git a/homeassistant/components/free_mobile/manifest.json b/homeassistant/components/free_mobile/manifest.json index ea6ea921a38a8..7fb7f9986432d 100644 --- a/homeassistant/components/free_mobile/manifest.json +++ b/homeassistant/components/free_mobile/manifest.json @@ -2,7 +2,7 @@ "domain": "free_mobile", "name": "Free Mobile", "documentation": "https://www.home-assistant.io/integrations/free_mobile", - "requirements": ["freesms==0.1.2"], + "requirements": ["freesms==0.2.0"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py index a4351bfe67884..6173323780725 100644 --- a/homeassistant/components/free_mobile/notify.py +++ b/homeassistant/components/free_mobile/notify.py @@ -1,17 +1,12 @@ """Support for Free Mobile SMS platform.""" +from http import HTTPStatus import logging from freesms import FreeClient import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_USERNAME, - HTTP_BAD_REQUEST, - HTTP_FORBIDDEN, - HTTP_INTERNAL_SERVER_ERROR, -) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,11 +32,11 @@ def send_message(self, message="", **kwargs): """Send a message to the Free Mobile user cell.""" resp = self.free_client.send_sms(message) - if resp.status_code == HTTP_BAD_REQUEST: + if resp.status_code == HTTPStatus.BAD_REQUEST: _LOGGER.error("At least one parameter is missing") - elif resp.status_code == 402: + elif resp.status_code == HTTPStatus.PAYMENT_REQUIRED: _LOGGER.error("Too much SMS send in a few time") - elif resp.status_code == HTTP_FORBIDDEN: + elif resp.status_code == HTTPStatus.FORBIDDEN: _LOGGER.error("Wrong Username/Password") - elif resp.status_code == HTTP_INTERNAL_SERVER_ERROR: + elif resp.status_code == HTTPStatus.INTERNAL_SERVER_ERROR: _LOGGER.error("Server error, try later") diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 44816a5c8ae3e..c343e8d629c56 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -39,7 +39,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Freebox entry.""" router = FreeboxRouter(hass, entry) await router.setup() @@ -67,7 +67,7 @@ async def async_close_connection(event): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 0e10a8328c5e6..f0a7801823e1c 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -5,7 +5,9 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .router import get_api @@ -105,8 +107,11 @@ async def async_step_import(self, user_input=None): """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_zeroconf(self, discovery_info: dict): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Initialize flow from zeroconf.""" - host = discovery_info["properties"]["api_domain"] - port = discovery_info["properties"]["https_port"] + zeroconf_properties = discovery_info.properties + host = zeroconf_properties["api_domain"] + port = zeroconf_properties["https_port"] return await self.async_step_user({CONF_HOST: host, CONF_PORT: port}) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index df251dcf95479..77f36cf44deb2 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -1,12 +1,10 @@ """Freebox component constants.""" +from __future__ import annotations + import socket -from homeassistant.const import ( - DATA_RATE_KILOBYTES_PER_SECOND, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_CELSIUS, -) +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND, PERCENTAGE, Platform DOMAIN = "freebox" SERVICE_REBOOT = "reboot" @@ -19,7 +17,7 @@ } API_VERSION = "v6" -PLATFORMS = ["device_tracker", "sensor", "switch"] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH] DEFAULT_DEVICE_NAME = "Unknown device" @@ -27,51 +25,39 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -# Sensor -SENSOR_NAME = "name" -SENSOR_UNIT = "unit" -SENSOR_ICON = "icon" -SENSOR_DEVICE_CLASS = "device_class" -CONNECTION_SENSORS = { - "rate_down": { - SENSOR_NAME: "Freebox download speed", - SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - SENSOR_ICON: "mdi:download-network", - SENSOR_DEVICE_CLASS: None, - }, - "rate_up": { - SENSOR_NAME: "Freebox upload speed", - SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - SENSOR_ICON: "mdi:upload-network", - SENSOR_DEVICE_CLASS: None, - }, -} - -CALL_SENSORS = { - "missed": { - SENSOR_NAME: "Freebox missed calls", - SENSOR_UNIT: None, - SENSOR_ICON: "mdi:phone-missed", - SENSOR_DEVICE_CLASS: None, - }, -} +CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="rate_down", + name="Freebox download speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download-network", + ), + SensorEntityDescription( + key="rate_up", + name="Freebox upload speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload-network", + ), +) +CONNECTION_SENSORS_KEYS: list[str] = [desc.key for desc in CONNECTION_SENSORS] -DISK_PARTITION_SENSORS = { - "partition_free_space": { - SENSOR_NAME: "free space", - SENSOR_UNIT: PERCENTAGE, - SENSOR_ICON: "mdi:harddisk", - SENSOR_DEVICE_CLASS: None, - }, -} +CALL_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="missed", + name="Freebox missed calls", + icon="mdi:phone-missed", + ), +) -TEMPERATURE_SENSOR_TEMPLATE = { - SENSOR_NAME: None, - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_ICON: "mdi:thermometer", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, -} +DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="partition_free_space", + name="free space", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + ), +) # Icons DEVICE_ICONS = { diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 2ad262dd2bd4b..38a781c8c1295 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -114,12 +114,12 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def device_info(self) -> DeviceInfo: """Return the device information.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": self._manufacturer, - } + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._mac)}, + identifiers={(DOMAIN, self.unique_id)}, + manufacturer=self._manufacturer, + name=self.name, + ) @property def should_poll(self) -> bool: diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 9438b3eadc6a2..e352146915e94 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -24,7 +24,7 @@ from .const import ( API_VERSION, APP_DESC, - CONNECTION_SENSORS, + CONNECTION_SENSORS_KEYS, DOMAIN, STORAGE_KEY, STORAGE_VERSION, @@ -141,7 +141,7 @@ async def update_sensors(self) -> None: # Connection sensors connection_datas: dict[str, Any] = await self._api.connection.get_status() - for sensor_key in CONNECTION_SENSORS: + for sensor_key in CONNECTION_SENSORS_KEYS: self.sensors_connection[sensor_key] = connection_datas[sensor_key] self._attrs = { @@ -183,13 +183,14 @@ async def close(self) -> None: @property def device_info(self) -> DeviceInfo: """Return the device information.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self.mac)}, - "identifiers": {(DOMAIN, self.mac)}, - "name": self.name, - "manufacturer": "Freebox SAS", - "sw_version": self._sw_v, - } + return DeviceInfo( + configuration_url=f"https://{self._host}:{self._port}/", + connections={(CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.mac)}, + manufacturer="Freebox SAS", + name=self.name, + sw_version=self._sw_v, + ) @property def signal_device_new(self) -> str: diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 8c4e611827efb..6286fef71e517 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -4,25 +4,19 @@ import logging from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND +from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo import homeassistant.util.dt as dt_util -from .const import ( - CALL_SENSORS, - CONNECTION_SENSORS, - DISK_PARTITION_SENSORS, - DOMAIN, - SENSOR_DEVICE_CLASS, - SENSOR_ICON, - SENSOR_NAME, - SENSOR_UNIT, - TEMPERATURE_SENSOR_TEMPLATE, -) +from .const import CALL_SENSORS, CONNECTION_SENSORS, DISK_PARTITION_SENSORS, DOMAIN from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -41,36 +35,33 @@ async def async_setup_entry( router.mac, len(router.sensors_temperature), ) - for sensor_name in router.sensors_temperature: - entities.append( - FreeboxSensor( - router, - sensor_name, - {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"}, - ) - ) - - for sensor_key in CONNECTION_SENSORS: - entities.append( - FreeboxSensor(router, sensor_key, CONNECTION_SENSORS[sensor_key]) + entities = [ + FreeboxSensor( + router, + SensorEntityDescription( + key=sensor_name, + name=f"Freebox {sensor_name}", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), ) + for sensor_name in router.sensors_temperature + ] - for sensor_key in CALL_SENSORS: - entities.append(FreeboxCallSensor(router, sensor_key, CALL_SENSORS[sensor_key])) + entities.extend( + [FreeboxSensor(router, description) for description in CONNECTION_SENSORS] + ) + entities.extend( + [FreeboxCallSensor(router, description) for description in CALL_SENSORS] + ) _LOGGER.debug("%s - %s - %s disk(s)", router.name, router.mac, len(router.disks)) - for disk in router.disks.values(): - for partition in disk["partitions"]: - for sensor_key in DISK_PARTITION_SENSORS: - entities.append( - FreeboxDiskSensor( - router, - disk, - partition, - sensor_key, - DISK_PARTITION_SENSORS[sensor_key], - ) - ) + entities.extend( + FreeboxDiskSensor(router, disk, partition, description) + for disk in router.disks.values() + for partition in disk["partitions"] + for description in DISK_PARTITION_SENSORS + ) async_add_entities(entities, True) @@ -78,68 +69,30 @@ async def async_setup_entry( class FreeboxSensor(SensorEntity): """Representation of a Freebox sensor.""" + _attr_should_poll = False + def __init__( - self, router: FreeboxRouter, sensor_type: str, sensor: dict[str, Any] + self, router: FreeboxRouter, description: SensorEntityDescription ) -> None: """Initialize a Freebox sensor.""" - self._state = None + self.entity_description = description self._router = router - self._sensor_type = sensor_type - self._name = sensor[SENSOR_NAME] - self._unit = sensor[SENSOR_UNIT] - self._icon = sensor[SENSOR_ICON] - self._device_class = sensor[SENSOR_DEVICE_CLASS] - self._unique_id = f"{self._router.mac} {self._name}" + self._attr_unique_id = f"{router.mac} {description.name}" @callback def async_update_state(self) -> None: """Update the Freebox sensor.""" - state = self._router.sensors[self._sensor_type] - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: - self._state = round(state / 1000, 2) + state = self._router.sensors[self.entity_description.key] + if self.native_unit_of_measurement == DATA_RATE_KILOBYTES_PER_SECOND: + self._attr_native_value = round(state / 1000, 2) else: - self._state = state - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def state(self) -> str: - """Return the state.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit.""" - return self._unit - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def device_class(self) -> str: - """Return the device_class.""" - return self._device_class + self._attr_native_value = state @property def device_info(self) -> DeviceInfo: """Return the device information.""" return self._router.device_info - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - @callback def async_on_demand_update(self): """Update state.""" @@ -162,10 +115,10 @@ class FreeboxCallSensor(FreeboxSensor): """Representation of a Freebox call sensor.""" def __init__( - self, router: FreeboxRouter, sensor_type: str, sensor: dict[str, Any] + self, router: FreeboxRouter, description: SensorEntityDescription ) -> None: """Initialize a Freebox call sensor.""" - super().__init__(router, sensor_type, sensor) + super().__init__(router, description) self._call_list_for_type = [] @callback @@ -176,10 +129,10 @@ def async_update_state(self) -> None: for call in self._router.call_list: if not call["new"]: continue - if call["type"] == self._sensor_type: + if self.entity_description.key == call["type"]: self._call_list_for_type.append(call) - self._state = len(self._call_list_for_type) + self._attr_native_value = len(self._call_list_for_type) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -198,33 +151,35 @@ def __init__( router: FreeboxRouter, disk: dict[str, Any], partition: dict[str, Any], - sensor_type: str, - sensor: dict[str, Any], + description: SensorEntityDescription, ) -> None: """Initialize a Freebox disk sensor.""" - super().__init__(router, sensor_type, sensor) + super().__init__(router, description) self._disk = disk self._partition = partition - self._name = f"{partition['label']} {sensor[SENSOR_NAME]}" - self._unique_id = f"{self._router.mac} {sensor_type} {self._disk['id']} {self._partition['id']}" + self._attr_name = f"{partition['label']} {description.name}" + self._unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}" @property def device_info(self) -> DeviceInfo: """Return the device information.""" - return { - "identifiers": {(DOMAIN, self._disk["id"])}, - "name": f"Disk {self._disk['id']}", - "model": self._disk["model"], - "sw_version": self._disk["firmware"], - "via_device": ( + return DeviceInfo( + identifiers={(DOMAIN, self._disk["id"])}, + model=self._disk["model"], + name=f"Disk {self._disk['id']}", + sw_version=self._disk["firmware"], + via_device=( DOMAIN, self._router.mac, ), - } + ) @callback def async_update_state(self) -> None: """Update the Freebox disk sensor.""" - self._state = round( - self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 - ) + value = None + if self._partition.get("total_bytes"): + value = round( + self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 + ) + self._attr_native_value = value diff --git a/homeassistant/components/freebox/services.yaml b/homeassistant/components/freebox/services.yaml index be7afa60562bf..7b2a4059434ae 100644 --- a/homeassistant/components/freebox/services.yaml +++ b/homeassistant/components/freebox/services.yaml @@ -1,5 +1,5 @@ # Freebox service entries description. reboot: - # Description of the service + name: Reboot description: Reboots the Freebox. diff --git a/homeassistant/components/freebox/translations/bg.json b/homeassistant/components/freebox/translations/bg.json new file mode 100644 index 0000000000000..c8526b8367dbf --- /dev/null +++ b/homeassistant/components/freebox/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" + }, + "step": { + "user": { + "data": { + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/de.json b/homeassistant/components/freebox/translations/de.json index 738b9d48f3cdc..50644a8798247 100644 --- a/homeassistant/components/freebox/translations/de.json +++ b/homeassistant/components/freebox/translations/de.json @@ -10,7 +10,7 @@ }, "step": { "link": { - "description": "Klicken Sie auf \"Senden\" und ber\u00fchren Sie dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router]\n (/static/images/config_freebox.png)", + "description": "Klicke auf \"Senden\" und ber\u00fchre dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router](/static/images/config_freebox.png)", "title": "Link Freebox Router" }, "user": { diff --git a/homeassistant/components/freebox/translations/fr.json b/homeassistant/components/freebox/translations/fr.json index f06cfed6cd769..7b459ebc0ab33 100644 --- a/homeassistant/components/freebox/translations/fr.json +++ b/homeassistant/components/freebox/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "register_failed": "\u00c9chec de l'inscription, veuillez r\u00e9essayer", - "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + "unknown": "Erreur inattendue" }, "step": { "link": { @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freebox/translations/he.json b/homeassistant/components/freebox/translations/he.json new file mode 100644 index 0000000000000..58521f503e2bd --- /dev/null +++ b/homeassistant/components/freebox/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/hu.json b/homeassistant/components/freebox/translations/hu.json index 1f0b848d3b6ec..873e1057c159c 100644 --- a/homeassistant/components/freebox/translations/hu.json +++ b/homeassistant/components/freebox/translations/hu.json @@ -9,9 +9,13 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "link": { + "description": "Kattintson a \u201eK\u00fcld\u00e9s\u201d gombra, majd \u00e9rintse meg a jobbra mutat\u00f3 nyilat az \u00fatv\u00e1laszt\u00f3n a Freebox regisztr\u00e1l\u00e1s\u00e1hoz Home Assistant seg\u00edts\u00e9g\u00e9vel. \n\n![A gomb helye a routeren] (/static/images/config_freebox.png)", + "title": "Freebox \u00fatv\u00e1laszt\u00f3 linkel\u00e9se" + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freebox/translations/it.json b/homeassistant/components/freebox/translations/it.json index 7dd5e279a8727..4eb49d5fdfbf7 100644 --- a/homeassistant/components/freebox/translations/it.json +++ b/homeassistant/components/freebox/translations/it.json @@ -10,7 +10,7 @@ }, "step": { "link": { - "description": "Fare clic su \"Invia\", quindi toccare la freccia destra sul router per registrare Freebox con Home Assistant.\n\n![Posizione del pulsante sul router](/static/images/config_freebox.png)", + "description": "Fai clic su \"Invia\", quindi tocca la freccia destra sul router per registrare Freebox con Home Assistant.\n\n![Posizione del pulsante sul router](/static/images/config_freebox.png)", "title": "Collega il router Freebox" }, "user": { diff --git a/homeassistant/components/freebox/translations/ja.json b/homeassistant/components/freebox/translations/ja.json new file mode 100644 index 0000000000000..fa11e1c482207 --- /dev/null +++ b/homeassistant/components/freebox/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "register_failed": "\u767b\u9332\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "link": { + "description": "\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3057\u3001\u30eb\u30fc\u30bf\u30fc\u306e\u53f3\u77e2\u5370\u3092\u30bf\u30c3\u30c1\u3057\u3066\u3001Freebox\u3092Home Assistant\u306b\u767b\u9332\u3057\u307e\u3059\u3002 \n\n\uff01[\u30eb\u30fc\u30bf\u30fc\u306e\u30dc\u30bf\u30f3\u306e\u5834\u6240](/static/images/config_freebox.png)", + "title": "Freebox router\u306b\u30ea\u30f3\u30af" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/tr.json b/homeassistant/components/freebox/translations/tr.json index b675d38057dc6..6690b2a5b238d 100644 --- a/homeassistant/components/freebox/translations/tr.json +++ b/homeassistant/components/freebox/translations/tr.json @@ -5,12 +5,17 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "register_failed": "Kay\u0131t ba\u015far\u0131s\u0131z oldu, l\u00fctfen tekrar deneyin", "unknown": "Beklenmeyen hata" }, "step": { + "link": { + "description": "\"G\u00f6nder\"e t\u0131klay\u0131n, ard\u0131ndan Freebox'\u0131 Home Assistant ile kaydetmek i\u00e7in y\u00f6nlendiricideki sa\u011f oka dokunun. \n\n ![Y\u00f6nlendiricideki d\u00fc\u011fmenin konumu](/static/images/config_freebox.png)", + "title": "Freebox y\u00f6nlendiriciyi ba\u011fla" + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 7aa34c8780e88..754e6cb881868 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -72,7 +72,7 @@ async def _update_freedns(hass, session, url, auth_token): params[auth_token] = "" try: - with async_timeout.timeout(TIMEOUT): + async with async_timeout.timeout(TIMEOUT): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py new file mode 100644 index 0000000000000..327b6314a3454 --- /dev/null +++ b/homeassistant/components/freedompro/__init__.py @@ -0,0 +1,96 @@ +"""Support for freedompro.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from pyfreedompro import get_list, get_states + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: Final[list[Platform]] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Freedompro from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + api_key = entry.data[CONF_API_KEY] + + coordinator = FreedomproDataUpdateCoordinator(hass, api_key) + await coordinator.async_config_entry_first_refresh() + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass, config_entry): + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +class FreedomproDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Freedompro data API.""" + + def __init__(self, hass, api_key): + """Initialize.""" + self._hass = hass + self._api_key = api_key + self._devices = None + + update_interval = timedelta(minutes=1) + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + if self._devices is None: + result = await get_list( + aiohttp_client.async_get_clientsession(self._hass), self._api_key + ) + if result["state"]: + self._devices = result["devices"] + else: + raise UpdateFailed() + + result = await get_states( + aiohttp_client.async_get_clientsession(self._hass), self._api_key + ) + + for device in self._devices: + dev = next( + (dev for dev in result if dev["uid"] == device["uid"]), + None, + ) + if dev is not None and "state" in dev: + device["state"] = dev["state"] + return self._devices diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py new file mode 100644 index 0000000000000..b69f942e532a1 --- /dev/null +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -0,0 +1,77 @@ +"""Support for Freedompro binary_sensor.""" +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +DEVICE_CLASS_MAP = { + "smokeSensor": BinarySensorDeviceClass.SMOKE, + "occupancySensor": BinarySensorDeviceClass.OCCUPANCY, + "motionSensor": BinarySensorDeviceClass.MOTION, + "contactSensor": BinarySensorDeviceClass.OPENING, +} + +DEVICE_KEY_MAP = { + "smokeSensor": "smokeDetected", + "occupancySensor": "occupancyDetected", + "motionSensor": "motionDetected", + "contactSensor": "contactSensorState", +} + +SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactSensor"} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro binary_sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(device, coordinator) + for device in coordinator.data + if device["type"] in SUPPORTED_SENSORS + ) + + +class Device(CoordinatorEntity, BinarySensorEntity): + """Representation of an Freedompro binary_sensor.""" + + def __init__(self, device, coordinator): + """Initialize the Freedompro binary_sensor.""" + super().__init__(coordinator) + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._type = device["type"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + self._attr_is_on = state[DEVICE_KEY_MAP[self._type]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py new file mode 100644 index 0000000000000..1707ee4a88412 --- /dev/null +++ b/homeassistant/components/freedompro/climate.py @@ -0,0 +1,139 @@ +"""Support for Freedompro climate.""" +import json +import logging + +from pyfreedompro import put_state + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +HVAC_MAP = { + 0: HVAC_MODE_OFF, + 1: HVAC_MODE_HEAT, + 2: HVAC_MODE_COOL, +} + +HVAC_INVERT_MAP = {v: k for k, v in HVAC_MAP.items()} + +SUPPORTED_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro climate.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device( + aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator + ) + for device in coordinator.data + if device["type"] == "thermostat" + ) + + +class Device(CoordinatorEntity, ClimateEntity): + """Representation of an Freedompro climate.""" + + _attr_hvac_modes = SUPPORTED_HVAC_MODES + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, session, api_key, device, coordinator): + """Initialize the Freedompro climate.""" + super().__init__(coordinator) + self._session = session + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._characteristics = device["characteristics"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_current_temperature = 0 + self._attr_target_temperature = 0 + self._attr_hvac_mode = HVAC_MODE_OFF + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self._attr_unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "currentTemperature" in state: + self._attr_current_temperature = state["currentTemperature"] + if "targetTemperature" in state: + self._attr_target_temperature = state["targetTemperature"] + if "heatingCoolingState" in state: + self._attr_hvac_mode = HVAC_MAP[state["heatingCoolingState"]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_set_hvac_mode(self, hvac_mode): + """Async function to set mode to climate.""" + if hvac_mode not in SUPPORTED_HVAC_MODES: + raise ValueError(f"Got unsupported hvac_mode {hvac_mode}") + + payload = {} + payload["heatingCoolingState"] = HVAC_INVERT_MAP[hvac_mode] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs): + """Async function to set temperarture to climate.""" + payload = {} + if ATTR_HVAC_MODE in kwargs: + if kwargs[ATTR_HVAC_MODE] not in SUPPORTED_HVAC_MODES: + _LOGGER.error( + "Got unsupported hvac_mode %s, expected one of %s", + kwargs[ATTR_HVAC_MODE], + SUPPORTED_HVAC_MODES, + ) + return + payload["heatingCoolingState"] = HVAC_INVERT_MAP[kwargs[ATTR_HVAC_MODE]] + if ATTR_TEMPERATURE in kwargs: + payload["targetTemperature"] = kwargs[ATTR_TEMPERATURE] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/config_flow.py b/homeassistant/components/freedompro/config_flow.py new file mode 100644 index 0000000000000..c1288e614064d --- /dev/null +++ b/homeassistant/components/freedompro/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow to configure Freedompro.""" +from pyfreedompro import get_list +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class Hub: + """Freedompro Hub class.""" + + def __init__(self, hass, api_key): + """Freedompro Hub class init.""" + self._hass = hass + self._api_key = api_key + + async def authenticate(self): + """Freedompro Hub class authenticate.""" + return await get_list( + aiohttp_client.async_get_clientsession(self._hass), self._api_key + ) + + +async def validate_input(hass: core.HomeAssistant, api_key): + """Validate api key.""" + hub = Hub(hass, api_key) + result = await hub.authenticate() + if result["state"] is False: + if result["code"] == -201: + raise InvalidAuth + if result["code"] == -200: + raise CannotConnect + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Show the setup form to the user.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_create_entry(title="Freedompro", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/freedompro/const.py b/homeassistant/components/freedompro/const.py new file mode 100644 index 0000000000000..3f5df9283d402 --- /dev/null +++ b/homeassistant/components/freedompro/const.py @@ -0,0 +1,3 @@ +"""Constants for the Freedompro integration.""" + +DOMAIN = "freedompro" diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py new file mode 100644 index 0000000000000..2131069eb0e63 --- /dev/null +++ b/homeassistant/components/freedompro/cover.py @@ -0,0 +1,114 @@ +"""Support for Freedompro cover.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.cover import ( + ATTR_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + CoverDeviceClass, + CoverEntity, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +DEVICE_CLASS_MAP = { + "windowCovering": CoverDeviceClass.BLIND, + "gate": CoverDeviceClass.GATE, + "garageDoor": CoverDeviceClass.GARAGE, + "door": CoverDeviceClass.DOOR, + "window": CoverDeviceClass.WINDOW, +} + +SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro cover.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] in SUPPORTED_SENSORS + ) + + +class Device(CoordinatorEntity, CoverEntity): + """Representation of an Freedompro cover.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro cover.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_current_cover_position = 0 + self._attr_is_closed = True + self._attr_supported_features = ( + SUPPORT_CLOSE | SUPPORT_OPEN | SUPPORT_SET_POSITION + ) + self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "position" in state: + self._attr_current_cover_position = state["position"] + if self._attr_current_cover_position == 0: + self._attr_is_closed = True + else: + self._attr_is_closed = False + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self.async_set_cover_position(position=100) + + async def async_close_cover(self, **kwargs): + """Close the cover.""" + await self.async_set_cover_position(position=0) + + async def async_set_cover_position(self, **kwargs): + """Async function to set position to cover.""" + payload = {} + payload["position"] = kwargs[ATTR_POSITION] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py new file mode 100644 index 0000000000000..52c2de85ca6c5 --- /dev/null +++ b/homeassistant/components/freedompro/fan.py @@ -0,0 +1,125 @@ +"""Support for Freedompro fan.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro fan.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + FreedomproFan(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "fan" + ) + + +class FreedomproFan(CoordinatorEntity, FanEntity): + """Representation of an Freedompro fan.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro fan.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._characteristics = device["characteristics"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_is_on = False + self._attr_percentage = 0 + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._attr_is_on + + @property + def percentage(self): + """Return the current speed percentage.""" + return self._attr_percentage + + @property + def supported_features(self): + """Flag supported features.""" + if "rotationSpeed" in self._characteristics: + return SUPPORT_SET_SPEED + return 0 + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + self._attr_is_on = state["on"] + if "rotationSpeed" in state: + self._attr_percentage = state["rotationSpeed"] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): + """Async function to turn on the fan.""" + payload = {"on": True} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Async function to turn off the fan.""" + payload = {"on": False} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int): + """Set the speed percentage of the fan.""" + rotation_speed = {"rotationSpeed": percentage} + payload = json.dumps(rotation_speed) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py new file mode 100644 index 0000000000000..5610a561fdae1 --- /dev/null +++ b/homeassistant/components/freedompro/light.py @@ -0,0 +1,115 @@ +"""Support for Freedompro light.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + LightEntity, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro light.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "lightbulb" + ) + + +class Device(CoordinatorEntity, LightEntity): + """Representation of an Freedompro light.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro light.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_is_on = False + self._attr_brightness = 0 + color_mode = COLOR_MODE_ONOFF + if "hue" in device["characteristics"]: + color_mode = COLOR_MODE_HS + elif "brightness" in device["characteristics"]: + color_mode = COLOR_MODE_BRIGHTNESS + self._attr_color_mode = color_mode + self._attr_supported_color_modes = {color_mode} + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self._attr_unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "on" in state: + self._attr_is_on = state["on"] + if "brightness" in state: + self._attr_brightness = round(state["brightness"] / 100 * 255) + if "hue" in state and "saturation" in state: + self._attr_hs_color = (state["hue"], state["saturation"]) + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_turn_on(self, **kwargs): + """Async function to set on to light.""" + payload = {"on": True} + if ATTR_BRIGHTNESS in kwargs: + payload["brightness"] = round(kwargs[ATTR_BRIGHTNESS] / 255 * 100) + if ATTR_HS_COLOR in kwargs: + payload["saturation"] = round(kwargs[ATTR_HS_COLOR][1]) + payload["hue"] = round(kwargs[ATTR_HS_COLOR][0]) + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self._attr_unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Async function to set off to light.""" + payload = {"on": False} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self._attr_unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py new file mode 100644 index 0000000000000..57486f58d790c --- /dev/null +++ b/homeassistant/components/freedompro/lock.py @@ -0,0 +1,96 @@ +"""Support for Freedompro lock.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.lock import LockEntity +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro lock.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "lock" + ) + + +class Device(CoordinatorEntity, LockEntity): + """Representation of an Freedompro lock.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro lock.""" + super().__init__(coordinator) + self._hass = hass + self._session = aiohttp_client.async_get_clientsession(self._hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._type = device["type"] + self._characteristics = device["characteristics"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=self._type, + name=self.name, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "lock" in state: + if state["lock"] == 1: + self._attr_is_locked = True + else: + self._attr_is_locked = False + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_lock(self, **kwargs): + """Async function to lock the lock.""" + payload = {"lock": 1} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_unlock(self, **kwargs): + """Async function to unlock the lock.""" + payload = {"lock": 0} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/manifest.json b/homeassistant/components/freedompro/manifest.json new file mode 100644 index 0000000000000..94d57b37cae3f --- /dev/null +++ b/homeassistant/components/freedompro/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "freedompro", + "name": "Freedompro", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/freedompro", + "codeowners": [ + "@stefano055415" + ], + "requirements": ["pyfreedompro==1.1.0"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py new file mode 100644 index 0000000000000..04fe7ecddeb87 --- /dev/null +++ b/homeassistant/components/freedompro/sensor.py @@ -0,0 +1,88 @@ +"""Support for Freedompro sensor.""" +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import LIGHT_LUX, PERCENTAGE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +DEVICE_CLASS_MAP = { + "temperatureSensor": SensorDeviceClass.TEMPERATURE, + "humiditySensor": SensorDeviceClass.HUMIDITY, + "lightSensor": SensorDeviceClass.ILLUMINANCE, +} +STATE_CLASS_MAP = { + "temperatureSensor": SensorStateClass.MEASUREMENT, + "humiditySensor": SensorStateClass.MEASUREMENT, + "lightSensor": None, +} +UNIT_MAP = { + "temperatureSensor": TEMP_CELSIUS, + "humiditySensor": PERCENTAGE, + "lightSensor": LIGHT_LUX, +} +DEVICE_KEY_MAP = { + "temperatureSensor": "currentTemperature", + "humiditySensor": "currentRelativeHumidity", + "lightSensor": "currentAmbientLightLevel", +} +SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(device, coordinator) + for device in coordinator.data + if device["type"] in SUPPORTED_SENSORS + ) + + +class Device(CoordinatorEntity, SensorEntity): + """Representation of an Freedompro sensor.""" + + def __init__(self, device, coordinator): + """Initialize the Freedompro sensor.""" + super().__init__(coordinator) + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._type = device["type"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] + self._attr_state_class = STATE_CLASS_MAP[device["type"]] + self._attr_native_unit_of_measurement = UNIT_MAP[device["type"]] + self._attr_native_value = 0 + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + self._attr_native_value = state[DEVICE_KEY_MAP[self._type]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/freedompro/strings.json b/homeassistant/components/freedompro/strings.json new file mode 100644 index 0000000000000..947a9bd2e33b1 --- /dev/null +++ b/homeassistant/components/freedompro/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Please enter the API key obtained from https://home.freedompro.eu", + "title": "Freedompro API key" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py new file mode 100644 index 0000000000000..c44af65ba3231 --- /dev/null +++ b/homeassistant/components/freedompro/switch.py @@ -0,0 +1,91 @@ +"""Support for Freedompro switch.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro switch.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "switch" or device["type"] == "outlet" + ) + + +class Device(CoordinatorEntity, SwitchEntity): + """Representation of an Freedompro switch.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro switch.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Freedompro", + model=device["type"], + name=self.name, + ) + self._attr_is_on = False + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "on" in state: + self._attr_is_on = state["on"] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_turn_on(self, **kwargs): + """Async function to set on to switch.""" + payload = {"on": True} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Async function to set off to switch.""" + payload = {"on": False} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/translations/ar.json b/homeassistant/components/freedompro/translations/ar.json new file mode 100644 index 0000000000000..799f812ecca7c --- /dev/null +++ b/homeassistant/components/freedompro/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "\u0627\u0644\u0631\u062c\u0627\u0621 \u0625\u062f\u062e\u0627\u0644 \u0645\u0641\u062a\u0627\u062d API \u0627\u0644\u0630\u064a \u062a\u0645 \u0627\u0644\u062d\u0635\u0648\u0644 \u0639\u0644\u064a\u0647 \u0645\u0646 https://home.freedompro.eu", + "title": "\u0645\u0641\u062a\u0627\u062d Freedompro API" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/bg.json b/homeassistant/components/freedompro/translations/bg.json new file mode 100644 index 0000000000000..1e5b299d96bde --- /dev/null +++ b/homeassistant/components/freedompro/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + }, + "title": "Freedompro API \u043a\u043b\u044e\u0447" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/ca.json b/homeassistant/components/freedompro/translations/ca.json new file mode 100644 index 0000000000000..29fc97d35fff6 --- /dev/null +++ b/homeassistant/components/freedompro/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API" + }, + "description": "Introdueix la clau API obtinguda de https://home.freedompro.eu", + "title": "Clau API de Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/cs.json b/homeassistant/components/freedompro/translations/cs.json new file mode 100644 index 0000000000000..24f35743b7b3d --- /dev/null +++ b/homeassistant/components/freedompro/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/de.json b/homeassistant/components/freedompro/translations/de.json new file mode 100644 index 0000000000000..7ac985baeee9b --- /dev/null +++ b/homeassistant/components/freedompro/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "description": "Bitte gib den API-Schl\u00fcssel ein, den du von https://home.freedompro.eu erhalten hast.", + "title": "Freedompro API-Schl\u00fcssel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/en.json b/homeassistant/components/freedompro/translations/en.json new file mode 100644 index 0000000000000..83c36c43b64c4 --- /dev/null +++ b/homeassistant/components/freedompro/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Please enter the API key obtained from https://home.freedompro.eu", + "title": "Freedompro API key" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/es-419.json b/homeassistant/components/freedompro/translations/es-419.json new file mode 100644 index 0000000000000..ed1317689fe24 --- /dev/null +++ b/homeassistant/components/freedompro/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Ingrese la clave API obtenida de https://home.freedompro.eu", + "title": "Clave de API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/es.json b/homeassistant/components/freedompro/translations/es.json new file mode 100644 index 0000000000000..c08c30d64dccf --- /dev/null +++ b/homeassistant/components/freedompro/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API" + }, + "description": "Ingresa la clave API obtenida de https://home.freedompro.eu", + "title": "Clave API de Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/et.json b/homeassistant/components/freedompro/translations/et.json new file mode 100644 index 0000000000000..16e5f414264b5 --- /dev/null +++ b/homeassistant/components/freedompro/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Sisesta aadressilt https://home.freedompro.eu saadud API v\u00f5ti", + "title": "Freedompro API v\u00f5ti" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/fr.json b/homeassistant/components/freedompro/translations/fr.json new file mode 100644 index 0000000000000..090c95fa3c247 --- /dev/null +++ b/homeassistant/components/freedompro/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 d'API" + }, + "description": "Veuillez saisir la cl\u00e9 API obtenue sur https://home.freedompro.eu", + "title": "Cl\u00e9 API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/he.json b/homeassistant/components/freedompro/translations/he.json new file mode 100644 index 0000000000000..b4bb1b26bfec1 --- /dev/null +++ b/homeassistant/components/freedompro/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/hu.json b/homeassistant/components/freedompro/translations/hu.json new file mode 100644 index 0000000000000..e56cc7a4a41a2 --- /dev/null +++ b/homeassistant/components/freedompro/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs" + }, + "description": "K\u00e9rj\u00fck, adja meg a https://home.freedompro.eu webhelyr\u0151l kapott API-kulcsot", + "title": "Freedompro API kulcs" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/id.json b/homeassistant/components/freedompro/translations/id.json new file mode 100644 index 0000000000000..e05abfb7d14c3 --- /dev/null +++ b/homeassistant/components/freedompro/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API" + }, + "description": "Masukkan kunci API yang diperoleh dari https://home.freedompro.eu.", + "title": "Kunci API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/it.json b/homeassistant/components/freedompro/translations/it.json new file mode 100644 index 0000000000000..51dfa372f174b --- /dev/null +++ b/homeassistant/components/freedompro/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API" + }, + "description": "Inserisci la chiave API ottenuta da https://home.freedompro.eu", + "title": "Chiave API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/ja.json b/homeassistant/components/freedompro/translations/ja.json new file mode 100644 index 0000000000000..22e7047f496fc --- /dev/null +++ b/homeassistant/components/freedompro/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "https://home.freedompro.eu \u304b\u3089\u53d6\u5f97\u3057\u305fAPI\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Freedompro API\u30ad\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/nl.json b/homeassistant/components/freedompro/translations/nl.json new file mode 100644 index 0000000000000..8bf5e60d9376e --- /dev/null +++ b/homeassistant/components/freedompro/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel" + }, + "description": "Voer de API-sleutel in die is verkregen van https://home.freedompro.eu", + "title": "Freedompro API key" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/no.json b/homeassistant/components/freedompro/translations/no.json new file mode 100644 index 0000000000000..39a3e339d9a62 --- /dev/null +++ b/homeassistant/components/freedompro/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Vennligst skriv inn API-n\u00f8kkelen hentet fra https://home.freedompro.eu", + "title": "Freedompro API-n\u00f8kkel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/pl.json b/homeassistant/components/freedompro/translations/pl.json new file mode 100644 index 0000000000000..62985add95ab4 --- /dev/null +++ b/homeassistant/components/freedompro/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API" + }, + "description": "Wprowad\u017a klucz API uzyskany z https://home.freedompro.eu", + "title": "Klucz API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/ru.json b/homeassistant/components/freedompro/translations/ru.json new file mode 100644 index 0000000000000..db1523bfecbd0 --- /dev/null +++ b/homeassistant/components/freedompro/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 API, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043d\u0430 https://home.freedompro.eu", + "title": "\u041a\u043b\u044e\u0447 API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/tr.json b/homeassistant/components/freedompro/translations/tr.json new file mode 100644 index 0000000000000..4d846a1711724 --- /dev/null +++ b/homeassistant/components/freedompro/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "L\u00fctfen https://home.freedompro.eu adresinden al\u0131nan API anahtar\u0131n\u0131 girin", + "title": "Freedompro API anahtar\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/zh-Hant.json b/homeassistant/components/freedompro/translations/zh-Hant.json new file mode 100644 index 0000000000000..b4c901d58e7c5 --- /dev/null +++ b/homeassistant/components/freedompro/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "api_key": "API \u91d1\u9470" + }, + "description": "\u8acb\u8f38\u5165\u7531 https://home.freedompro.eu \u6240\u7372\u5f97\u7684 API \u91d1\u9470", + "title": "Freedompro API \u91d1\u9470" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 5cbaa23c1b5ce..568364c45acf2 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -2,23 +2,27 @@ import logging from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from fritzconnection.core.logger import fritzlogger +from requests import exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .common import FritzBoxTools, FritzData from .const import DATA_FRITZ, DOMAIN, PLATFORMS +from .services import async_setup_services, async_unload_services _LOGGER = logging.getLogger(__name__) +level = _LOGGER.getEffectiveLevel() +_LOGGER.info( + "Setting logging level of fritzconnection: %s", logging.getLevelName(level) +) +fritzlogger.set_level(level) +fritzlogger.enable() + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" @@ -32,11 +36,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await fritz_tools.async_setup() - await fritz_tools.async_start() + await fritz_tools.async_setup(entry.options) except FritzSecurityError as ex: raise ConfigEntryAuthFailed from ex - except FritzConnectionException as ex: + except (FritzConnectionException, exceptions.ConnectionError) as ex: raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {}) @@ -45,23 +48,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DATA_FRITZ not in hass.data: hass.data[DATA_FRITZ] = FritzData() - @callback - def _async_unload(event): - fritz_tools.async_unload() + entry.async_on_unload(entry.add_update_listener(update_listener)) + + await fritz_tools.async_config_entry_first_refresh() - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_unload) - ) # Load the other platforms like switch hass.config_entries.async_setup_platforms(entry, PLATFORMS) + await async_setup_services(hass) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload FRITZ!Box Tools config entry.""" fritzbox: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] - fritzbox.async_unload() fritz_data = hass.data[DATA_FRITZ] fritz_data.tracked.pop(fritzbox.unique_id) @@ -73,4 +74,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + await async_unload_services(hass) + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update when config_entry options update.""" + if entry.options: + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 493f1bc0d42cb..a54390cf260f8 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -1,14 +1,17 @@ -"""AVM FRITZ!Box connectivitiy sensor.""" -import logging +"""AVM FRITZ!Box connectivity sensor.""" +from __future__ import annotations -from fritzconnection.core.exceptions import FritzConnectionException +import logging from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import FritzBoxBaseEntity, FritzBoxTools from .const import DOMAIN @@ -16,72 +19,76 @@ _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_connected", + name="Connection", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="is_linked", + name="Link", + device_class=BinarySensorDeviceClass.PLUG, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="firmware_update", + name="Firmware Update", + device_class=BinarySensorDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box binary sensors") - fritzbox_tools = hass.data[DOMAIN][entry.entry_id] + fritzbox_tools: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] - if "WANIPConn1" in fritzbox_tools.connection.services: + if ( + not fritzbox_tools.connection + or "WANIPConn1" not in fritzbox_tools.connection.services + ): # Only routers are supported at the moment - async_add_entities( - [FritzBoxConnectivitySensor(fritzbox_tools, entry.title)], True - ) + return + + entities = [ + FritzBoxBinarySensor(fritzbox_tools, entry.title, description) + for description in SENSOR_TYPES + ] + async_add_entities(entities, True) -class FritzBoxConnectivitySensor(FritzBoxBaseEntity, BinarySensorEntity): + +class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" - def __init__(self, fritzbox_tools: FritzBoxTools, device_friendlyname: str) -> None: + def __init__( + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + description: BinarySensorEntityDescription, + ) -> None: """Init FRITZ!Box connectivity class.""" - self._unique_id = f"{fritzbox_tools.unique_id}-connectivity" - self._name = f"{device_friendlyname} Connectivity" - self._is_on = True - self._is_available = True - super().__init__(fritzbox_tools, device_friendlyname) - - @property - def name(self): - """Return name.""" - return self._name - - @property - def device_class(self): - """Return device class.""" - return DEVICE_CLASS_CONNECTIVITY - - @property - def is_on(self) -> bool: - """Return status.""" - return self._is_on - - @property - def unique_id(self): - """Return unique id.""" - return self._unique_id - - @property - def available(self) -> bool: - """Return availability.""" - return self._is_available + self.entity_description = description + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{description.key}" + super().__init__(fritzbox_tools, device_friendly_name) def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box binary sensors") - self._is_on = True - try: - if "WANCommonInterfaceConfig1" in self._fritzbox_tools.connection.services: - link_props = self._fritzbox_tools.connection.call_action( - "WANCommonInterfaceConfig1", "GetCommonLinkProperties" - ) - is_up = link_props["NewPhysicalLinkStatus"] - self._is_on = is_up == "Up" - else: - self._is_on = self._fritzbox_tools.fritzstatus.is_connected - - self._is_available = True - - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._is_available = False + + if self.entity_description.key == "is_connected": + self._attr_is_on = bool(self._fritzbox_tools.fritz_status.is_connected) + elif self.entity_description.key == "is_linked": + self._attr_is_on = bool(self._fritzbox_tools.fritz_status.is_linked) + elif self.entity_description.key == "firmware_update": + self._attr_is_on = self._fritzbox_tools.update_available + self._attr_extra_state_attributes = { + "installed_version": self._fritzbox_tools.current_firmware, + "latest_available_version": self._fritzbox_tools.latest_firmware, + } diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py new file mode 100644 index 0000000000000..ea80754532370 --- /dev/null +++ b/homeassistant/components/fritz/button.py @@ -0,0 +1,100 @@ +"""Switches for AVM Fritz!Box buttons.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .common import FritzBoxTools +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class FritzButtonDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable + + +@dataclass +class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixin): + """Class to describe a Button entity.""" + + +BUTTONS: Final = [ + FritzButtonDescription( + key="firmware_update", + name="Firmware Update", + device_class=ButtonDeviceClass.UPDATE, + entity_category=ENTITY_CATEGORY_CONFIG, + press_action=lambda router: router.async_trigger_firmware_update(), + ), + FritzButtonDescription( + key="reboot", + name="Reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + press_action=lambda router: router.async_trigger_reboot(), + ), + FritzButtonDescription( + key="reconnect", + name="Reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + press_action=lambda router: router.async_trigger_reconnect(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set buttons for device.""" + _LOGGER.debug("Setting up buttons") + router: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([FritzButton(router, entry.title, button) for button in BUTTONS]) + + +class FritzButton(ButtonEntity): + """Defines a Fritz!Box base button.""" + + entity_description: FritzButtonDescription + + def __init__( + self, + router: FritzBoxTools, + device_friendly_name: str, + description: FritzButtonDescription, + ) -> None: + """Initialize Fritz!Box button.""" + self.entity_description = description + self.router = router + + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = slugify(self._attr_name) + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, router.mac)} + ) + + async def async_press(self) -> None: + """Triggers Fritz!Box service.""" + await self.entity_description.press_action(self.router) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 3a6bf132fb6a8..6e5d89e58bdcc 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1,33 +1,103 @@ """Support for AVM FRITZ!Box classes.""" from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Callable, ValuesView +from dataclasses import dataclass, field from datetime import datetime, timedelta import logging -from typing import Any +from types import MappingProxyType +from typing import Any, TypedDict, cast -# pylint: disable=import-error from fritzconnection import FritzConnection +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzConnectionException, + FritzSecurityError, + FritzServiceError, +) from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus -from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.components.switch import DOMAIN as DEVICE_SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + async_entries_for_config_entry, + async_get, +) +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_registry import ( + EntityRegistry, + RegistryEntry, + async_entries_for_device, +) from homeassistant.util import dt as dt_util from .const import ( + DEFAULT_DEVICE_NAME, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, - TRACKER_SCAN_INTERVAL, + SERVICE_CLEANUP, + SERVICE_REBOOT, + SERVICE_RECONNECT, ) _LOGGER = logging.getLogger(__name__) +def _is_tracked(mac: str, current_devices: ValuesView) -> bool: + """Check if device is already tracked.""" + for tracked in current_devices: + if mac in tracked: + return True + return False + + +def device_filter_out_from_trackers( + mac: str, + device: FritzDevice, + current_devices: ValuesView, +) -> bool: + """Check if device should be filtered out from trackers.""" + reason: str | None = None + if device.ip_address == "": + reason = "Missing IP" + elif _is_tracked(mac, current_devices): + reason = "Already tracked" + + if reason: + _LOGGER.debug( + "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason + ) + return bool(reason) + + +def _cleanup_entity_filter(device: RegistryEntry) -> bool: + """Filter only relevant entities.""" + return device.domain == DEVICE_TRACKER_DOMAIN or ( + device.domain == DEVICE_SWITCH_DOMAIN and "_internet_access" in device.entity_id + ) + + +class ClassSetupMissing(Exception): + """Raised when a Class func is called before setup.""" + + def __init__(self) -> None: + """Init custom exception.""" + super().__init__("Function called before Class setup") + + @dataclass class Device: """FRITZ!Box device class.""" @@ -35,40 +105,62 @@ class Device: mac: str ip_address: str name: str + wan_access: bool + + +class HostInfo(TypedDict): + """FRITZ!Box host info class.""" + + mac: str + name: str + ip: str + status: bool -class FritzBoxTools: +class FritzBoxTools(update_coordinator.DataUpdateCoordinator): """FrtizBoxTools class.""" def __init__( self, - hass, - password, - username=DEFAULT_USERNAME, - host=DEFAULT_HOST, - port=DEFAULT_PORT, - ): + hass: HomeAssistant, + password: str, + username: str = DEFAULT_USERNAME, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + ) -> None: """Initialize FritzboxTools class.""" - self._cancel_scan = None - self._devices: dict[str, Any] = {} - self._unique_id = None - self.connection = None - self.fritzhosts = None - self.fritzstatus = None + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{DOMAIN}-{host}-coordinator", + update_interval=timedelta(seconds=30), + ) + + self._devices: dict[str, FritzDevice] = {} + self._options: MappingProxyType[str, Any] | None = None + self._unique_id: str | None = None + self.connection: FritzConnection = None + self.fritz_hosts: FritzHosts = None + self.fritz_status: FritzStatus = None self.hass = hass self.host = host self.password = password self.port = port self.username = username - self.mac = None - self.model = None - self.sw_version = None - - async def async_setup(self): + self._mac: str | None = None + self._model: str | None = None + self._current_firmware: str | None = None + self._latest_firmware: str | None = None + self._update_available: bool = False + + async def async_setup( + self, options: MappingProxyType[str, Any] | None = None + ) -> None: """Wrap up FritzboxTools class setup.""" - return await self.hass.async_add_executor_job(self.setup) + self._options = options + await self.hass.async_add_executor_job(self.setup) - def setup(self): + def setup(self) -> None: """Set up FritzboxTools class.""" self.connection = FritzConnection( address=self.host, @@ -76,42 +168,72 @@ def setup(self): user=self.username, password=self.password, timeout=60.0, + pool_maxsize=30, ) - self.fritzstatus = FritzStatus(fc=self.connection) + if not self.connection: + _LOGGER.error("Unable to establish a connection with %s", self.host) + return + + self.fritz_status = FritzStatus(fc=self.connection) info = self.connection.call_action("DeviceInfo:1", "GetInfo") - if self._unique_id is None: + if not self._unique_id: self._unique_id = info["NewSerialNumber"] - self.model = info.get("NewModelName") - self.sw_version = info.get("NewSoftwareVersion") - self.mac = self.unique_id - - async def async_start(self): - """Start FritzHosts connection.""" - self.fritzhosts = FritzHosts(fc=self.connection) + self._model = info.get("NewModelName") + self._current_firmware = info.get("NewSoftwareVersion") - await self.hass.async_add_executor_job(self.scan_devices) - - self._cancel_scan = async_track_time_interval( - self.hass, self.scan_devices, timedelta(seconds=TRACKER_SCAN_INTERVAL) - ) + self._update_available, self._latest_firmware = self._update_device_info() @callback - def async_unload(self): - """Unload FritzboxTools class.""" - _LOGGER.debug("Unloading FRITZ!Box router integration") - if self._cancel_scan is not None: - self._cancel_scan() - self._cancel_scan = None + async def _async_update_data(self) -> None: + """Update FritzboxTools data.""" + try: + self.fritz_hosts = FritzHosts(fc=self.connection) + await self.async_scan_devices() + except (FritzSecurityError, FritzConnectionException) as ex: + raise update_coordinator.UpdateFailed from ex @property - def unique_id(self): + def unique_id(self) -> str: """Return unique id.""" + if not self._unique_id: + raise ClassSetupMissing() return self._unique_id @property - def devices(self) -> dict[str, Any]: + def model(self) -> str: + """Return device model.""" + if not self._model: + raise ClassSetupMissing() + return self._model + + @property + def current_firmware(self) -> str: + """Return current SW version.""" + if not self._current_firmware: + raise ClassSetupMissing() + return self._current_firmware + + @property + def latest_firmware(self) -> str | None: + """Return latest SW version.""" + return self._latest_firmware + + @property + def update_available(self) -> bool: + """Return if new SW version is available.""" + return self._update_available + + @property + def mac(self) -> str: + """Return device Mac address.""" + if not self._unique_id: + raise ClassSetupMissing() + return self._unique_id + + @property + def devices(self) -> dict[str, FritzDevice]: """Return devices.""" return self._devices @@ -125,16 +247,40 @@ def signal_device_update(self) -> str: """Event specific per FRITZ!Box entry to signal updates in devices.""" return f"{DOMAIN}-device-update-{self._unique_id}" - def _update_info(self): - """Retrieve latest information from the FRITZ!Box.""" - return self.fritzhosts.get_hosts_info() + def _update_hosts_info(self) -> list[HostInfo]: + """Retrieve latest hosts information from the FRITZ!Box.""" + try: + return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] + except Exception as ex: # pylint: disable=[broad-except] + if not self.hass.is_stopping: + raise HomeAssistantError("Error refreshing hosts info") from ex + return [] + + def _update_device_info(self) -> tuple[bool, str | None]: + """Retrieve latest device information from the FRITZ!Box.""" + version = self.connection.call_action("UserInterface1", "GetInfo").get( + "NewX_AVM-DE_Version" + ) + return bool(version), version + + async def async_scan_devices(self, now: datetime | None = None) -> None: + """Wrap up FritzboxTools class scan.""" + await self.hass.async_add_executor_job(self.scan_devices, now) def scan_devices(self, now: datetime | None = None) -> None: """Scan for new devices and return a list of found device ids.""" _LOGGER.debug("Checking devices for FRITZ!Box router %s", self.host) + _default_consider_home = DEFAULT_CONSIDER_HOME.total_seconds() + if self._options: + consider_home = self._options.get( + CONF_CONSIDER_HOME, _default_consider_home + ) + else: + consider_home = _default_consider_home + new_device = False - for known_host in self._update_info(): + for known_host in self._update_hosts_info(): if not known_host.get("mac"): continue @@ -142,80 +288,283 @@ def scan_devices(self, now: datetime | None = None) -> None: dev_name = known_host["name"] dev_ip = known_host["ip"] dev_home = known_host["status"] - - dev_info = Device(dev_mac, dev_ip, dev_name) + dev_wan_access = True + if dev_ip: + wan_access = self.connection.call_action( + "X_AVM-DE_HostFilter:1", + "GetWANAccessByIP", + NewIPv4Address=dev_ip, + ) + if wan_access: + dev_wan_access = not wan_access.get("NewDisallow") + + dev_info = Device(dev_mac, dev_ip, dev_name, dev_wan_access) if dev_mac in self._devices: - self._devices[dev_mac].update(dev_info, dev_home) + self._devices[dev_mac].update(dev_info, dev_home, consider_home) else: - device = FritzDevice(dev_mac) - device.update(dev_info, dev_home) + device = FritzDevice(dev_mac, dev_name) + device.update(dev_info, dev_home, consider_home) self._devices[dev_mac] = device new_device = True - async_dispatcher_send(self.hass, self.signal_device_update) + dispatcher_send(self.hass, self.signal_device_update) if new_device: - async_dispatcher_send(self.hass, self.signal_device_new) + dispatcher_send(self.hass, self.signal_device_new) + + _LOGGER.debug("Checking host info for FRITZ!Box router %s", self.host) + self._update_available, self._latest_firmware = self._update_device_info() + + async def async_trigger_firmware_update(self) -> bool: + """Trigger firmware update.""" + results = await self.hass.async_add_executor_job( + self.connection.call_action, "UserInterface:1", "X_AVM-DE_DoUpdate" + ) + return cast(bool, results["NewX_AVM-DE_UpdateState"]) + + async def async_trigger_reboot(self) -> None: + """Trigger device reboot.""" + await self.hass.async_add_executor_job( + self.connection.call_action, "DeviceConfig1", "Reboot" + ) + + async def async_trigger_reconnect(self) -> None: + """Trigger device reconnect.""" + await self.hass.async_add_executor_job( + self.connection.call_action, "WANIPConn1", "ForceTermination" + ) + + async def service_fritzbox( + self, service_call: ServiceCall, config_entry: ConfigEntry + ) -> None: + """Define FRITZ!Box services.""" + _LOGGER.debug("FRITZ!Box router: %s", service_call.service) + + if not self.connection: + raise HomeAssistantError("Unable to establish a connection") + + try: + if service_call.service == SERVICE_REBOOT: + _LOGGER.warning( + 'Service "fritz.reboot" is deprecated, please use the corresponding button entity instead' + ) + await self.hass.async_add_executor_job( + self.connection.call_action, "DeviceConfig1", "Reboot" + ) + return + + if service_call.service == SERVICE_RECONNECT: + _LOGGER.warning( + 'Service "fritz.reconnect" is deprecated, please use the corresponding button entity instead' + ) + await self.hass.async_add_executor_job( + self.connection.call_action, + "WANIPConn1", + "ForceTermination", + ) + return + + if service_call.service == SERVICE_CLEANUP: + device_hosts_list: list = await self.hass.async_add_executor_job( + self.fritz_hosts.get_hosts_info + ) + + except (FritzServiceError, FritzActionError) as ex: + raise HomeAssistantError("Service or parameter unknown") from ex + except FritzConnectionException as ex: + raise HomeAssistantError("Service not supported") from ex + + entity_reg: EntityRegistry = ( + await self.hass.helpers.entity_registry.async_get_registry() + ) + + ha_entity_reg_list: list[ + RegistryEntry + ] = self.hass.helpers.entity_registry.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + entities_removed: bool = False + + device_hosts_macs = {device["mac"] for device in device_hosts_list} + + for entry in ha_entity_reg_list: + if ( + not _cleanup_entity_filter(entry) + or entry.unique_id.split("_")[0] in device_hosts_macs + ): + continue + _LOGGER.info("Removing entity: %s", entry.name or entry.original_name) + entity_reg.async_remove(entry.entity_id) + entities_removed = True + + if entities_removed: + self._async_remove_empty_devices(entity_reg, config_entry) + + @callback + def _async_remove_empty_devices( + self, entity_reg: EntityRegistry, config_entry: ConfigEntry + ) -> None: + """Remove devices with no entities.""" + + device_reg = async_get(self.hass) + device_list = async_entries_for_config_entry(device_reg, config_entry.entry_id) + for device_entry in device_list: + if not async_entries_for_device( + entity_reg, + device_entry.id, + include_disabled_entities=True, + ): + _LOGGER.info("Removing device: %s", device_entry.name) + device_reg.async_remove_device(device_entry.id) +@dataclass class FritzData: """Storage class for platform global data.""" - def __init__(self) -> None: - """Initialize the data.""" - self.tracked: dict = {} + tracked: dict = field(default_factory=dict) + profile_switches: dict = field(default_factory=dict) + + +class FritzDeviceBase(update_coordinator.CoordinatorEntity): + """Entity base class for a device connected to a FRITZ!Box router.""" + + def __init__(self, router: FritzBoxTools, device: FritzDevice) -> None: + """Initialize a FRITZ!Box device.""" + super().__init__(router) + self._router = router + self._mac: str = device.mac_address + self._name: str = device.hostname or DEFAULT_DEVICE_NAME + + @property + def name(self) -> str: + """Return device name.""" + return self._name + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + if self._mac: + return self._router.devices[self._mac].ip_address + return None + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._mac + + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + if self._mac: + return self._router.devices[self._mac].hostname + return None + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._mac)}, + default_manufacturer="AVM", + default_model="FRITZ!Box Tracked device", + default_name=self.name, + identifiers={(DOMAIN, self._mac)}, + via_device=( + DOMAIN, + self._router.unique_id, + ), + ) + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_process_update(self) -> None: + """Update device.""" + raise NotImplementedError() + + async def async_on_demand_update(self) -> None: + """Update state.""" + await self.async_process_update() + self.async_write_ha_state() class FritzDevice: - """FritzScanner device.""" + """Representation of a device connected to the FRITZ!Box.""" - def __init__(self, mac, name=None): + def __init__(self, mac: str, name: str) -> None: """Initialize device info.""" self._mac = mac self._name = name - self._ip_address = None - self._last_activity = None + self._ip_address: str | None = None + self._last_activity: datetime | None = None self._connected = False + self._wan_access = False - def update(self, dev_info, dev_home): + def update(self, dev_info: Device, dev_home: bool, consider_home: float) -> None: """Update device info.""" utc_point_in_time = dt_util.utcnow() + + if self._last_activity: + consider_home_evaluated = ( + utc_point_in_time - self._last_activity + ).total_seconds() < consider_home + else: + consider_home_evaluated = dev_home + if not self._name: self._name = dev_info.name or self._mac.replace(":", "_") - self._connected = dev_home - if not self._connected: - self._ip_address = None - return + self._connected = dev_home or consider_home_evaluated + + if dev_home: + self._last_activity = utc_point_in_time - self._last_activity = utc_point_in_time self._ip_address = dev_info.ip_address + self._wan_access = dev_info.wan_access @property - def is_connected(self): + def is_connected(self) -> bool: """Return connected status.""" return self._connected @property - def mac_address(self): + def mac_address(self) -> str: """Get MAC address.""" return self._mac @property - def hostname(self): + def hostname(self) -> str: """Get Name.""" return self._name @property - def ip_address(self): + def ip_address(self) -> str | None: """Get IP address.""" return self._ip_address @property - def last_activity(self): + def last_activity(self) -> datetime | None: """Return device last activity.""" return self._last_activity + @property + def wan_access(self) -> bool: + """Return device wan access.""" + return self._wan_access + + +class SwitchInfo(TypedDict): + """FRITZ!Box switch info class.""" + + description: str + friendly_name: str + icon: str + type: str + callback_update: Callable + callback_switch: Callable + class FritzBoxBaseEntity: """Fritz host entity base class.""" @@ -231,14 +580,14 @@ def mac_address(self) -> str: return self._fritzbox_tools.mac @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information.""" - - return { - "connections": {(CONNECTION_NETWORK_MAC, self.mac_address)}, - "identifiers": {(DOMAIN, self._fritzbox_tools.unique_id)}, - "name": self._device_name, - "manufacturer": "AVM", - "model": self._fritzbox_tools.model, - "sw_version": self._fritzbox_tools.sw_version, - } + return DeviceInfo( + configuration_url=f"http://{self._fritzbox_tools.host}", + connections={(CONNECTION_NETWORK_MAC, self.mac_address)}, + identifiers={(DOMAIN, self._fritzbox_tools.unique_id)}, + manufacturer="AVM", + model=self._fritzbox_tools.model, + name=self._device_name, + sw_version=self._fritzbox_tools.current_firmware, + ) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 23e713f796679..3b6089d3272bd 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -2,19 +2,22 @@ from __future__ import annotations import logging -from urllib.parse import urlparse +import socket +from typing import Any +from urllib.parse import ParseResult, urlparse from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError import voluptuous as vol -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_UDN, +from homeassistant.components import ssdp +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .common import FritzBoxTools from .const import ( @@ -22,7 +25,7 @@ DEFAULT_PORT, DOMAIN, ERROR_AUTH_INVALID, - ERROR_CONNECTION_ERROR, + ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, ) @@ -34,19 +37,28 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return FritzBoxToolsOptionsFlowHandler(config_entry) + + def __init__(self) -> None: """Initialize FRITZ!Box Tools flow.""" - self._host = None - self._entry = None - self._name = None - self._password = None - self._port = None - self._username = None - self.import_schema = None - self.fritz_tools = None - - async def fritz_tools_init(self): + self._host: str | None = None + self._entry: ConfigEntry + self._name: str + self._password: str + self._port: int | None = None + self._username: str + self.fritz_tools: FritzBoxTools + + async def fritz_tools_init(self) -> str | None: """Initialize FRITZ!Box Tools class.""" + + if not self._host or not self._port: + return None + self.fritz_tools = FritzBoxTools( hass=self.hass, host=self._host, @@ -60,7 +72,7 @@ async def fritz_tools_init(self): except FritzSecurityError: return ERROR_AUTH_INVALID except FritzConnectionException: - return ERROR_CONNECTION_ERROR + return ERROR_CANNOT_CONNECT except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return ERROR_UNKNOWN @@ -69,13 +81,21 @@ async def fritz_tools_init(self): async def async_check_configured_entry(self) -> ConfigEntry | None: """Check if entry is configured.""" + + current_host = await self.hass.async_add_executor_job( + socket.gethostbyname, self._host + ) + for entry in self._async_current_entries(include_ignore=False): - if entry.data[CONF_HOST] == self._host: + entry_host = await self.hass.async_add_executor_job( + socket.gethostbyname, entry.data[CONF_HOST] + ) + if entry_host == current_host: return entry return None @callback - def _async_create_entry(self): + def _async_create_entry(self) -> FlowResult: """Async create flow handler entry.""" return self.async_create_entry( title=self._name, @@ -85,17 +105,23 @@ def _async_create_entry(self): CONF_PORT: self.fritz_tools.port, CONF_USERNAME: self.fritz_tools.username, }, + options={ + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.total_seconds(), + }, ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by discovery.""" - ssdp_location = urlparse(discovery_info[ATTR_SSDP_LOCATION]) + ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") self._host = ssdp_location.hostname self._port = ssdp_location.port - self._name = discovery_info.get(ATTR_UPNP_FRIENDLY_NAME) + self._name = ( + discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + ) self.context[CONF_HOST] = self._host - if uuid := discovery_info.get(ATTR_UPNP_UDN): + if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] await self.async_set_unique_id(uuid) @@ -115,7 +141,9 @@ async def async_step_ssdp(self, discovery_info): } return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is None: return self._show_setup_form_confirm() @@ -133,7 +161,7 @@ async def async_step_confirm(self, user_input=None): return self._async_create_entry() - def _show_setup_form_init(self, errors=None): + def _show_setup_form_init(self, errors: dict[str, str] | None = None) -> FlowResult: """Show the setup form to the user.""" return self.async_show_form( step_id="user", @@ -148,7 +176,9 @@ def _show_setup_form_init(self, errors=None): errors=errors or {}, ) - def _show_setup_form_confirm(self, errors=None): + def _show_setup_form_confirm( + self, errors: dict[str, str] | None = None + ) -> FlowResult: """Show the setup form to the user.""" return self.async_show_form( step_id="confirm", @@ -162,7 +192,9 @@ def _show_setup_form_confirm(self, errors=None): errors=errors or {}, ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if user_input is None: return self._show_setup_form_init() @@ -182,24 +214,28 @@ async def async_step_user(self, user_input=None): return self._async_create_entry() - async def async_step_reauth(self, data): + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: """Handle flow upon an API authentication error.""" - self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if cfg_entry := self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ): + self._entry = cfg_entry self._host = data[CONF_HOST] self._port = data[CONF_PORT] self._username = data[CONF_USERNAME] self._password = data[CONF_PASSWORD] return await self.async_step_reauth_confirm() - def _show_setup_form_reauth_confirm(self, user_input, errors=None): + def _show_setup_form_reauth_confirm( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> FlowResult: """Show the reauth form to the user.""" + default_username = user_input.get(CONF_USERNAME) return self.async_show_form( step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME) - ): str, + vol.Required(CONF_USERNAME, default=default_username): str, vol.Required(CONF_PASSWORD): str, } ), @@ -207,7 +243,9 @@ def _show_setup_form_reauth_confirm(self, user_input, errors=None): errors=errors or {}, ) - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: return self._show_setup_form_reauth_confirm( @@ -234,7 +272,7 @@ async def async_step_reauth_confirm(self, user_input=None): await self.hass.config_entries.async_reload(self._entry.entry_id) return self.async_abort(reason="reauth_successful") - async def async_step_import(self, import_config): + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Import a config entry from configuration.yaml.""" return await self.async_step_user( { @@ -244,3 +282,31 @@ async def async_step_import(self, import_config): CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT), } ) + + +class FritzBoxToolsOptionsFlowHandler(OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 0bc33786a0f86..7bf65a8566dcb 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -1,21 +1,39 @@ """Constants for the FRITZ!Box Tools integration.""" +from typing import Literal + +from homeassistant.const import Platform + DOMAIN = "fritz" -PLATFORMS = ["binary_sensor", "device_tracker", "sensor"] +PLATFORMS = [ + Platform.BUTTON, + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SWITCH, +] DATA_FRITZ = "fritz_data" +DSL_CONNECTION: Literal["dsl"] = "dsl" + DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.178.1" DEFAULT_PORT = 49000 DEFAULT_USERNAME = "" - ERROR_AUTH_INVALID = "invalid_auth" -ERROR_CONNECTION_ERROR = "connection_error" +ERROR_CANNOT_CONNECT = "cannot_connect" ERROR_UNKNOWN = "unknown_error" -TRACKER_SCAN_INTERVAL = 30 +FRITZ_SERVICES = "fritz_services" +SERVICE_REBOOT = "reboot" +SERVICE_RECONNECT = "reconnect" +SERVICE_CLEANUP = "cleanup" + +SWITCH_TYPE_DEFLECTION = "CallDeflection" +SWITCH_TYPE_PORTFORWARD = "PortForward" +SWITCH_TYPE_WIFINETWORK = "WiFiNetwork" UPTIME_DEVIATION = 5 diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 646a8cc986e92..4b8169b4db81b 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -1,13 +1,14 @@ """Support for FRITZ!Box routers.""" from __future__ import annotations +import datetime import logging import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity @@ -15,12 +16,18 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .common import FritzBoxTools -from .const import DATA_FRITZ, DEFAULT_DEVICE_NAME, DOMAIN +from .common import ( + FritzBoxTools, + FritzData, + FritzDevice, + FritzDeviceBase, + device_filter_out_from_trackers, +) +from .const import DATA_FRITZ, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,7 +38,7 @@ cv.deprecated(CONF_HOST), cv.deprecated(CONF_USERNAME), cv.deprecated(CONF_PASSWORD), - PLATFORM_SCHEMA.extend( + PARENT_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=YAML_DEFAULT_HOST): cv.string, vol.Optional(CONF_USERNAME, default=YAML_DEFAULT_USERNAME): cv.string, @@ -41,7 +48,7 @@ ) -async def async_get_scanner(hass: HomeAssistant, config: ConfigType): +async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: """Import legacy FRITZ!Box configuration.""" _LOGGER.debug("Import legacy FRITZ!Box configuration from YAML") @@ -63,40 +70,39 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up device tracker for FRITZ!Box component.""" _LOGGER.debug("Starting FRITZ!Box device tracker") - router = hass.data[DOMAIN][entry.entry_id] - data_fritz = hass.data[DATA_FRITZ] + router: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] + data_fritz: FritzData = hass.data[DATA_FRITZ] @callback - def update_router(): + def update_router() -> None: """Update the values of the router.""" _async_add_entities(router, async_add_entities, data_fritz) - async_dispatcher_connect(hass, router.signal_device_new, update_router) + entry.async_on_unload( + async_dispatcher_connect(hass, router.signal_device_new, update_router) + ) update_router() @callback -def _async_add_entities(router, async_add_entities, data_fritz): +def _async_add_entities( + router: FritzBoxTools, + async_add_entities: AddEntitiesCallback, + data_fritz: FritzData, +) -> None: """Add new tracker entities from the router.""" - def _is_tracked(mac, device): - for tracked in data_fritz.tracked.values(): - if mac in tracked: - return True - - return False - new_tracked = [] if router.unique_id not in data_fritz.tracked: data_fritz.tracked[router.unique_id] = set() for mac, device in router.devices.items(): - if device.ip_address == "" or _is_tracked(mac, device): + if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()): continue new_tracked.append(FritzBoxTracker(router, device)) @@ -106,103 +112,43 @@ def _is_tracked(mac, device): async_add_entities(new_tracked) -class FritzBoxTracker(ScannerEntity): +class FritzBoxTracker(FritzDeviceBase, ScannerEntity): """This class queries a FRITZ!Box router.""" - def __init__(self, router: FritzBoxTools, device): + def __init__(self, router: FritzBoxTools, device: FritzDevice) -> None: """Initialize a FRITZ!Box device.""" - self._router = router - self._mac = device.mac_address - self._name = device.hostname or DEFAULT_DEVICE_NAME - self._active = False - self._attrs: dict = {} + super().__init__(router, device) + self._last_activity: datetime.datetime | None = device.last_activity @property - def is_connected(self): + def is_connected(self) -> bool: """Return device status.""" - return self._active + return self._router.devices[self._mac].is_connected @property - def name(self): - """Return device name.""" - return self._name - - @property - def unique_id(self): + def unique_id(self) -> str: """Return device unique id.""" - return self._mac - - @property - def ip_address(self) -> str: - """Return the primary ip address of the device.""" - return self._router.devices[self._mac].ip_address + return f"{self._mac}_tracker" @property - def mac_address(self) -> str: - """Return the mac address of the device.""" - return self._mac - - @property - def hostname(self) -> str: - """Return hostname of the device.""" - return self._router.devices[self._mac].hostname - - @property - def source_type(self) -> str: - """Return tracker source type.""" - return SOURCE_TYPE_ROUTER - - @property - def device_info(self): - """Return the device information.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "AVM", - "model": "FRITZ!Box Tracked device", - "via_device": ( - DOMAIN, - self._router.unique_id, - ), - } - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - - @property - def icon(self): + def icon(self) -> str: """Return device icon.""" if self.is_connected: return "mdi:lan-connect" return "mdi:lan-disconnect" - @callback - def async_process_update(self) -> None: - """Update device.""" - device = self._router.devices[self._mac] - self._active = device.is_connected - - if device.last_activity: - self._attrs["last_time_reachable"] = device.last_activity.isoformat( + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the attributes.""" + attrs: dict[str, str] = {} + self._last_activity = self._router.devices[self._mac].last_activity + if self._last_activity is not None: + attrs["last_time_reachable"] = self._last_activity.isoformat( timespec="seconds" ) + return attrs - @callback - def async_on_demand_update(self): - """Update state.""" - self.async_process_update() - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Register state update callback.""" - self.async_process_update() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - self._router.signal_device_update, - self.async_on_demand_update, - ) - ) + @property + def source_type(self) -> str: + """Return tracker source type.""" + return SOURCE_TYPE_ROUTER diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 68b1bde4f3818..219e8f946ae08 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -3,9 +3,10 @@ "name": "AVM FRITZ!Box Tools", "documentation": "https://www.home-assistant.io/integrations/fritz", "requirements": [ - "fritzconnection==1.4.2", + "fritzconnection==1.7.2", "xmltodict==0.12.0" ], + "dependencies": ["network"], "codeowners": [ "@mammuth", "@AaronDavidSchneider", diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 21c121ea295f4..9f3bc0fd7c1e5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -1,141 +1,349 @@ """AVM FRITZ!Box binary sensors.""" from __future__ import annotations -import datetime +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta import logging - -from fritzconnection.core.exceptions import FritzConnectionException - -from homeassistant.components.binary_sensor import BinarySensorEntity +from typing import Any, Literal + +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzActionFailedError, + FritzConnectionException, + FritzInternalError, + FritzServiceError, +) +from fritzconnection.lib.fritzstatus import FritzStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import ( + DATA_GIGABYTES, + DATA_RATE_KILOBITS_PER_SECOND, + DATA_RATE_KILOBYTES_PER_SECOND, + SIGNAL_STRENGTH_DECIBELS, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from .common import FritzBoxBaseEntity, FritzBoxTools -from .const import DOMAIN, UPTIME_DEVIATION +from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) -def _retrieve_uptime_state(status, last_value): - """Return uptime from device.""" - delta_uptime = utcnow() - datetime.timedelta(seconds=status.uptime) +def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: + """Calculate uptime with deviation.""" + delta_uptime = utcnow() - timedelta(seconds=seconds_uptime) if ( not last_value - or abs( - (delta_uptime - datetime.datetime.fromisoformat(last_value)).total_seconds() - ) - > UPTIME_DEVIATION + or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION ): - return delta_uptime.replace(microsecond=0).isoformat() + return delta_uptime return last_value -def _retrieve_external_ip_state(status, last_value): +def _retrieve_device_uptime_state( + status: FritzStatus, last_value: datetime +) -> datetime: + """Return uptime from device.""" + return _uptime_calculation(status.device_uptime, last_value) + + +def _retrieve_connection_uptime_state( + status: FritzStatus, last_value: datetime | None +) -> datetime: + """Return uptime from connection.""" + return _uptime_calculation(status.connection_uptime, last_value) + + +def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str: """Return external ip from device.""" - return status.external_ip + return status.external_ip # type: ignore[no-any-return] + + +def _retrieve_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload transmission rate.""" + return round(status.transmission_rate[0] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download transmission rate.""" + return round(status.transmission_rate[1] / 1000, 1) # type: ignore[no-any-return] + +def _retrieve_max_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload max transmission rate.""" + return round(status.max_bit_rate[0] / 1000, 1) # type: ignore[no-any-return] -SENSOR_NAME = 0 -SENSOR_DEVICE_CLASS = 1 -SENSOR_ICON = 2 -SENSOR_STATE_PROVIDER = 3 -# sensor_type: [name, device_class, icon, state_provider] -SENSOR_DATA = { - "external_ip": [ - "External IP", - None, - "mdi:earth", - _retrieve_external_ip_state, - ], - "uptime": ["Uptime", DEVICE_CLASS_TIMESTAMP, None, _retrieve_uptime_state], -} +def _retrieve_max_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download max transmission rate.""" + return round(status.max_bit_rate[1] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_gb_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload total data.""" + return round(status.bytes_sent / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_gb_received_state(status: FritzStatus, last_value: str) -> float: + """Return download total data.""" + return round(status.bytes_received / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload link rate.""" + return round(status.max_linked_bit_rate[0] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download link rate.""" + return round(status.max_linked_bit_rate[1] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_noise_margin_sent_state( + status: FritzStatus, last_value: str +) -> float: + """Return upload noise margin.""" + return status.noise_margin[0] / 10 # type: ignore[no-any-return] + + +def _retrieve_link_noise_margin_received_state( + status: FritzStatus, last_value: str +) -> float: + """Return download noise margin.""" + return status.noise_margin[1] / 10 # type: ignore[no-any-return] + + +def _retrieve_link_attenuation_sent_state( + status: FritzStatus, last_value: str +) -> float: + """Return upload line attenuation.""" + return status.attenuation[0] / 10 # type: ignore[no-any-return] + + +def _retrieve_link_attenuation_received_state( + status: FritzStatus, last_value: str +) -> float: + """Return download line attenuation.""" + return status.attenuation[1] / 10 # type: ignore[no-any-return] + + +@dataclass +class FritzRequireKeysMixin: + """Fritz sensor data class.""" + + value_fn: Callable[[FritzStatus, Any], Any] + + +@dataclass +class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): + """Describes Fritz sensor entity.""" + + connection_type: Literal["dsl"] | None = None + + +SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( + FritzSensorEntityDescription( + key="external_ip", + name="External IP", + icon="mdi:earth", + value_fn=_retrieve_external_ip_state, + ), + FritzSensorEntityDescription( + key="device_uptime", + name="Device Uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_device_uptime_state, + ), + FritzSensorEntityDescription( + key="connection_uptime", + name="Connection Uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_connection_uptime_state, + ), + FritzSensorEntityDescription( + key="kb_s_sent", + name="Upload Throughput", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload", + value_fn=_retrieve_kb_s_sent_state, + ), + FritzSensorEntityDescription( + key="kb_s_received", + name="Download Throughput", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download", + value_fn=_retrieve_kb_s_received_state, + ), + FritzSensorEntityDescription( + key="max_kb_s_sent", + name="Max Connection Upload Throughput", + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:upload", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_max_kb_s_sent_state, + ), + FritzSensorEntityDescription( + key="max_kb_s_received", + name="Max Connection Download Throughput", + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:download", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_max_kb_s_received_state, + ), + FritzSensorEntityDescription( + key="gb_sent", + name="GB sent", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:upload", + value_fn=_retrieve_gb_sent_state, + ), + FritzSensorEntityDescription( + key="gb_received", + name="GB received", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + value_fn=_retrieve_gb_received_state, + ), + FritzSensorEntityDescription( + key="link_kb_s_sent", + name="Link Upload Throughput", + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:upload", + value_fn=_retrieve_link_kb_s_sent_state, + connection_type=DSL_CONNECTION, + ), + FritzSensorEntityDescription( + key="link_kb_s_received", + name="Link Download Throughput", + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:download", + value_fn=_retrieve_link_kb_s_received_state, + connection_type=DSL_CONNECTION, + ), + FritzSensorEntityDescription( + key="link_noise_margin_sent", + name="Link Upload Noise Margin", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:upload", + value_fn=_retrieve_link_noise_margin_sent_state, + connection_type=DSL_CONNECTION, + ), + FritzSensorEntityDescription( + key="link_noise_margin_received", + name="Link Download Noise Margin", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:download", + value_fn=_retrieve_link_noise_margin_received_state, + connection_type=DSL_CONNECTION, + ), + FritzSensorEntityDescription( + key="link_attenuation_sent", + name="Link Upload Power Attenuation", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:upload", + value_fn=_retrieve_link_attenuation_sent_state, + connection_type=DSL_CONNECTION, + ), + FritzSensorEntityDescription( + key="link_attenuation_received", + name="Link Download Power Attenuation", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:download", + value_fn=_retrieve_link_attenuation_received_state, + connection_type=DSL_CONNECTION, + ), +) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box sensors") - fritzbox_tools = hass.data[DOMAIN][entry.entry_id] + fritzbox_tools: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] - if "WANIPConn1" not in fritzbox_tools.connection.services: + if ( + not fritzbox_tools.connection + or "WANIPConn1" not in fritzbox_tools.connection.services + ): # Only routers are supported at the moment return - for sensor_type in SENSOR_DATA: - async_add_entities( - [FritzBoxSensor(fritzbox_tools, entry.title, sensor_type)], - True, + dsl: bool = False + try: + dslinterface = await hass.async_add_executor_job( + fritzbox_tools.connection.call_action, + "WANDSLInterfaceConfig:1", + "GetInfo", ) + dsl = dslinterface["NewEnable"] + except ( + FritzInternalError, + FritzActionError, + FritzActionFailedError, + FritzServiceError, + ): + pass + entities = [ + FritzBoxSensor(fritzbox_tools, entry.title, description) + for description in SENSOR_TYPES + if dsl or description.connection_type != DSL_CONNECTION + ] -class FritzBoxSensor(FritzBoxBaseEntity, BinarySensorEntity): + async_add_entities(entities, True) + + +class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" + entity_description: FritzSensorEntityDescription + def __init__( - self, fritzbox_tools: FritzBoxTools, device_friendlyname: str, sensor_type: str + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + description: FritzSensorEntityDescription, ) -> None: """Init FRITZ!Box connectivity class.""" - self._sensor_data = SENSOR_DATA[sensor_type] - self._unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" - self._name = f"{device_friendlyname} {self._sensor_data[SENSOR_NAME]}" - self._is_available = True - self._last_value: str | None = None - self._state: str | None = None - super().__init__(fritzbox_tools, device_friendlyname) - - @property - def _state_provider(self): - """Return the state provider for the binary sensor.""" - return self._sensor_data[SENSOR_STATE_PROVIDER] - - @property - def name(self): - """Return name.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return device class.""" - return self._sensor_data[SENSOR_DEVICE_CLASS] - - @property - def icon(self): - """Return icon.""" - return self._sensor_data[SENSOR_ICON] - - @property - def unique_id(self): - """Return unique id.""" - return self._unique_id - - @property - def state(self) -> str | None: - """Return the state of the sensor.""" - return self._state - - @property - def available(self) -> bool: - """Return availability.""" - return self._is_available + self.entity_description = description + self._last_device_value: str | None = None + self._attr_available = True + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{description.key}" + super().__init__(fritzbox_tools, device_friendly_name) def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box sensors") try: - status = self._fritzbox_tools.fritzstatus - self._is_available = True - - self._state = self._last_value = self._state_provider( - status, self._last_value - ) - + status: FritzStatus = self._fritzbox_tools.fritz_status + self._attr_available = True except FritzConnectionException: _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._is_available = False + self._attr_available = False + return + + self._attr_native_value = ( + self._last_device_value + ) = self.entity_description.value_fn(status, self._last_device_value) diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py new file mode 100644 index 0000000000000..2d8e16f15f0ab --- /dev/null +++ b/homeassistant/components/fritz/services.py @@ -0,0 +1,79 @@ +"""Services for Fritz integration.""" +import logging + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .common import FritzBoxTools +from .const import ( + DOMAIN, + FRITZ_SERVICES, + SERVICE_CLEANUP, + SERVICE_REBOOT, + SERVICE_RECONNECT, +) + +_LOGGER = logging.getLogger(__name__) + + +SERVICE_LIST = [SERVICE_CLEANUP, SERVICE_REBOOT, SERVICE_RECONNECT] + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Fritz integration.""" + + for service in SERVICE_LIST: + if hass.services.has_service(DOMAIN, service): + return + + async def async_call_fritz_service(service_call: ServiceCall) -> None: + """Call correct Fritz service.""" + + if not ( + fritzbox_entry_ids := await _async_get_configured_fritz_tools( + hass, service_call + ) + ): + raise HomeAssistantError( + f"Failed to call service '{service_call.service}'. Config entry for target not found" + ) + + for entry_id in fritzbox_entry_ids: + _LOGGER.debug("Executing service %s", service_call.service) + fritz_tools: FritzBoxTools = hass.data[DOMAIN][entry_id] + if config_entry := hass.config_entries.async_get_entry(entry_id): + await fritz_tools.service_fritzbox(service_call, config_entry) + else: + _LOGGER.error( + "Executing service %s failed, no config entry found", + service_call.service, + ) + + for service in SERVICE_LIST: + hass.services.async_register(DOMAIN, service, async_call_fritz_service) + + +async def _async_get_configured_fritz_tools( + hass: HomeAssistant, service_call: ServiceCall +) -> list: + """Get FritzBoxTools class from config entry.""" + + list_entry_id: list = [] + for entry_id in await async_extract_config_entry_ids(hass, service_call): + config_entry = hass.config_entries.async_get_entry(entry_id) + if config_entry and config_entry.domain == DOMAIN: + list_entry_id.append(entry_id) + return list_entry_id + + +async def async_unload_services(hass: HomeAssistant) -> None: + """Unload services for Fritz integration.""" + + if not hass.data.get(FRITZ_SERVICES): + return + + hass.data[FRITZ_SERVICES] = False + + for service in SERVICE_LIST: + hass.services.async_remove(DOMAIN, service) diff --git a/homeassistant/components/fritz/services.yaml b/homeassistant/components/fritz/services.yaml new file mode 100644 index 0000000000000..2375aa71f575a --- /dev/null +++ b/homeassistant/components/fritz/services.yaml @@ -0,0 +1,37 @@ +reconnect: + description: Reconnects your FRITZ!Box internet connection + fields: + device_id: + name: Fritz!Box Device + description: Select the Fritz!Box to reconnect + required: true + selector: + device: + integration: fritz + entity: + device_class: connectivity +reboot: + description: Reboots your FRITZ!Box + fields: + device_id: + name: Fritz!Box Device + description: Select the Fritz!Box to reboot + required: true + selector: + device: + integration: fritz + entity: + device_class: connectivity + +cleanup: + description: Remove FRITZ!Box stale device_tracker entities + fields: + device_id: + name: Fritz!Box Device + description: Select the Fritz!Box to check + required: true + selector: + device: + integration: fritz + entity: + device_class: connectivity diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 3f6cb4adba4ec..f1cdb71974133 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -10,20 +10,20 @@ "password": "[%key:common::config_flow::data::password%]" } }, - "start_config": { - "title": "Setup FRITZ!Box Tools - mandatory", - "description": "Setup FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", + "reauth_confirm": { + "title": "Updating FRITZ!Box Tools - credentials", + "description": "Update FRITZ!Box Tools credentials for: {host}.\n\nFRITZ!Box Tools is unable to log in to your FRITZ!Box.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } }, - "reauth_confirm": { - "title": "Updating FRITZ!Box Tools - credentials", - "description": "Update FRITZ!Box Tools credentials for: {host}.\n\nFRITZ!Box Tools is unable to log in to your FRITZ!Box.", + "user": { + "title": "Setup FRITZ!Box Tools", + "description": "Setup FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } @@ -35,10 +35,19 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to consider a device at 'home'" + } + } + } } } diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py new file mode 100644 index 0000000000000..e9cbd80b13382 --- /dev/null +++ b/homeassistant/components/fritz/switch.py @@ -0,0 +1,691 @@ +"""Switches for AVM Fritz!Box functions.""" +from __future__ import annotations + +from collections import OrderedDict +from functools import partial +import logging +from typing import Any + +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzActionFailedError, + FritzConnectionException, + FritzSecurityError, + FritzServiceError, +) +import xmltodict + +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .common import ( + FritzBoxBaseEntity, + FritzBoxTools, + FritzData, + FritzDevice, + FritzDeviceBase, + SwitchInfo, + device_filter_out_from_trackers, +) +from .const import ( + DATA_FRITZ, + DOMAIN, + SWITCH_TYPE_DEFLECTION, + SWITCH_TYPE_PORTFORWARD, + SWITCH_TYPE_WIFINETWORK, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_service_call_action( + fritzbox_tools: FritzBoxTools, + service_name: str, + service_suffix: str | None, + action_name: str, + **kwargs: Any, +) -> None | dict: + """Return service details.""" + return await fritzbox_tools.hass.async_add_executor_job( + partial( + service_call_action, + fritzbox_tools, + service_name, + service_suffix, + action_name, + **kwargs, + ) + ) + + +def service_call_action( + fritzbox_tools: FritzBoxTools, + service_name: str, + service_suffix: str | None, + action_name: str, + **kwargs: Any, +) -> dict | None: + """Return service details.""" + + if f"{service_name}{service_suffix}" not in fritzbox_tools.connection.services: + return None + + try: + return fritzbox_tools.connection.call_action( # type: ignore[no-any-return] + f"{service_name}:{service_suffix}", + action_name, + **kwargs, + ) + except FritzSecurityError: + _LOGGER.error( + "Authorization Error: Please check the provided credentials and verify that you can log into the web interface", + exc_info=True, + ) + return None + except (FritzActionError, FritzActionFailedError, FritzServiceError): + _LOGGER.error( + "Service/Action Error: cannot execute service %s", + service_name, + exc_info=True, + ) + return None + except FritzConnectionException: + _LOGGER.error( + "Connection Error: Please check the device is properly configured for remote login", + exc_info=True, + ) + return None + + +def get_deflections( + fritzbox_tools: FritzBoxTools, service_name: str +) -> list[OrderedDict[Any, Any]] | None: + """Get deflection switch info.""" + + deflection_list = service_call_action( + fritzbox_tools, + service_name, + "1", + "GetDeflections", + ) + + if not deflection_list: + return [] + + items = xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"] + if not isinstance(items, list): + return [items] + return items + + +def deflection_entities_list( + fritzbox_tools: FritzBoxTools, device_friendly_name: str +) -> list[FritzBoxDeflectionSwitch]: + """Get list of deflection entities.""" + + _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_DEFLECTION) + + service_name = "X_AVM-DE_OnTel" + deflections_response = service_call_action( + fritzbox_tools, service_name, "1", "GetNumberOfDeflections" + ) + if not deflections_response: + _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) + return [] + + _LOGGER.debug( + "Specific %s response: GetNumberOfDeflections=%s", + SWITCH_TYPE_DEFLECTION, + deflections_response, + ) + + if deflections_response["NewNumberOfDeflections"] == 0: + _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) + return [] + + deflection_list = get_deflections(fritzbox_tools, service_name) + if deflection_list is None: + return [] + + return [ + FritzBoxDeflectionSwitch( + fritzbox_tools, device_friendly_name, dict_of_deflection + ) + for dict_of_deflection in deflection_list + ] + + +def port_entities_list( + fritzbox_tools: FritzBoxTools, device_friendly_name: str, local_ip: str +) -> list[FritzBoxPortSwitch]: + """Get list of port forwarding entities.""" + + _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PORTFORWARD) + entities_list: list[FritzBoxPortSwitch] = [] + service_name = "Layer3Forwarding" + connection_type = service_call_action( + fritzbox_tools, service_name, "1", "GetDefaultConnectionService" + ) + if not connection_type: + _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_PORTFORWARD) + return [] + + # Return NewDefaultConnectionService sample: "1.WANPPPConnection.1" + con_type: str = connection_type["NewDefaultConnectionService"][2:][:-2] + + # Query port forwardings and setup a switch for each forward for the current device + resp = service_call_action( + fritzbox_tools, con_type, "1", "GetPortMappingNumberOfEntries" + ) + if not resp: + _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) + return [] + + port_forwards_count: int = resp["NewPortMappingNumberOfEntries"] + + _LOGGER.debug( + "Specific %s response: GetPortMappingNumberOfEntries=%s", + SWITCH_TYPE_PORTFORWARD, + port_forwards_count, + ) + + _LOGGER.debug("IP source for %s is %s", fritzbox_tools.host, local_ip) + + for i in range(port_forwards_count): + + portmap = service_call_action( + fritzbox_tools, + con_type, + "1", + "GetGenericPortMappingEntry", + NewPortMappingIndex=i, + ) + + if not portmap: + _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) + continue + + _LOGGER.debug( + "Specific %s response: GetGenericPortMappingEntry=%s", + SWITCH_TYPE_PORTFORWARD, + portmap, + ) + + # We can only handle port forwards of the given device + if portmap["NewInternalClient"] == local_ip: + port_name = portmap["NewPortMappingDescription"] + for entity in entities_list: + if entity.port_mapping and ( + port_name in entity.port_mapping["NewPortMappingDescription"] + ): + port_name = f"{port_name} {portmap['NewExternalPort']}" + entities_list.append( + FritzBoxPortSwitch( + fritzbox_tools, + device_friendly_name, + portmap, + port_name, + i, + con_type, + ) + ) + + return entities_list + + +def wifi_entities_list( + fritzbox_tools: FritzBoxTools, device_friendly_name: str +) -> list[FritzBoxWifiSwitch]: + """Get list of wifi entities.""" + _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_WIFINETWORK) + std_table = {"ax": "Wifi6", "ac": "5Ghz", "n": "2.4Ghz"} + if fritzbox_tools.model == "FRITZ!Box 7390": + std_table = {"n": "5Ghz"} + + networks: dict = {} + for i in range(4): + if not ("WLANConfiguration" + str(i)) in fritzbox_tools.connection.services: + continue + + network_info = service_call_action( + fritzbox_tools, "WLANConfiguration", str(i), "GetInfo" + ) + if network_info: + ssid = network_info["NewSSID"] + _LOGGER.debug("SSID from device: <%s>", ssid) + if ( + slugify( + ssid, + ) + in [slugify(v) for v in networks.values()] + ): + _LOGGER.debug("SSID duplicated, adding suffix") + networks[i] = f'{ssid} {std_table[network_info["NewStandard"]]}' + else: + networks[i] = ssid + _LOGGER.debug("SSID normalized: <%s>", networks[i]) + + return [ + FritzBoxWifiSwitch(fritzbox_tools, device_friendly_name, net, network_name) + for net, network_name in networks.items() + ] + + +def profile_entities_list( + router: FritzBoxTools, + data_fritz: FritzData, +) -> list[FritzBoxProfileSwitch]: + """Add new tracker entities from the router.""" + + new_profiles: list[FritzBoxProfileSwitch] = [] + + if "X_AVM-DE_HostFilter1" not in router.connection.services: + return new_profiles + + if router.unique_id not in data_fritz.profile_switches: + data_fritz.profile_switches[router.unique_id] = set() + + for mac, device in router.devices.items(): + if device_filter_out_from_trackers( + mac, device, data_fritz.profile_switches.values() + ): + continue + + new_profiles.append(FritzBoxProfileSwitch(router, device)) + data_fritz.profile_switches[router.unique_id].add(mac) + + return new_profiles + + +def all_entities_list( + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + data_fritz: FritzData, + local_ip: str, +) -> list[Entity]: + """Get a list of all entities.""" + return [ + *deflection_entities_list(fritzbox_tools, device_friendly_name), + *port_entities_list(fritzbox_tools, device_friendly_name, local_ip), + *wifi_entities_list(fritzbox_tools, device_friendly_name), + *profile_entities_list(fritzbox_tools, data_fritz), + ] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up switches") + fritzbox_tools: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] + data_fritz: FritzData = hass.data[DATA_FRITZ] + + _LOGGER.debug("Fritzbox services: %s", fritzbox_tools.connection.services) + + local_ip = await async_get_source_ip( + fritzbox_tools.hass, target_ip=fritzbox_tools.host + ) + + entities_list = await hass.async_add_executor_job( + all_entities_list, + fritzbox_tools, + entry.title, + data_fritz, + local_ip, + ) + + async_add_entities(entities_list) + + @callback + def update_router() -> None: + """Update the values of the router.""" + async_add_entities(profile_entities_list(fritzbox_tools, data_fritz)) + + entry.async_on_unload( + async_dispatcher_connect(hass, fritzbox_tools.signal_device_new, update_router) + ) + + +class FritzBoxBaseSwitch(FritzBoxBaseEntity): + """Fritz switch base class.""" + + def __init__( + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + switch_info: SwitchInfo, + ) -> None: + """Init Fritzbox port switch.""" + super().__init__(fritzbox_tools, device_friendly_name) + + self._description = switch_info["description"] + self._friendly_name = switch_info["friendly_name"] + self._icon = switch_info["icon"] + self._type = switch_info["type"] + self._update = switch_info["callback_update"] + self._switch = switch_info["callback_switch"] + + self._name = f"{self._friendly_name} {self._description}" + self._unique_id = ( + f"{self._fritzbox_tools.unique_id}-{slugify(self._description)}" + ) + + self._attributes: dict[str, str] = {} + self._is_available = True + + self._attr_is_on = False + + @property + def name(self) -> str: + """Return name.""" + return self._name + + @property + def icon(self) -> str: + """Return name.""" + return self._icon + + @property + def unique_id(self) -> str: + """Return unique id.""" + return self._unique_id + + @property + def available(self) -> bool: + """Return availability.""" + return self._is_available + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return device attributes.""" + return self._attributes + + async def async_update(self) -> None: + """Update data.""" + _LOGGER.debug("Updating '%s' (%s) switch state", self.name, self._type) + await self._update() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_handle_turn_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_handle_turn_on_off(turn_on=False) + + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: + """Handle switch state change request.""" + await self._switch(turn_on) + self._attr_is_on = turn_on + + +class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): + """Defines a FRITZ!Box Tools PortForward switch.""" + + def __init__( + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + port_mapping: dict[str, Any] | None, + port_name: str, + idx: int, + connection_type: str, + ) -> None: + """Init Fritzbox port switch.""" + self._fritzbox_tools = fritzbox_tools + + self._attributes = {} + self.connection_type = connection_type + self.port_mapping = port_mapping # dict in the format as it comes from fritzconnection. eg: {'NewRemoteHost': '0.0.0.0', 'NewExternalPort': 22, 'NewProtocol': 'TCP', 'NewInternalPort': 22, 'NewInternalClient': '192.168.178.31', 'NewEnabled': True, 'NewPortMappingDescription': 'Beast SSH ', 'NewLeaseDuration': 0} + self._idx = idx # needed for update routine + self._attr_entity_category = EntityCategory.CONFIG + + if port_mapping is None: + return + + switch_info = SwitchInfo( + description=f"Port forward {port_name}", + friendly_name=device_friendly_name, + icon="mdi:check-network", + type=SWITCH_TYPE_PORTFORWARD, + callback_update=self._async_fetch_update, + callback_switch=self._async_handle_port_switch_on_off, + ) + super().__init__(fritzbox_tools, device_friendly_name, switch_info) + + async def _async_fetch_update(self) -> None: + """Fetch updates.""" + + self.port_mapping = await async_service_call_action( + self._fritzbox_tools, + self.connection_type, + "1", + "GetGenericPortMappingEntry", + NewPortMappingIndex=self._idx, + ) + _LOGGER.debug( + "Specific %s response: %s", SWITCH_TYPE_PORTFORWARD, self.port_mapping + ) + if self.port_mapping is None: + self._is_available = False + return + + self._attr_is_on = self.port_mapping["NewEnabled"] is True + self._is_available = True + + attributes_dict = { + "NewInternalClient": "internal_ip", + "NewInternalPort": "internal_port", + "NewExternalPort": "external_port", + "NewProtocol": "protocol", + "NewPortMappingDescription": "description", + } + + for key, attr in attributes_dict.items(): + self._attributes[attr] = self.port_mapping[key] + + async def _async_handle_port_switch_on_off(self, turn_on: bool) -> bool: + + if self.port_mapping is None: + return False + + self.port_mapping["NewEnabled"] = "1" if turn_on else "0" + + resp = await async_service_call_action( + self._fritzbox_tools, + self.connection_type, + "1", + "AddPortMapping", + **self.port_mapping, + ) + + return bool(resp is not None) + + +class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): + """Defines a FRITZ!Box Tools PortForward switch.""" + + def __init__( + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + dict_of_deflection: Any, + ) -> None: + """Init Fritxbox Deflection class.""" + self._fritzbox_tools: FritzBoxTools = fritzbox_tools + + self.dict_of_deflection = dict_of_deflection + self._attributes = {} + self.id = int(self.dict_of_deflection["DeflectionId"]) + self._attr_entity_category = EntityCategory.CONFIG + + switch_info = SwitchInfo( + description=f"Call deflection {self.id}", + friendly_name=device_friendly_name, + icon="mdi:phone-forward", + type=SWITCH_TYPE_DEFLECTION, + callback_update=self._async_fetch_update, + callback_switch=self._async_switch_on_off_executor, + ) + super().__init__(self._fritzbox_tools, device_friendly_name, switch_info) + + async def _async_fetch_update(self) -> None: + """Fetch updates.""" + + resp = await async_service_call_action( + self._fritzbox_tools, "X_AVM-DE_OnTel", "1", "GetDeflections" + ) + if not resp: + self._is_available = False + return + + self.dict_of_deflection = xmltodict.parse(resp["NewDeflectionList"])["List"][ + "Item" + ] + if isinstance(self.dict_of_deflection, list): + self.dict_of_deflection = self.dict_of_deflection[self.id] + + _LOGGER.debug( + "Specific %s response: NewDeflectionList=%s", + SWITCH_TYPE_DEFLECTION, + self.dict_of_deflection, + ) + + self._attr_is_on = self.dict_of_deflection["Enable"] == "1" + self._is_available = True + + self._attributes["type"] = self.dict_of_deflection["Type"] + self._attributes["number"] = self.dict_of_deflection["Number"] + self._attributes["deflection_to_number"] = self.dict_of_deflection[ + "DeflectionToNumber" + ] + # Return mode sample: "eImmediately" + self._attributes["mode"] = self.dict_of_deflection["Mode"][1:] + self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"] + self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"] + + async def _async_switch_on_off_executor(self, turn_on: bool) -> None: + """Handle deflection switch.""" + await async_service_call_action( + self._fritzbox_tools, + "X_AVM-DE_OnTel", + "1", + "SetDeflectionEnable", + NewDeflectionId=self.id, + NewEnable="1" if turn_on else "0", + ) + + +class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): + """Defines a FRITZ!Box Tools DeviceProfile switch.""" + + _attr_icon = "mdi:router-wireless-settings" + + def __init__(self, fritzbox_tools: FritzBoxTools, device: FritzDevice) -> None: + """Init Fritz profile.""" + super().__init__(fritzbox_tools, device) + self._attr_is_on: bool = False + self._name = f"{device.hostname} Internet Access" + self._attr_unique_id = f"{self._mac}_internet_access" + self._attr_entity_category = EntityCategory.CONFIG + + @property + def is_on(self) -> bool: + """Switch status.""" + return self._router.devices[self._mac].wan_access + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_handle_turn_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_handle_turn_on_off(turn_on=False) + + async def _async_handle_turn_on_off(self, turn_on: bool) -> bool: + """Handle switch state change request.""" + await self._async_switch_on_off(turn_on) + self.async_write_ha_state() + return True + + async def _async_switch_on_off(self, turn_on: bool) -> None: + """Handle parental control switch.""" + await async_service_call_action( + self._router, + "X_AVM-DE_HostFilter", + "1", + "DisallowWANAccessByIP", + NewIPv4Address=self.ip_address, + NewDisallow="0" if turn_on else "1", + ) + + +class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): + """Defines a FRITZ!Box Tools Wifi switch.""" + + def __init__( + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + network_num: int, + network_name: str, + ) -> None: + """Init Fritz Wifi switch.""" + self._fritzbox_tools = fritzbox_tools + + self._attributes = {} + self._attr_entity_category = EntityCategory.CONFIG + self._network_num = network_num + + switch_info = SwitchInfo( + description=f"Wi-Fi {network_name}", + friendly_name=device_friendly_name, + icon="mdi:wifi", + type=SWITCH_TYPE_WIFINETWORK, + callback_update=self._async_fetch_update, + callback_switch=self._async_switch_on_off_executor, + ) + super().__init__(self._fritzbox_tools, device_friendly_name, switch_info) + + async def _async_fetch_update(self) -> None: + """Fetch updates.""" + + wifi_info = await async_service_call_action( + self._fritzbox_tools, + "WLANConfiguration", + str(self._network_num), + "GetInfo", + ) + _LOGGER.debug( + "Specific %s response: GetInfo=%s", SWITCH_TYPE_WIFINETWORK, wifi_info + ) + + if wifi_info is None: + self._is_available = False + return + + self._attr_is_on = wifi_info["NewEnable"] is True + self._is_available = True + + std = wifi_info["NewStandard"] + self._attributes["standard"] = std if std else None + self._attributes["bssid"] = wifi_info["NewBSSID"] + self._attributes["mac_address_control"] = wifi_info[ + "NewMACAddressControlEnabled" + ] + + async def _async_switch_on_off_executor(self, turn_on: bool) -> None: + """Handle wifi switch.""" + await async_service_call_action( + self._fritzbox_tools, + "WLANConfiguration", + str(self._network_num), + "SetEnable", + NewEnable="1" if turn_on else "0", + ) diff --git a/homeassistant/components/fritz/translations/bg.json b/homeassistant/components/fritz/translations/bg.json new file mode 100644 index 0000000000000..3fca53d101305 --- /dev/null +++ b/homeassistant/components/fritz/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "start_config": { + "data": { + "port": "\u041f\u043e\u0440\u0442" + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/ca.json b/homeassistant/components/fritz/translations/ca.json index 1b55ba3e23d8c..2e240bb98339a 100644 --- a/homeassistant/components/fritz/translations/ca.json +++ b/homeassistant/components/fritz/translations/ca.json @@ -8,10 +8,11 @@ "error": { "already_configured": "El dispositiu ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "cannot_connect": "Ha fallat la connexi\u00f3", "connection_error": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, - "flow_title": "FRITZ!Box Tools: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "Configura FRITZ!Box Tools per poder controlar FRITZ!Box.\nEl m\u00ednim necessari \u00e9s: nom d'usuari i contrasenya.", "title": "Configuraci\u00f3 de FRITZ!Box Tools - obligatori" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari" + }, + "description": "Configura FRITZ!Box Tools per poder controlar FRITZ!Box.\nEl m\u00ednim necessari \u00e9s: nom d'usuari i contrasenya.", + "title": "Configuraci\u00f3 de FRITZ!Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segons d'espera abans de considerar un dispositiu a 'casa'" + } } } } diff --git a/homeassistant/components/fritz/translations/de.json b/homeassistant/components/fritz/translations/de.json index 037c9eb07f134..47938084f5b97 100644 --- a/homeassistant/components/fritz/translations/de.json +++ b/homeassistant/components/fritz/translations/de.json @@ -8,26 +8,27 @@ "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "cannot_connect": "Verbindung fehlgeschlagen", "connection_error": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung" }, - "flow_title": "FRITZ! Box Tools: {name}", + "flow_title": "FRITZ!Box Tools: {name}", "step": { "confirm": { "data": { "password": "Passwort", "username": "Benutzername" }, - "description": "Entdeckte FRITZ! Box: {name} \n\n Richten Sie FRITZ! Box Tools ein, um {name} zu kontrollieren", - "title": "FRITZ! Box Tools einrichten" + "description": "Entdeckte FRITZ!Box: {name} \n\nRichte deine FRITZ!Box Tools ein, um {name} zu kontrollieren", + "title": "FRITZ!Box Tools einrichten" }, "reauth_confirm": { "data": { "password": "Passwort", "username": "Benutzername" }, - "description": "Aktualisieren Sie die Anmeldeinformationen von FRITZ! Box Tools f\u00fcr: {host} . \n\n FRITZ! Box Tools kann sich nicht bei Ihrer FRITZ! Box anmelden.", - "title": "Aktualisieren der FRITZ! Box Tools - Anmeldeinformationen" + "description": "Aktualisiere die Anmeldeinformationen von FRITZ!Box Tools f\u00fcr: {host}. \n\nFRITZ!Box Tools kann sich nicht an deiner FRITZ!Box anmelden.", + "title": "Aktualisieren der FRITZ!Box Tools - Anmeldeinformationen" }, "start_config": { "data": { @@ -36,8 +37,27 @@ "port": "Port", "username": "Benutzername" }, - "description": "Einrichten der FRITZ! Box Tools zur Steuerung Ihrer FRITZ! Box.\n Ben\u00f6tigt: Benutzername, Passwort.", - "title": "Setup FRITZ! Box Tools - obligatorisch" + "description": "Einrichten der FRITZ!Box Tools zur Steuerung deiner FRITZ!Box.\nBen\u00f6tigt: Benutzername, Passwort.", + "title": "Setup FRITZ!Box Tools - obligatorisch" + }, + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + }, + "description": "FRITZ!Box Tools einrichten, um deine FRITZ!Box zu steuern.\nMindestens erforderlich: Benutzername, Passwort.", + "title": "Setup FRITZ!Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Sekunden, um ein Ger\u00e4t als 'zu Hause' zu betrachten" + } } } } diff --git a/homeassistant/components/fritz/translations/en.json b/homeassistant/components/fritz/translations/en.json index de1b61763f6d1..0fa47bd8328fa 100644 --- a/homeassistant/components/fritz/translations/en.json +++ b/homeassistant/components/fritz/translations/en.json @@ -8,6 +8,7 @@ "error": { "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", "connection_error": "Failed to connect", "invalid_auth": "Invalid authentication" }, @@ -38,6 +39,25 @@ }, "description": "Setup FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", "title": "Setup FRITZ!Box Tools - mandatory" + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username" + }, + "description": "Setup FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", + "title": "Setup FRITZ!Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to consider a device at 'home'" + } } } } diff --git a/homeassistant/components/fritz/translations/es-419.json b/homeassistant/components/fritz/translations/es-419.json new file mode 100644 index 0000000000000..94412f031e63a --- /dev/null +++ b/homeassistant/components/fritz/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\nM\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", + "title": "Configurar las herramientas de FRITZ! Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos para considerar un dispositivo en 'casa'" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/es.json b/homeassistant/components/fritz/translations/es.json index db9b2fa5c2a7d..45519eb7eb501 100644 --- a/homeassistant/components/fritz/translations/es.json +++ b/homeassistant/components/fritz/translations/es.json @@ -8,6 +8,7 @@ "error": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", + "cannot_connect": "No se pudo conectar", "connection_error": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, @@ -18,7 +19,7 @@ "password": "Contrase\u00f1a", "username": "Usuario" }, - "description": "Descubierto FRITZ!Box: {nombre}\n\nConfigurar FRITZ!Box Tools para controlar tu {nombre}", + "description": "Descubierto FRITZ!Box: {name}\n\nConfigurar FRITZ!Box Tools para controlar tu {name}", "title": "Configurar FRITZ!Box Tools" }, "reauth_confirm": { @@ -38,6 +39,25 @@ }, "description": "Configurar FRITZ!Box Tools para controlar tu FRITZ!Box.\nM\u00ednimo necesario: usuario, contrase\u00f1a.", "title": "Configurar FRITZ!Box Tools - obligatorio" + }, + "user": { + "data": { + "host": "Anfitri\u00f3n", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + }, + "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\n M\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", + "title": "Configurar las herramientas de FRITZ! Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos para considerar un dispositivo en 'casa'" + } } } } diff --git a/homeassistant/components/fritz/translations/et.json b/homeassistant/components/fritz/translations/et.json index 1ab5b27ffd720..2866ae13336e1 100644 --- a/homeassistant/components/fritz/translations/et.json +++ b/homeassistant/components/fritz/translations/et.json @@ -8,10 +8,11 @@ "error": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "already_in_progress": "Seadistamine on juba k\u00e4ivitatud", + "cannot_connect": "\u00dchendamine nurjus", "connection_error": "\u00dchendamine nurjus", "invalid_auth": "Tuvastamine nurjus" }, - "flow_title": "FRITZ!Box t\u00f6\u00f6riistad: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "Seadista FRITZ!Boxi t\u00f6\u00f6riistad oma FRITZ!Boxi juhtimiseks.\n Minimaalselt vaja: kasutajanimi ja salas\u00f5na.", "title": "FRITZ! Boxi t\u00f6\u00f6riistade seadistamine - kohustuslik" + }, + "user": { + "data": { + "host": "Host", + "password": "Salas\u00f5ma", + "port": "Port", + "username": "Kasutajanimi" + }, + "description": "Seadista FRITZ!Box Tools oma FRITZ!Boxi juhtimiseks.\n Minimaalselt vajalik: kasutajanimi ja salas\u00f5na.", + "title": "Seadista FRITZ! Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Millal m\u00e4\u00e4rata seade olema kodus (sekundites)" + } } } } diff --git a/homeassistant/components/fritz/translations/fr.json b/homeassistant/components/fritz/translations/fr.json index e0fa5dd3e8c52..38c8a9e802d09 100644 --- a/homeassistant/components/fritz/translations/fr.json +++ b/homeassistant/components/fritz/translations/fr.json @@ -1,14 +1,18 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9 ", - "connection_error": "Erreur de connexion", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", + "connection_error": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, - "flow_title": "FRITZ!Box Tools : {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -35,6 +39,25 @@ }, "description": "Configuration de FRITZ!Box Tools pour contr\u00f4ler votre FRITZ!Box.\nMinimum requis: nom d'utilisateur, mot de passe.", "title": "Configuration FRITZ!Box Tools - obligatoire" + }, + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "description": "Configurer FRITZ!Box Tools pour contr\u00f4ler votre FRITZ!Box.\n Minimum requis : nom d'utilisateur, mot de passe.", + "title": "Configurer les outils de FRITZ!Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Secondes pour consid\u00e9rer un appareil \u00e0 la 'maison'" + } } } } diff --git a/homeassistant/components/fritz/translations/he.json b/homeassistant/components/fritz/translations/he.json new file mode 100644 index 0000000000000..783f215cc405a --- /dev/null +++ b/homeassistant/components/fritz/translations/he.json @@ -0,0 +1,56 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "connection_error": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "start_config": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u05e9\u05e0\u05d9\u05d5\u05ea \u05db\u05d3\u05d9 \u05dc\u05d4\u05d7\u05e9\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df \u05d1'\u05d1\u05d9\u05ea'" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/hu.json b/homeassistant/components/fritz/translations/hu.json new file mode 100644 index 0000000000000..733a4fb1a8e43 --- /dev/null +++ b/homeassistant/components/fritz/translations/hu.json @@ -0,0 +1,64 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" + }, + "error": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "connection_error": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Felfedezte a FRITZ! Boxot: {name} \n\n A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa a {name}", + "title": "A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa" + }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "{host} FRITZ! Box Tools hiteles\u00edt\u0151 adatait. \n\n A FRITZ! Box Tools nem tud bejelentkezni a FRITZ! Box eszk\u00f6zbe.", + "title": "A FRITZ! Box Tools friss\u00edt\u00e9se - hiteles\u00edt\u0151 adatok" + }, + "start_config": { + "data": { + "host": "C\u00edm", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "A FRITZ! Box eszk\u00f6z\u00f6k be\u00e1ll\u00edt\u00e1sa a FRITZ! Box vez\u00e9rl\u00e9s\u00e9hez.\n Minimum sz\u00fcks\u00e9ges: felhaszn\u00e1l\u00f3n\u00e9v, jelsz\u00f3.", + "title": "A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa - k\u00f6telez\u0151" + }, + "user": { + "data": { + "host": "C\u00edm", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "A FRITZ! Box eszk\u00f6z\u00f6k be\u00e1ll\u00edt\u00e1sa a FRITZ! Box vez\u00e9rl\u00e9s\u00e9hez.\n Minimum sz\u00fcks\u00e9ges: felhaszn\u00e1l\u00f3n\u00e9v, jelsz\u00f3.", + "title": "A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "M\u00e1sodpercek egy eszk\u00f6z \"otthon\" tart\u00e1s\u00e1ra" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/id.json b/homeassistant/components/fritz/translations/id.json new file mode 100644 index 0000000000000..5aae1443d02f3 --- /dev/null +++ b/homeassistant/components/fritz/translations/id.json @@ -0,0 +1,64 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "cannot_connect": "Gagal terhubung", + "connection_error": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "FRITZ!Box ditemukan: {name} \n\nSiapkan FRITZ!Box Tools untuk mengontrol {name}", + "title": "Siapkan FRITZ!Box Tools." + }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Perbarui kredensial FRITZ!Box Tools untuk: {host} . \n\nFRITZ!Box Tools tidak dapat masuk ke FRITZ!Box Anda.", + "title": "Memperbarui FRITZ!Box Tools - kredensial" + }, + "start_config": { + "data": { + "host": "Host", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + }, + "description": "Siapkan FRITZ!Box Tools untuk mengontrol FRITZ!Box Anda.\nDiperlukan minimal: nama pengguna dan kata sandi.", + "title": "Siapkan FRITZ!Box Tools - wajib" + }, + "user": { + "data": { + "host": "Host", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + }, + "description": "Siapkan FRITZ!Box Tools untuk mengontrol FRITZ!Box Anda.\nDiperlukan minimal: nama pengguna dan kata sandi.", + "title": "Siapkan FRITZ!Box Tools." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Wakti dalam detik untuk mempertimbangkan perangkat sebagai 'di rumah'" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/it.json b/homeassistant/components/fritz/translations/it.json index 257198cf6848e..0169d275205b9 100644 --- a/homeassistant/components/fritz/translations/it.json +++ b/homeassistant/components/fritz/translations/it.json @@ -8,10 +8,11 @@ "error": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "cannot_connect": "Impossibile connettersi", "connection_error": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida" }, - "flow_title": "Strumenti FRITZ! Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "Configura gli strumenti FRITZ!Box per controllare il tuo FRITZ!Box.\n Minimo necessario: nome utente, password.", "title": "Configurazione degli strumenti FRITZ!Box - obbligatorio" + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "username": "Nome utente" + }, + "description": "Configura gli strumenti FRITZ!Box per controllare il tuo FRITZ! Box.\nMinimo necessario: nome utente, password.", + "title": "Configura gli strumenti del FRITZ!Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Secondi per considerare un dispositivo \"a casa\"" + } } } } diff --git a/homeassistant/components/fritz/translations/ja.json b/homeassistant/components/fritz/translations/ja.json new file mode 100644 index 0000000000000..156caabbfa363 --- /dev/null +++ b/homeassistant/components/fritz/translations/ja.json @@ -0,0 +1,64 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "connection_error": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u3092\u767a\u898b\u3057\u307e\u3057\u305f: {name}\n\nFRITZ!Box Tools\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001 {name} \u3092\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u3059\u308b\u3002", + "title": "FRITZ!Box Tools\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u306e\u8a8d\u8a3c\u3092\u66f4\u65b0\u3057\u307e\u3059: {host}\n\nFRITZ!Box Tools\u304c\u3001FRITZ!Box\u306b\u30ed\u30b0\u30a4\u30f3\u3067\u304d\u307e\u305b\u3093\u3002", + "title": "FRITZ!Box Tools\u306e\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8 - \u8a8d\u8a3c\u60c5\u5831" + }, + "start_config": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066FRITZ!Box\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002\n\u6700\u4f4e\u9650\u5fc5\u8981\u306a\u3082\u306e: \u30e6\u30fc\u30b6\u30fc\u540d\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3002", + "title": "FRITZ!Box Tools\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7 - \u5fc5\u9808" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066FRITZ!Box\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002\n\u6700\u4f4e\u9650\u5fc5\u8981\u306a\u3082\u306e: \u30e6\u30fc\u30b6\u30fc\u540d\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3002", + "title": "FRITZ!Box Tools\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "'\u30db\u30fc\u30e0' \u3067\u30c7\u30d0\u30a4\u30b9\u3092\u691c\u8a0e\u3059\u308b\u79d2\u6570" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/ko.json b/homeassistant/components/fritz/translations/ko.json new file mode 100644 index 0000000000000..718b105df33a6 --- /dev/null +++ b/homeassistant/components/fritz/translations/ko.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "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", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "reauth_confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "start_config": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/nl.json b/homeassistant/components/fritz/translations/nl.json index 563603aef5fb8..06c29cd9d381c 100644 --- a/homeassistant/components/fritz/translations/nl.json +++ b/homeassistant/components/fritz/translations/nl.json @@ -8,10 +8,11 @@ "error": { "already_configured": "Apparaat is al geconfigureerd", "already_in_progress": "De configuratiestroom is al aan de gang", + "cannot_connect": "Kan geen verbinding maken", "connection_error": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie" }, - "flow_title": "FRITZ!Box Tools: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "Stel FRITZ!Box Tools in om uw FRITZ!Box te bedienen.\nMinimaal nodig: gebruikersnaam, wachtwoord.", "title": "Configureer FRITZ! Box Tools - verplicht" + }, + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "description": "Stel FRITZ!Box Tools in om uw FRITZ!Box te bedienen.\nMinimaal nodig: gebruikersnaam, wachtwoord.", + "title": "Setup FRITZ!Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconden om een apparaat als \"thuis\" te beschouwen" + } } } } diff --git a/homeassistant/components/fritz/translations/no.json b/homeassistant/components/fritz/translations/no.json index e3b642a159437..0838efbf64968 100644 --- a/homeassistant/components/fritz/translations/no.json +++ b/homeassistant/components/fritz/translations/no.json @@ -8,10 +8,11 @@ "error": { "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "cannot_connect": "Tilkobling mislyktes", "connection_error": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning" }, - "flow_title": "FRITZ!Box Verkt\u00f8y: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "Sett opp FRITZ!Box verkt\u00f8y for \u00e5 kontrollere fritz! Boksen.\nMinimum n\u00f8dvendig: brukernavn, passord.", "title": "Sett opp FRITZ!Box verkt\u00f8y - obligatorisk" + }, + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "username": "Brukernavn" + }, + "description": "Sett opp FRITZ!Box verkt\u00f8y for \u00e5 kontrollere fritz! Boksen.\nMinimum n\u00f8dvendig: brukernavn, passord.", + "title": "Sett opp FRITZ!Box verkt\u00f8y" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Sekunder \u00e5 vurdere en enhet hjemme" + } } } } diff --git a/homeassistant/components/fritz/translations/pl.json b/homeassistant/components/fritz/translations/pl.json index 9d3f934d177c1..5632ae6769430 100644 --- a/homeassistant/components/fritz/translations/pl.json +++ b/homeassistant/components/fritz/translations/pl.json @@ -8,10 +8,11 @@ "error": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_auth": "Niepoprawne uwierzytelnienie" }, - "flow_title": "Narz\u0119dzia FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "Skonfiguruj narz\u0119dzia FRITZ!Box, aby sterowa\u0107 urz\u0105dzeniem FRITZ! Box.\nMinimalne wymagania: nazwa u\u017cytkownika, has\u0142o.", "title": "Konfiguracja narz\u0119dzi FRITZ!Box - obowi\u0105zkowe" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Skonfiguruj narz\u0119dzia FRITZ!Box, aby sterowa\u0107 urz\u0105dzeniem FRITZ!Box.\nMinimalne wymagania: nazwa u\u017cytkownika, has\u0142o.", + "title": "Konfiguracja narz\u0119dzi FRITZ!Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Czas w sekundach, zanim urz\u0105dzenie otrzyma stan \"w domu\"" + } } } } diff --git a/homeassistant/components/fritz/translations/ru.json b/homeassistant/components/fritz/translations/ru.json index b50c42c4bfcf7..54619e22a36df 100644 --- a/homeassistant/components/fritz/translations/ru.json +++ b/homeassistant/components/fritz/translations/ru.json @@ -8,10 +8,11 @@ "error": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, - "flow_title": "FRITZ!Box Tools: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 FRITZ!Box Tools \u0434\u043b\u044f \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0412\u0430\u0448\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c FRITZ!Box.\n\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0443\u043a\u0430\u0437\u0430\u0442\u044c \u043a\u0430\u043a \u043c\u0438\u043d\u0438\u043c\u0443\u043c \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", "title": "FRITZ!Box Tools" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 FRITZ!Box Tools \u0434\u043b\u044f \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0412\u0430\u0448\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c FRITZ!Box.\n\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0443\u043a\u0430\u0437\u0430\u0442\u044c \u043a\u0430\u043a \u043c\u0438\u043d\u0438\u043c\u0443\u043c \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", + "title": "FRITZ!Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + } } } } diff --git a/homeassistant/components/fritz/translations/tr.json b/homeassistant/components/fritz/translations/tr.json new file mode 100644 index 0000000000000..686c248cd39c7 --- /dev/null +++ b/homeassistant/components/fritz/translations/tr.json @@ -0,0 +1,64 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "connection_error": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Bulunan FRITZ!Box: {name} \n\n {name} kontrol etmek i\u00e7in FRITZ!Box Tools'u kurun", + "title": "FRITZ!Box Tools Kurulumu" + }, + "reauth_confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "{host} i\u00e7in FRITZ!Box Tools kimlik bilgilerini g\u00fcncelleyin. \n\n FRITZ!Box Tools, FRITZ!Box'\u0131n\u0131zda oturum a\u00e7am\u0131yor.", + "title": "FRITZ!Box Tools - kimlik bilgilerinin g\u00fcncellenmesi" + }, + "start_config": { + "data": { + "host": "Sunucu", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "FRITZ!Box'\u0131n\u0131z\u0131 kontrol etmek i\u00e7in FRITZ!Box Tools'u kurun.\n Minimum gerekli: kullan\u0131c\u0131 ad\u0131, \u015fifre.", + "title": "FRITZ!Box Tools Kurulumu - zorunlu" + }, + "user": { + "data": { + "host": "Sunucu", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "FRITZ!Box'\u0131n\u0131z\u0131 kontrol etmek i\u00e7in FRITZ!Box Tools'u kurun.\n Minimum gerekli: kullan\u0131c\u0131 ad\u0131, \u015fifre.", + "title": "FRITZ!Box Tools Kurulumu" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Bir cihaz\u0131 'evde' varsaymak i\u00e7in saniye" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/zh-Hans.json b/homeassistant/components/fritz/translations/zh-Hans.json new file mode 100644 index 0000000000000..91d68989675ee --- /dev/null +++ b/homeassistant/components/fritz/translations/zh-Hans.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "start_config": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u914d\u7f6e FRITZ!Box Tool \u4ee5\u63a7\u5236\u60a8\u7684 FRITZ!Box\u3002\n\u6700\u4f4e\u4fe1\u606f\u63d0\u4f9b\u8981\u6c42\uff1a\u7528\u6237\u540d\u3001\u5bc6\u7801\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/zh-Hant.json b/homeassistant/components/fritz/translations/zh-Hant.json index 29872e14868fe..861ec6d62ce99 100644 --- a/homeassistant/components/fritz/translations/zh-Hant.json +++ b/homeassistant/components/fritz/translations/zh-Hant.json @@ -8,10 +8,11 @@ "error": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "cannot_connect": "\u9023\u7dda\u5931\u6557", "connection_error": "\u9023\u7dda\u5931\u6557", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" }, - "flow_title": "FRITZ!Box Tools\uff1a{name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -38,6 +39,25 @@ }, "description": "\u8a2d\u5b9a FRITZ!Box Tools \u4ee5\u63a7\u5236 FRITZ!Box\u3002\n\u9700\u8981\u8f38\u5165\uff1a\u4f7f\u7528\u8005\u540d\u7a31\u3001\u5bc6\u78bc\u3002", "title": "\u8a2d\u5b9a FRITZ!Box Tools - \u5f37\u5236" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a FRITZ!Box Tools \u4ee5\u63a7\u5236 FRITZ!Box\u3002\n\u9700\u8981\u8f38\u5165\uff1a\u4f7f\u7528\u8005\u540d\u7a31\u3001\u5bc6\u78bc\u3002", + "title": "\u8a2d\u5b9a FRITZ!Box Tools" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u8996\u70ba\u5728\u5bb6\u7684\u7b49\u5019\u79d2\u6578" + } } } } diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index dc2001957488c..e72e1d86fc119 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -1,30 +1,33 @@ """Support for AVM FRITZ!SmartHome devices.""" from __future__ import annotations -from datetime import timedelta - from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError -import requests from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_LOCKED, + CONF_CONNECTIONS, + CONF_COORDINATOR, + DOMAIN, + LOGGER, + PLATFORMS, ) - -from .const import CONF_CONNECTIONS, CONF_COORDINATOR, DOMAIN, LOGGER, PLATFORMS +from .coordinator import FritzboxDataUpdateCoordinator +from .model import FritzExtraAttributes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -45,43 +48,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_CONNECTIONS: fritz, } - def _update_fritz_devices() -> dict[str, FritzhomeDevice]: - """Update all fritzbox device data.""" - try: - devices = fritz.get_devices() - except requests.exceptions.HTTPError: - # If the device rebooted, login again - try: - fritz.login() - except requests.exceptions.HTTPError as ex: - raise ConfigEntryAuthFailed from ex - devices = fritz.get_devices() - - data = {} - for device in devices: - device.update() - data[device.ain] = device - return data - - async def async_update_coordinator(): - """Fetch all device data.""" - return await hass.async_add_executor_job(_update_fritz_devices) - - hass.data[DOMAIN][entry.entry_id][ - CONF_COORDINATOR - ] = coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name=f"{entry.entry_id}", - update_method=async_update_coordinator, - update_interval=timedelta(seconds=30), - ) + coordinator = FritzboxDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id][CONF_COORDINATOR] = coordinator + + def _update_unique_id(entry: RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if ( + entry.unit_of_measurement == TEMP_CELSIUS + and "_temperature" not in entry.unique_id + ): + new_unique_id = f"{entry.unique_id}_temperature" + LOGGER.info( + "Migrating unique_id [%s] to [%s]", entry.unique_id, new_unique_id + ) + return {"new_unique_id": new_unique_id} + return None + + await async_migrate_entries(hass, entry.entry_id, _update_unique_id) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - def logout_fritzbox(event): + def logout_fritzbox(event: Event) -> None: """Close connections to this fritzbox.""" fritz.logout() @@ -107,20 +97,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class FritzBoxEntity(CoordinatorEntity): """Basis FritzBox entity.""" + coordinator: FritzboxDataUpdateCoordinator + def __init__( self, - entity_info: dict[str, str], - coordinator: DataUpdateCoordinator, + coordinator: FritzboxDataUpdateCoordinator, ain: str, - ): + entity_description: EntityDescription | None = None, + ) -> None: """Initialize the FritzBox entity.""" super().__init__(coordinator) self.ain = ain - self._name = entity_info[ATTR_NAME] - self._unique_id = entity_info[ATTR_ENTITY_ID] - self._unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] - self._device_class = entity_info[ATTR_DEVICE_CLASS] + if entity_description is not None: + self.entity_description = entity_description + self._attr_name = f"{self.device.name} {entity_description.name}" + self._attr_unique_id = f"{ain}_{entity_description.key}" + else: + self._attr_name = self.device.name + self._attr_unique_id = ain + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.device.present @property def device(self) -> FritzhomeDevice: @@ -128,32 +128,21 @@ def device(self) -> FritzhomeDevice: return self.coordinator.data[self.ain] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "name": self.device.name, - "identifiers": {(DOMAIN, self.ain)}, - "manufacturer": self.device.manufacturer, - "model": self.device.productname, - "sw_version": self.device.fw_version, - } - - @property - def unique_id(self): - """Return the unique ID of the device.""" - return self._unique_id - - @property - def name(self): - """Return the name of the device.""" - return self._name + return DeviceInfo( + name=self.device.name, + identifiers={(DOMAIN, self.ain)}, + manufacturer=self.device.manufacturer, + model=self.device.productname, + sw_version=self.device.fw_version, + configuration_url=self.coordinator.configuration_url, + ) @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self): - """Return the device class.""" - return self._device_class + def extra_state_attributes(self) -> FritzExtraAttributes: + """Return the state attributes of the device.""" + return { + ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, + ATTR_STATE_LOCKED: self.device.lock, + } diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 807ca41ca64d6..e83a2a6747258 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,55 +1,85 @@ """Support for Fritzbox binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from pyfritzhome.fritzhomedevice import FritzhomeDevice + from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_WINDOW, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .coordinator import FritzboxDataUpdateCoordinator +from .model import FritzEntityDescriptionMixinBase + + +@dataclass +class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): + """BinarySensor description mixin for Fritz!Smarthome entities.""" + + is_on: Callable[[FritzhomeDevice], bool | None] + + +@dataclass +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor +): + """Description for Fritz!Smarthome binary sensor entities.""" + + +BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( + FritzBinarySensorEntityDescription( + key="alarm", + name="Alarm", + device_class=BinarySensorDeviceClass.WINDOW, + suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] + is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + ), +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - entities = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_alarm: - continue - - entities.append( - FritzboxBinarySensor( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_WINDOW, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxBinarySensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in BINARY_SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): """Representation of a binary FRITZ!SmartHome device.""" + entity_description: FritzBinarySensorEntityDescription + + def __init__( + self, + coordinator: FritzboxDataUpdateCoordinator, + ain: str, + entity_description: FritzBinarySensorEntityDescription, + ) -> None: + """Initialize the FritzBox entity.""" + super().__init__(coordinator, ain, entity_description) + self._attr_name = self.device.name + self._attr_unique_id = ain + @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" - if not self.device.present: - return False - return self.device.alert_state + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 947a95a7a5be1..0f481773778aa 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -1,4 +1,8 @@ """Support for AVM FRITZ!SmartHome thermostate devices.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, @@ -12,11 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, PRECISION_HALVES, TEMP_CELSIUS, ) @@ -34,6 +34,7 @@ CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, ) +from .model import ClimateExtraAttributes SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE @@ -55,95 +56,80 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - entities = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_thermostat: - continue - - entities.append( - FritzboxThermostat( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxThermostat(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_thermostat + ] + ) class FritzboxThermostat(FritzBoxEntity, ClimateEntity): """The thermostat class for FRITZ!SmartHome thermostates.""" @property - def supported_features(self): + def supported_features(self) -> int: """Return the list of supported features.""" return SUPPORT_FLAGS @property - def available(self): - """Return if thermostat is available.""" - return self.device.present - - @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement that is used.""" return TEMP_CELSIUS @property - def precision(self): + def precision(self) -> float: """Return precision 0.5.""" return PRECISION_HALVES @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" - return self.device.actual_temperature + if self.device.has_temperature_sensor and self.device.temperature is not None: + return self.device.temperature # type: ignore [no-any-return] + return self.device.actual_temperature # type: ignore [no-any-return] @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" if self.device.target_temperature == ON_API_TEMPERATURE: return ON_REPORT_SET_TEMPERATURE if self.device.target_temperature == OFF_API_TEMPERATURE: return OFF_REPORT_SET_TEMPERATURE - return self.device.target_temperature + return self.device.target_temperature # type: ignore [no-any-return] - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if ATTR_HVAC_MODE in kwargs: - hvac_mode = kwargs.get(ATTR_HVAC_MODE) + if kwargs.get(ATTR_HVAC_MODE) is not None: + hvac_mode = kwargs[ATTR_HVAC_MODE] await self.async_set_hvac_mode(hvac_mode) - elif ATTR_TEMPERATURE in kwargs: - temperature = kwargs.get(ATTR_TEMPERATURE) + elif kwargs.get(ATTR_TEMPERATURE) is not None: + temperature = kwargs[ATTR_TEMPERATURE] await self.hass.async_add_executor_job( self.device.set_target_temperature, temperature ) await self.coordinator.async_refresh() @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return the current operation mode.""" - if ( - self.device.target_temperature == OFF_REPORT_SET_TEMPERATURE - or self.device.target_temperature == OFF_API_TEMPERATURE + if self.device.target_temperature in ( + OFF_REPORT_SET_TEMPERATURE, + OFF_API_TEMPERATURE, ): return HVAC_MODE_OFF return HVAC_MODE_HEAT @property - def hvac_modes(self): + def hvac_modes(self) -> list[str]: """Return the list of available operation modes.""" return OPERATION_LIST - async def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new operation mode.""" if hvac_mode == HVAC_MODE_OFF: await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) @@ -153,7 +139,7 @@ async def async_set_hvac_mode(self, hvac_mode): ) @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return current preset mode.""" if self.device.target_temperature == self.device.comfort_temperature: return PRESET_COMFORT @@ -162,11 +148,11 @@ def preset_mode(self): return None @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return supported preset modes.""" return [PRESET_ECO, PRESET_COMFORT] - async def async_set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" if preset_mode == PRESET_COMFORT: await self.async_set_temperature( @@ -176,19 +162,19 @@ async def async_set_preset_mode(self, preset_mode): await self.async_set_temperature(temperature=self.device.eco_temperature) @property - def min_temp(self): + def min_temp(self) -> int: """Return the minimum temperature.""" return MIN_TEMPERATURE @property - def max_temp(self): + def max_temp(self) -> int: """Return the maximum temperature.""" return MAX_TEMPERATURE @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> ClimateExtraAttributes: """Return the device specific state attributes.""" - attrs = { + attrs: ClimateExtraAttributes = { ATTR_STATE_BATTERY_LOW: self.device.battery_low, ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, ATTR_STATE_LOCKED: self.device.lock, @@ -201,7 +187,7 @@ def extra_state_attributes(self): attrs[ATTR_STATE_HOLIDAY_MODE] = self.device.holiday_active if self.device.summer_active is not None: attrs[ATTR_STATE_SUMMER_MODE] = self.device.summer_active - if ATTR_STATE_WINDOW_OPEN is not None: + if self.device.window_open is not None: attrs[ATTR_STATE_WINDOW_OPEN] = self.device.window_open return attrs diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 79763d18d2a25..0841757d14759 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -1,17 +1,17 @@ """Config flow for AVM FRITZ!SmartHome.""" +from __future__ import annotations + +from typing import Any from urllib.parse import urlparse from pyfritzhome import Fritzhome, LoginError from requests.exceptions import HTTPError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_UDN, -) +from homeassistant.components import ssdp +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -36,22 +36,22 @@ RESULT_SUCCESS = "success" -class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a AVM FRITZ!SmartHome config flow.""" VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" - self._entry = None - self._host = None - self._name = None - self._password = None - self._username = None + self._entry: ConfigEntry | None = None + self._host: str | None = None + self._name: str | None = None + self._password: str | None = None + self._username: str | None = None - def _get_entry(self): + def _get_entry(self, name: str) -> FlowResult: return self.async_create_entry( - title=self._name, + title=name, data={ CONF_HOST: self._host, CONF_PASSWORD: self._password, @@ -59,7 +59,8 @@ def _get_entry(self): }, ) - async def _update_entry(self): + async def _update_entry(self) -> None: + assert self._entry is not None self.hass.config_entries.async_update_entry( self._entry, data={ @@ -70,7 +71,7 @@ async def _update_entry(self): ) await self.hass.config_entries.async_reload(self._entry.entry_id) - def _try_connect(self): + def _try_connect(self) -> str: """Try to connect and check auth.""" fritzbox = Fritzhome( host=self._host, user=self._username, password=self._password @@ -87,25 +88,24 @@ def _try_connect(self): except OSError: return RESULT_NO_DEVICES_FOUND - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - - for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == user_input[CONF_HOST]: - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) self._host = user_input[CONF_HOST] - self._name = user_input[CONF_HOST] + self._name = str(user_input[CONF_HOST]) self._password = user_input[CONF_PASSWORD] self._username = user_input[CONF_USERNAME] result = await self.hass.async_add_executor_job(self._try_connect) if result == RESULT_SUCCESS: - return self._get_entry() + return self._get_entry(self._name) if result != RESULT_INVALID_AUTH: return self.async_abort(reason=result) errors["base"] = result @@ -114,13 +114,13 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by discovery.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location).hostname + assert isinstance(host, str) self.context[CONF_HOST] = host - uuid = discovery_info.get(ATTR_UPNP_UDN) - if uuid: + if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] await self.async_set_unique_id(uuid) @@ -131,19 +131,21 @@ async def async_step_ssdp(self, discovery_info): return self.async_abort(reason="already_in_progress") # update old and user-configured config entries - for entry in self.hass.config_entries.async_entries(DOMAIN): + for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: if uuid and not entry.unique_id: self.hass.config_entries.async_update_entry(entry, unique_id=uuid) return self.async_abort(reason="already_configured") self._host = host - self._name = discovery_info.get(ATTR_UPNP_FRIENDLY_NAME) or host + self._name = str(discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or host) self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user-confirmation of discovered node.""" errors = {} @@ -153,7 +155,8 @@ async def async_step_confirm(self, user_input=None): result = await self.hass.async_add_executor_job(self._try_connect) if result == RESULT_SUCCESS: - return self._get_entry() + assert self._name is not None + return self._get_entry(self._name) if result != RESULT_INVALID_AUTH: return self.async_abort(reason=result) errors["base"] = result @@ -165,16 +168,20 @@ async def async_step_confirm(self, user_input=None): errors=errors, ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: """Trigger a reauthentication flow.""" - self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self._entry = entry self._host = data[CONF_HOST] - self._name = data[CONF_HOST] + self._name = str(data[CONF_HOST]) self._username = data[CONF_USERNAME] return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauthorization flow.""" errors = {} diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index edfc13d49fe75..d4827fbb28901 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -1,26 +1,35 @@ """Constants for the AVM FRITZ!SmartHome integration.""" +from __future__ import annotations + import logging +from typing import Final -ATTR_STATE_BATTERY_LOW = "battery_low" -ATTR_STATE_DEVICE_LOCKED = "device_locked" -ATTR_STATE_HOLIDAY_MODE = "holiday_mode" -ATTR_STATE_LOCKED = "locked" -ATTR_STATE_SUMMER_MODE = "summer_mode" -ATTR_STATE_WINDOW_OPEN = "window_open" +from homeassistant.const import Platform -ATTR_TEMPERATURE_UNIT = "temperature_unit" +ATTR_STATE_BATTERY_LOW: Final = "battery_low" +ATTR_STATE_DEVICE_LOCKED: Final = "device_locked" +ATTR_STATE_HOLIDAY_MODE: Final = "holiday_mode" +ATTR_STATE_LOCKED: Final = "locked" +ATTR_STATE_SUMMER_MODE: Final = "summer_mode" +ATTR_STATE_WINDOW_OPEN: Final = "window_open" -ATTR_TOTAL_CONSUMPTION = "total_consumption" -ATTR_TOTAL_CONSUMPTION_UNIT = "total_consumption_unit" +COLOR_MODE: Final = "1" +COLOR_TEMP_MODE: Final = "4" -CONF_CONNECTIONS = "connections" -CONF_COORDINATOR = "coordinator" +CONF_CONNECTIONS: Final = "connections" +CONF_COORDINATOR: Final = "coordinator" -DEFAULT_HOST = "fritz.box" -DEFAULT_USERNAME = "admin" +DEFAULT_HOST: Final = "fritz.box" +DEFAULT_USERNAME: Final = "admin" -DOMAIN = "fritzbox" +DOMAIN: Final = "fritzbox" -LOGGER: logging.Logger = logging.getLogger(__package__) +LOGGER: Final[logging.Logger] = logging.getLogger(__package__) -PLATFORMS = ["binary_sensor", "climate", "switch", "sensor"] +PLATFORMS: Final[list[Platform]] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.LIGHT, + Platform.SWITCH, + Platform.SENSOR, +] diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py new file mode 100644 index 0000000000000..69ab0b4c27435 --- /dev/null +++ b/homeassistant/components/fritzbox/coordinator.py @@ -0,0 +1,68 @@ +"""Data update coordinator for AVM FRITZ!SmartHome devices.""" +from __future__ import annotations + +from datetime import timedelta + +from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_CONNECTIONS, DOMAIN, LOGGER + + +class FritzboxDataUpdateCoordinator(DataUpdateCoordinator): + """Fritzbox Smarthome device data update coordinator.""" + + configuration_url: str + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Fritzbox Smarthome device coordinator.""" + self.entry = entry + self.fritz: Fritzhome = hass.data[DOMAIN][self.entry.entry_id][CONF_CONNECTIONS] + self.configuration_url = self.fritz.get_prefixed_host() + super().__init__( + hass, + LOGGER, + name=entry.entry_id, + update_interval=timedelta(seconds=30), + ) + + def _update_fritz_devices(self) -> dict[str, FritzhomeDevice]: + """Update all fritzbox device data.""" + try: + devices = self.fritz.get_devices() + except requests.exceptions.ConnectionError as ex: + raise ConfigEntryNotReady from ex + except requests.exceptions.HTTPError: + # If the device rebooted, login again + try: + self.fritz.login() + except LoginError as ex: + raise ConfigEntryAuthFailed from ex + devices = self.fritz.get_devices() + + data = {} + self.fritz.update_devices() + for device in devices: + # assume device as unavailable, see #55799 + if ( + device.has_powermeter + and device.present + and hasattr(device, "voltage") + and device.voltage <= 0 + and device.power <= 0 + and device.energy <= 0 + ): + LOGGER.debug("Assume device %s as unavailable", device.name) + device.present = False + + data[device.ain] = device + return data + + async def _async_update_data(self) -> dict[str, FritzhomeDevice]: + """Fetch all device data.""" + return await self.hass.async_add_executor_job(self._update_fritz_devices) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py new file mode 100644 index 0000000000000..272d170e13d3f --- /dev/null +++ b/homeassistant/components/fritzbox/light.py @@ -0,0 +1,153 @@ +"""Support for AVM FRITZ!SmartHome lightbulbs.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color + +from . import FritzBoxEntity +from .const import ( + COLOR_MODE, + COLOR_TEMP_MODE, + CONF_COORDINATOR, + DOMAIN as FRITZBOX_DOMAIN, +) +from .coordinator import FritzboxDataUpdateCoordinator + +SUPPORTED_COLOR_MODES = {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FRITZ!SmartHome light from ConfigEntry.""" + entities: list[FritzboxLight] = [] + coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] + + for ain, device in coordinator.data.items(): + if not device.has_lightbulb: + continue + + supported_color_temps = await hass.async_add_executor_job( + device.get_color_temps + ) + + supported_colors = await hass.async_add_executor_job(device.get_colors) + + entities.append( + FritzboxLight( + coordinator, + ain, + supported_colors, + supported_color_temps, + ) + ) + + async_add_entities(entities) + + +class FritzboxLight(FritzBoxEntity, LightEntity): + """The light class for FRITZ!SmartHome lightbulbs.""" + + def __init__( + self, + coordinator: FritzboxDataUpdateCoordinator, + ain: str, + supported_colors: dict, + supported_color_temps: list[str], + ) -> None: + """Initialize the FritzboxLight entity.""" + super().__init__(coordinator, ain, None) + + max_kelvin = int(max(supported_color_temps)) + min_kelvin = int(min(supported_color_temps)) + + # max kelvin is min mireds and min kelvin is max mireds + self._attr_min_mireds = color.color_temperature_kelvin_to_mired(max_kelvin) + self._attr_max_mireds = color.color_temperature_kelvin_to_mired(min_kelvin) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + self._supported_hs = {} + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] + + @property + def is_on(self) -> bool: + """If the light is currently on or off.""" + return self.device.state # type: ignore [no-any-return] + + @property + def brightness(self) -> int: + """Return the current Brightness.""" + return self.device.level # type: ignore [no-any-return] + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hs color value.""" + if self.device.color_mode != COLOR_MODE: + return None + + hue = self.device.hue + saturation = self.device.saturation + + return (hue, float(saturation) * 100.0 / 255.0) + + @property + def color_temp(self) -> int | None: + """Return the CT color value.""" + if self.device.color_mode != COLOR_TEMP_MODE: + return None + + kelvin = self.device.color_temp + return color.color_temperature_kelvin_to_mired(kelvin) + + @property + def supported_color_modes(self) -> set: + """Flag supported color modes.""" + return SUPPORTED_COLOR_MODES + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + if kwargs.get(ATTR_BRIGHTNESS) is not None: + level = kwargs[ATTR_BRIGHTNESS] + await self.hass.async_add_executor_job(self.device.set_level, level) + if kwargs.get(ATTR_HS_COLOR) is not None: + hass_hue = int(kwargs[ATTR_HS_COLOR][0]) + hass_saturation = round(kwargs[ATTR_HS_COLOR][1] * 255.0 / 100.0) + # find supported hs values closest to what user selected + hue = min(self._supported_hs.keys(), key=lambda x: abs(x - hass_hue)) + saturation = min( + self._supported_hs[hue], key=lambda x: abs(x - hass_saturation) + ) + await self.hass.async_add_executor_job( + self.device.set_color, (hue, saturation) + ) + + if kwargs.get(ATTR_COLOR_TEMP) is not None: + kelvin = color.color_temperature_kelvin_to_mired(kwargs[ATTR_COLOR_TEMP]) + await self.hass.async_add_executor_job(self.device.set_color_temp, kelvin) + + await self.hass.async_add_executor_job(self.device.set_state_on) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.hass.async_add_executor_job(self.device.set_state_off) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 3daecb1980dcf..98c02d0166ed2 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -2,13 +2,13 @@ "domain": "fritzbox", "name": "AVM FRITZ!SmartHome", "documentation": "https://www.home-assistant.io/integrations/fritzbox", - "requirements": ["pyfritzhome==0.4.2"], + "requirements": ["pyfritzhome==0.6.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" } ], - "codeowners": ["@mib1185"], + "codeowners": ["@mib1185", "@flabbamann"], "config_flow": true, "iot_class": "local_polling" } diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py new file mode 100644 index 0000000000000..133638c1fe8ee --- /dev/null +++ b/homeassistant/components/fritzbox/model.py @@ -0,0 +1,32 @@ +"""Models for the AVM FRITZ!SmartHome integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TypedDict + +from pyfritzhome import FritzhomeDevice + + +class FritzExtraAttributes(TypedDict): + """TypedDict for sensors extra attributes.""" + + device_locked: bool + locked: bool + + +class ClimateExtraAttributes(FritzExtraAttributes, total=False): + """TypedDict for climates extra attributes.""" + + battery_level: int + battery_low: bool + holiday_mode: bool + summer_mode: bool + window_open: bool + + +@dataclass +class FritzEntityDescriptionMixinBase: + """Bases description mixin for Fritz!Smarthome entities.""" + + suitable: Callable[[FritzhomeDevice], bool] diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 836b4fb407ceb..473e68ed5da37 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,24 +1,155 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Final + +from pyfritzhome.fritzhomedevice import FritzhomeDevice + +from homeassistant.components.climate.const import PRESET_COMFORT, PRESET_ECO +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, + ENERGY_KILO_WATT_HOUR, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .model import FritzEntityDescriptionMixinBase + + +@dataclass +class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): + """Sensor description mixin for Fritz!Smarthome entities.""" + + native_value: Callable[[FritzhomeDevice], StateType | datetime] + + +@dataclass +class FritzSensorEntityDescription( + SensorEntityDescription, FritzEntityDescriptionMixinSensor +): + """Description for Fritz!Smarthome sensor entities.""" + + +SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( + FritzSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suitable=lambda device: ( + device.has_temperature_sensor and not device.has_thermostat + ), + native_value=lambda device: device.temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + suitable=lambda device: device.rel_humidity is not None, + native_value=lambda device: device.rel_humidity, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + suitable=lambda device: device.battery_level is not None, + native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.power / 1000 if device.power else 0.0, + ), + FritzSensorEntityDescription( + key="total_energy", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.energy / 1000 if device.energy else 0.0, + ), + # Thermostat Sensors + FritzSensorEntityDescription( + key="comfort_temperature", + name="Comfort Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.comfort_temperature is not None, + native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="eco_temperature", + name="Eco Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.eco_temperature is not None, + native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="nextchange_temperature", + name="Next Scheduled Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="nextchange_time", + name="Next Scheduled Change Time", + device_class=SensorDeviceClass.TIMESTAMP, + suitable=lambda device: device.has_thermostat + and device.nextchange_endperiod is not None, + native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), + ), + FritzSensorEntityDescription( + key="nextchange_preset", + name="Next Scheduled Preset", + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: PRESET_ECO + if device.nextchange_temperature == device.eco_temperature + else PRESET_COMFORT, + ), + FritzSensorEntityDescription( + key="scheduled_preset", + name="Current Scheduled Preset", + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: PRESET_COMFORT + if device.nextchange_temperature == device.eco_temperature + else PRESET_ECO, + ), ) @@ -26,67 +157,24 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - entities = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if ( - device.has_temperature_sensor - and not device.has_switch - and not device.has_thermostat - ): - entities.append( - FritzBoxTempSensor( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - if device.battery_level is not None: - entities.append( - FritzBoxBatterySensor( - { - ATTR_NAME: f"{device.name} Battery", - ATTR_ENTITY_ID: f"{device.ain}_battery", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) - - -class FritzBoxBatterySensor(FritzBoxEntity, SensorEntity): - """The entity class for FRITZ!SmartHome sensors.""" + async_add_entities( + [ + FritzBoxSensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in SENSOR_TYPES + if description.suitable(device) + ] + ) - @property - def state(self): - """Return the state of the sensor.""" - return self.device.battery_level +class FritzBoxSensor(FritzBoxEntity, SensorEntity): + """The entity class for FRITZ!SmartHome sensors.""" -class FritzBoxTempSensor(FritzBoxEntity, SensorEntity): - """The entity class for FRITZ!SmartHome temperature sensors.""" + entity_description: FritzSensorEntityDescription @property - def state(self): + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" - return self.device.temperature - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attrs = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs + return self.entity_description.native_value(self.device) diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 39d8c4e8ec2ce..79f256bded001 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -1,104 +1,46 @@ """Support for AVM FRITZ!SmartHome switch devices.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - ENERGY_KILO_WATT_HOUR, - TEMP_CELSIUS, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - ATTR_TEMPERATURE_UNIT, - ATTR_TOTAL_CONSUMPTION, - ATTR_TOTAL_CONSUMPTION_UNIT, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, -) - -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - entities = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_switch: - continue - - entities.append( - FritzboxSwitch( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxSwitch(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_switch + ] + ) class FritzboxSwitch(FritzBoxEntity, SwitchEntity): """The switch class for FRITZ!SmartHome switches.""" @property - def available(self): - """Return if switch is available.""" - return self.device.present - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if the switch is on.""" - return self.device.switch_state + return self.device.switch_state # type: ignore [no-any-return] - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.hass.async_add_executor_job(self.device.set_switch_state_on) await self.coordinator.async_refresh() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.hass.async_add_executor_job(self.device.set_switch_state_off) await self.coordinator.async_refresh() - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attrs = {} - attrs[ATTR_STATE_DEVICE_LOCKED] = self.device.device_lock - attrs[ATTR_STATE_LOCKED] = self.device.lock - - if self.device.has_powermeter: - attrs[ - ATTR_TOTAL_CONSUMPTION - ] = f"{((self.device.energy or 0.0) / 1000):.3f}" - attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = ATTR_TOTAL_CONSUMPTION_UNIT_VALUE - if self.device.has_temperature_sensor: - attrs[ATTR_TEMPERATURE] = str( - self.hass.config.units.temperature( - self.device.temperature, TEMP_CELSIUS - ) - ) - attrs[ATTR_TEMPERATURE_UNIT] = self.hass.config.units.temperature_unit - return attrs - - @property - def current_power_w(self): - """Return the current power usage in W.""" - return self.device.power / 1000 diff --git a/homeassistant/components/fritzbox/translations/bg.json b/homeassistant/components/fritzbox/translations/bg.json index ec678d2d76c0e..ac7e60b9afcea 100644 --- a/homeassistant/components/fritzbox/translations/bg.json +++ b/homeassistant/components/fritzbox/translations/bg.json @@ -1,11 +1,29 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "flow_title": "{name}", "step": { + "confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name}?" + }, "reauth_confirm": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } } } } diff --git a/homeassistant/components/fritzbox/translations/ca.json b/homeassistant/components/fritzbox/translations/ca.json index 9324f91ef186b..efd81ddff840c 100644 --- a/homeassistant/components/fritzbox/translations/ca.json +++ b/homeassistant/components/fritzbox/translations/ca.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, - "flow_title": "AVM FRITZ!SmartHome: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/de.json b/homeassistant/components/fritzbox/translations/de.json index 1626372248243..7da8e616cfc9e 100644 --- a/homeassistant/components/fritzbox/translations/de.json +++ b/homeassistant/components/fritzbox/translations/de.json @@ -4,13 +4,13 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", - "not_supported": "Verbunden mit AVM FRITZ! Box, kann jedoch keine Smart Home-Ger\u00e4te steuern.", + "not_supported": "Verbunden mit AVM FRITZ!Box, kann jedoch keine Smart Home-Ger\u00e4te steuern.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { - "invalid_auth": "Ung\u00fcltige Zugangsdaten" + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, - "flow_title": "AVM FRITZ! Box: {name}", + "flow_title": "AVM FRITZ!Box: {name}", "step": { "confirm": { "data": { @@ -32,7 +32,7 @@ "password": "Passwort", "username": "Benutzername" }, - "description": "Gib deine AVM FRITZ! Box-Informationen ein." + "description": "Gib deine AVM FRITZ!Box-Informationen ein." } } } diff --git a/homeassistant/components/fritzbox/translations/es-419.json b/homeassistant/components/fritzbox/translations/es-419.json index f66a3dc0dd0a7..da929ac998370 100644 --- a/homeassistant/components/fritzbox/translations/es-419.json +++ b/homeassistant/components/fritzbox/translations/es-419.json @@ -14,6 +14,9 @@ }, "description": "\u00bfDesea configurar {name}?" }, + "reauth_confirm": { + "description": "Actualice su informaci\u00f3n de inicio de sesi\u00f3n para {name}." + }, "user": { "data": { "host": "Host o direcci\u00f3n IP", diff --git a/homeassistant/components/fritzbox/translations/et.json b/homeassistant/components/fritzbox/translations/et.json index 96c77903f974f..849dc7fadee3d 100644 --- a/homeassistant/components/fritzbox/translations/et.json +++ b/homeassistant/components/fritzbox/translations/et.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Tuvastamise viga" }, - "flow_title": "AVM FRITZ! SmartHome: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/fr.json b/homeassistant/components/fritzbox/translations/fr.json index e6302964988cf..8a75221ea8823 100644 --- a/homeassistant/components/fritzbox/translations/fr.json +++ b/homeassistant/components/fritzbox/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cette AVM FRITZ!Box est d\u00e9j\u00e0 configur\u00e9e.", - "already_in_progress": "Une configuration d'AVM FRITZ!Box est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "not_supported": "Connect\u00e9 \u00e0 AVM FRITZ! Box mais impossible de contr\u00f4ler les appareils Smart Home.", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Authentification invalide" }, - "flow_title": "AVM FRITZ!Box : {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "username": "Nom d'utilisateur" }, diff --git a/homeassistant/components/fritzbox/translations/he.json b/homeassistant/components/fritzbox/translations/he.json index 035cb07a170a8..ec9248b5ea63d 100644 --- a/homeassistant/components/fritzbox/translations/he.json +++ b/homeassistant/components/fritzbox/translations/he.json @@ -1,5 +1,15 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -7,8 +17,15 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } }, + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, "user": { "data": { + "host": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } diff --git a/homeassistant/components/fritzbox/translations/hu.json b/homeassistant/components/fritzbox/translations/hu.json index 44b68d5f540ca..c1cf8154aeaca 100644 --- a/homeassistant/components/fritzbox/translations/hu.json +++ b/homeassistant/components/fritzbox/translations/hu.json @@ -2,34 +2,37 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" + "not_supported": "Csatlakoztatva az AVM FRITZ! Boxhoz, de nem tudja vez\u00e9relni az intelligens otthoni eszk\u00f6z\u00f6ket.", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, - "flow_title": "AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" }, "reauth_confirm": { "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Friss\u00edtse {name} bejelentkez\u00e9si adatait." }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Adja meg az AVM FRITZ! Box adatait." } } } diff --git a/homeassistant/components/fritzbox/translations/id.json b/homeassistant/components/fritzbox/translations/id.json index 8dbd1f7153404..f9c4f09b4aeee 100644 --- a/homeassistant/components/fritzbox/translations/id.json +++ b/homeassistant/components/fritzbox/translations/id.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/it.json b/homeassistant/components/fritzbox/translations/it.json index da01b34ad02cd..68cbe08b1b98e 100644 --- a/homeassistant/components/fritzbox/translations/it.json +++ b/homeassistant/components/fritzbox/translations/it.json @@ -4,13 +4,13 @@ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "no_devices_found": "Nessun dispositivo trovato sulla rete", - "not_supported": "Collegato a AVM FRITZ!Box ma non \u00e8 in grado di controllare i dispositivi Smart Home.", + "not_supported": "Collegato a AVM FRITZ!Box, ma non \u00e8 in grado di controllare i dispositivi Smart Home.", "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida" }, - "flow_title": "AVM FRITZ! SmartHome: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/ja.json b/homeassistant/components/fritzbox/translations/ja.json new file mode 100644 index 0000000000000..c246ea5fb0db5 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/ja.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "not_supported": "AVM FRITZ!Box\u306b\u63a5\u7d9a\u3057\u307e\u3057\u305f\u304c\u3001Smart Home devices\u3092\u5236\u5fa1\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "{name} \u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u66f4\u65b0\u3057\u307e\u3059\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "AVM FRITZ!Box\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/nl.json b/homeassistant/components/fritzbox/translations/nl.json index c1f9c83e39508..b1be4c8214fca 100644 --- a/homeassistant/components/fritzbox/translations/nl.json +++ b/homeassistant/components/fritzbox/translations/nl.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Ongeldige authenticatie" }, - "flow_title": "AVM FRITZ!SmartHome: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/no.json b/homeassistant/components/fritzbox/translations/no.json index 9a64c5b850676..5ec0cc1acdc3b 100644 --- a/homeassistant/components/fritzbox/translations/no.json +++ b/homeassistant/components/fritzbox/translations/no.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Ugyldig godkjenning" }, - "flow_title": "AVM FRITZ! SmartHome: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/pl.json b/homeassistant/components/fritzbox/translations/pl.json index dc05e43183225..d9832ee51a434 100644 --- a/homeassistant/components/fritzbox/translations/pl.json +++ b/homeassistant/components/fritzbox/translations/pl.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Niepoprawne uwierzytelnienie" }, - "flow_title": "AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/ru.json b/homeassistant/components/fritzbox/translations/ru.json index 5ca8304249718..51e9aedc63269 100644 --- a/homeassistant/components/fritzbox/translations/ru.json +++ b/homeassistant/components/fritzbox/translations/ru.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, - "flow_title": "AVM FRITZ!SmartHome: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox/translations/tr.json b/homeassistant/components/fritzbox/translations/tr.json index 746fe594e1990..300ca1a096a09 100644 --- a/homeassistant/components/fritzbox/translations/tr.json +++ b/homeassistant/components/fritzbox/translations/tr.json @@ -3,11 +3,14 @@ "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "not_supported": "AVM FRITZ!Box'a ba\u011fl\u0131 ancak Ak\u0131ll\u0131 Ev cihazlar\u0131n\u0131 kontrol edemiyor.", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -18,17 +21,18 @@ }, "reauth_confirm": { "data": { - "password": "\u015eifre", + "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" }, "description": "Giri\u015f bilgilerinizi {name} i\u00e7in g\u00fcncelleyin." }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "AVM FRITZ!Box bilgilerinizi giriniz." } } } diff --git a/homeassistant/components/fritzbox/translations/zh-Hant.json b/homeassistant/components/fritzbox/translations/zh-Hant.json index d27d78b896212..b90b87aaee7e4 100644 --- a/homeassistant/components/fritzbox/translations/zh-Hant.json +++ b/homeassistant/components/fritzbox/translations/zh-Hant.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" }, - "flow_title": "AVM FRITZ!SmartHome\uff1a{name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index 4c36ee3ddfb07..edf463a84eb58 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -2,6 +2,7 @@ import logging from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from fritzconnection.core.logger import fritzlogger from requests.exceptions import ConnectionError as RequestsConnectionError from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -19,6 +20,13 @@ _LOGGER = logging.getLogger(__name__) +level = _LOGGER.getEffectiveLevel() +_LOGGER.info( + "Setting logging level of fritzconnection: %s", logging.getLevelName(level) +) +fritzlogger.set_level(level) +fritzlogger.enable() + async def async_setup_entry(hass, config_entry): """Set up the fritzbox_callmonitor platforms.""" diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index af0612d763257..0db40e2098fa6 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -8,7 +8,7 @@ from homeassistant.util import Throttle -from .const import REGEX_NUMBER, UNKOWN_NAME +from .const import REGEX_NUMBER, UNKNOWN_NAME _LOGGER = logging.getLogger(__name__) @@ -61,13 +61,13 @@ def get_name(self, number): """Return a name for a given phone number.""" number = re.sub(REGEX_NUMBER, "", str(number)) if self.number_dict is None: - return UNKOWN_NAME + return UNKNOWN_NAME if number in self.number_dict: return self.number_dict[number] if not self.prefixes: - return UNKOWN_NAME + return UNKNOWN_NAME for prefix in self.prefixes: with suppress(KeyError): diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py index a71f14401b3cf..435bfdef87ea1 100644 --- a/homeassistant/components/fritzbox_callmonitor/const.py +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -1,4 +1,5 @@ """Constants for the AVM Fritz!Box call monitor integration.""" +from homeassistant.const import Platform STATE_RINGING = "ringing" STATE_DIALING = "dialing" @@ -19,7 +20,7 @@ FRITZ_ATTR_SERIAL_NUMBER = "NewSerialNumber" FRITZ_SERVICE_DEVICE_INFO = "DeviceInfo" -UNKOWN_NAME = "unknown" +UNKNOWN_NAME = "unknown" SERIAL_NUMBER = "serial_number" REGEX_NUMBER = r"[^\d\+]" @@ -36,6 +37,6 @@ DOMAIN = "fritzbox_callmonitor" MANUFACTURER = "AVM" -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] UNDO_UPDATE_LISTENER = "undo_update_listener" FRITZBOX_PHONEBOOK = "fritzbox_phonebook" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 531fa13e23292..91bd73a6efddd 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -3,7 +3,7 @@ "name": "AVM FRITZ!Box Call Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", - "requirements": ["fritzconnection==1.4.2"], + "requirements": ["fritzconnection==1.7.2"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index a325c0ca71dcc..171e6966b28b5 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -19,6 +19,7 @@ EVENT_HOMEASSISTANT_STOP, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from .const import ( ATTR_PREFIXES, @@ -42,7 +43,7 @@ STATE_IDLE, STATE_RINGING, STATE_TALKING, - UNKOWN_NAME, + UNKNOWN_NAME, ) _LOGGER = logging.getLogger(__name__) @@ -158,7 +159,7 @@ def should_poll(self): return self._fritzbox_phonebook is not None @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -175,15 +176,15 @@ def extra_state_attributes(self): return self._attributes @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "name": self._fritzbox_phonebook.fph.modelname, - "identifiers": {(DOMAIN, self._unique_id)}, - "manufacturer": MANUFACTURER, - "model": self._fritzbox_phonebook.fph.modelname, - "sw_version": self._fritzbox_phonebook.fph.fc.system_version, - } + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer=MANUFACTURER, + model=self._fritzbox_phonebook.fph.modelname, + name=self._fritzbox_phonebook.fph.modelname, + sw_version=self._fritzbox_phonebook.fph.fc.system_version, + ) @property def unique_id(self): @@ -193,7 +194,7 @@ def unique_id(self): def number_to_name(self, number): """Return a name for a given phone number.""" if self._fritzbox_phonebook is None: - return UNKOWN_NAME + return UNKNOWN_NAME return self._fritzbox_phonebook.get_name(number) def update(self): diff --git a/homeassistant/components/fritzbox_callmonitor/translations/ca.json b/homeassistant/components/fritzbox_callmonitor/translations/ca.json index 808b642f4ff68..e98a1c345b8f2 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/ca.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/ca.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, - "flow_title": "Sensor de trucades d'AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/de.json b/homeassistant/components/fritzbox_callmonitor/translations/de.json index a26f301a9bdb5..b48ec34a0308b 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/de.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/de.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung" }, - "flow_title": "AVM FRITZ! Box-Anrufmonitor: {name}", + "flow_title": "AVM FRITZ!Box-Anrufmonitor: {name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/es-419.json b/homeassistant/components/fritzbox_callmonitor/translations/es-419.json new file mode 100644 index 0000000000000..8b10d7d7c2a94 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "insufficient_permissions": "El usuario no tiene permisos suficientes para acceder a la configuraci\u00f3n de AVM FRITZ! Box y sus agendas telef\u00f3nicas." + }, + "flow_title": "{name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Directorio telef\u00f3nico" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Los prefijos est\u00e1n mal formados, verifique su formato." + }, + "step": { + "init": { + "data": { + "prefixes": "Prefijos (lista separada por comas)" + }, + "title": "Configurar prefijos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/et.json b/homeassistant/components/fritzbox_callmonitor/translations/et.json index 7770f31ae0e07..4f9205efc8906 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/et.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/et.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Vigane autentimine" }, - "flow_title": "AVM FRITZ! K\u00f5nekontroll: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/fr.json b/homeassistant/components/fritzbox_callmonitor/translations/fr.json index cde9023273c68..2d1cadb8a48a8 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/fr.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/fr.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "insufficient_permissions": "L'utilisateur ne dispose pas des autorisations n\u00e9cessaires pour acc\u00e9der aux param\u00e8tres d'AVM FRITZ! Box et \u00e0 ses r\u00e9pertoires.", - "no_devices_found": "Aucun appreil trouv\u00e9 sur le r\u00e9seau " + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" }, "error": { "invalid_auth": "Authentification invalide" }, - "flow_title": "Moniteur d'appels AVM FRITZ! Box: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { @@ -17,10 +17,10 @@ }, "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "username": "Nom d'utilisateur " + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/fritzbox_callmonitor/translations/he.json b/homeassistant/components/fritzbox_callmonitor/translations/he.json new file mode 100644 index 0000000000000..7951a71054c9b --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/he.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{name}", + "step": { + "phonebook": { + "data": { + "phonebook": "\u05e1\u05e4\u05e8 \u05d8\u05dc\u05e4\u05d5\u05e0\u05d9\u05dd" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/hu.json b/homeassistant/components/fritzbox_callmonitor/translations/hu.json index 8c2c34775e5e8..86b4c637ca065 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/hu.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/hu.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "insufficient_permissions": "A felhaszn\u00e1l\u00f3nak nincs elegend\u0151 enged\u00e9lye az AVM FRITZ! Box be\u00e1ll\u00edt\u00e1sainak \u00e9s telefonk\u00f6nyveinek el\u00e9r\u00e9s\u00e9hez.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, + "flow_title": "{name}", "step": { "phonebook": { "data": { @@ -15,12 +17,25 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } + }, + "options": { + "error": { + "malformed_prefixes": "Az el\u0151tagok hib\u00e1san vannak form\u00e1zva, ellen\u0151rizze a form\u00e1tumukat." + }, + "step": { + "init": { + "data": { + "prefixes": "El\u0151tagok (vessz\u0151vel elv\u00e1lasztott lista)" + }, + "title": "Konfigur\u00e1lja az el\u0151tagokat" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/id.json b/homeassistant/components/fritzbox_callmonitor/translations/id.json index 43bb4a16b4726..1325edd720cfe 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/id.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/id.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "Pantau panggilan AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/it.json b/homeassistant/components/fritzbox_callmonitor/translations/it.json index 5696bf86fd1f7..154f57aee5259 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/it.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/it.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Autenticazione non valida" }, - "flow_title": "Monitoraggio chiamate FRITZ! Box AVM: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/ja.json b/homeassistant/components/fritzbox_callmonitor/translations/ja.json new file mode 100644 index 0000000000000..1c3294403ec5b --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "insufficient_permissions": "\u30e6\u30fc\u30b6\u30fc\u306b\u3001AVM FRITZ!Box\u306e\u8a2d\u5b9a\u3068\u96fb\u8a71\u5e33\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u6a29\u9650\u304c\u4e0d\u8db3\u3057\u3066\u3044\u307e\u3059\u3002", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "phonebook": { + "data": { + "phonebook": "\u96fb\u8a71\u5e33" + } + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u306e\u5f62\u5f0f\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002\u30d5\u30a9\u30fc\u30de\u30c3\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "data": { + "prefixes": "\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9(\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8)" + }, + "title": "\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/nl.json b/homeassistant/components/fritzbox_callmonitor/translations/nl.json index bc706861313f9..20d884535b8d8 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/nl.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/nl.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Ongeldige authenticatie" }, - "flow_title": "AVM FRITZ!Box oproepmonitor: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/no.json b/homeassistant/components/fritzbox_callmonitor/translations/no.json index 12883b0140d51..98b1c31c80432 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/no.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/no.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Ugyldig godkjenning" }, - "flow_title": "AVM FRITZ! Box monitor: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/pl.json b/homeassistant/components/fritzbox_callmonitor/translations/pl.json index fa0317f5c9d91..29ef1d6b3b69e 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/pl.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/pl.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Niepoprawne uwierzytelnienie" }, - "flow_title": "Monitor po\u0142\u0105cze\u0144 AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/ru.json b/homeassistant/components/fritzbox_callmonitor/translations/ru.json index 38448ac8c5982..608d6e1ee64f8 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/ru.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/ru.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, - "flow_title": "AVM FRITZ!Box call monitor: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/tr.json b/homeassistant/components/fritzbox_callmonitor/translations/tr.json index 76799f24af824..f96f3cc2d6efb 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/tr.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/tr.json @@ -17,8 +17,8 @@ }, "user": { "data": { - "host": "Ana Bilgisayar", - "password": "\u015eifre", + "host": "Sunucu", + "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/fritzbox_callmonitor/translations/zh-Hant.json b/homeassistant/components/fritzbox_callmonitor/translations/zh-Hant.json index 3e7da079b18fb..1f8127c192880 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/zh-Hant.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/zh-Hant.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" }, - "flow_title": "AVM FRITZ!Box \u901a\u8a71\u76e3\u63a7\u5668\uff1a{name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/fritzbox_netmonitor/__init__.py b/homeassistant/components/fritzbox_netmonitor/__init__.py deleted file mode 100644 index 8bea1da4a44bc..0000000000000 --- a/homeassistant/components/fritzbox_netmonitor/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The fritzbox_netmonitor component.""" diff --git a/homeassistant/components/fritzbox_netmonitor/manifest.json b/homeassistant/components/fritzbox_netmonitor/manifest.json deleted file mode 100644 index b52872fc04489..0000000000000 --- a/homeassistant/components/fritzbox_netmonitor/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "fritzbox_netmonitor", - "name": "AVM FRITZ!Box Net Monitor", - "documentation": "https://www.home-assistant.io/integrations/fritzbox_netmonitor", - "requirements": ["fritzconnection==1.4.2"], - "codeowners": [], - "iot_class": "local_polling" -} diff --git a/homeassistant/components/fritzbox_netmonitor/sensor.py b/homeassistant/components/fritzbox_netmonitor/sensor.py deleted file mode 100644 index 3c37de7664c66..0000000000000 --- a/homeassistant/components/fritzbox_netmonitor/sensor.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Support for monitoring an AVM Fritz!Box router.""" -from datetime import timedelta -import logging - -from fritzconnection.core.exceptions import FritzConnectionException -from fritzconnection.lib.fritzstatus import FritzStatus -from requests.exceptions import RequestException -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "fritz_netmonitor" -DEFAULT_HOST = "169.254.1.1" # This IP is valid for all FRITZ!Box routers. - -ATTR_BYTES_RECEIVED = "bytes_received" -ATTR_BYTES_SENT = "bytes_sent" -ATTR_TRANSMISSION_RATE_UP = "transmission_rate_up" -ATTR_TRANSMISSION_RATE_DOWN = "transmission_rate_down" -ATTR_EXTERNAL_IP = "external_ip" -ATTR_IS_CONNECTED = "is_connected" -ATTR_IS_LINKED = "is_linked" -ATTR_MAX_BYTE_RATE_DOWN = "max_byte_rate_down" -ATTR_MAX_BYTE_RATE_UP = "max_byte_rate_up" -ATTR_UPTIME = "uptime" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) - -STATE_ONLINE = "online" -STATE_OFFLINE = "offline" - -ICON = "mdi:web" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the FRITZ!Box monitor sensors.""" - name = config[CONF_NAME] - host = config[CONF_HOST] - - try: - fstatus = FritzStatus(address=host) - except (ValueError, TypeError, FritzConnectionException): - fstatus = None - - if fstatus is None: - _LOGGER.error("Failed to establish connection to FRITZ!Box: %s", host) - return 1 - _LOGGER.info("Successfully connected to FRITZ!Box") - - add_entities([FritzboxMonitorSensor(name, fstatus)], True) - - -class FritzboxMonitorSensor(SensorEntity): - """Implementation of a fritzbox monitor sensor.""" - - def __init__(self, name, fstatus): - """Initialize the sensor.""" - self._name = name - self._fstatus = fstatus - self._state = STATE_UNAVAILABLE - self._is_linked = self._is_connected = None - self._external_ip = self._uptime = None - self._bytes_sent = self._bytes_received = None - self._transmission_rate_up = None - self._transmission_rate_down = None - self._max_byte_rate_up = self._max_byte_rate_down = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name.rstrip() - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - # Don't return attributes if FritzBox is unreachable - if self._state == STATE_UNAVAILABLE: - return {} - return { - ATTR_IS_LINKED: self._is_linked, - ATTR_IS_CONNECTED: self._is_connected, - ATTR_EXTERNAL_IP: self._external_ip, - ATTR_UPTIME: self._uptime, - ATTR_BYTES_SENT: self._bytes_sent, - ATTR_BYTES_RECEIVED: self._bytes_received, - ATTR_TRANSMISSION_RATE_UP: self._transmission_rate_up, - ATTR_TRANSMISSION_RATE_DOWN: self._transmission_rate_down, - ATTR_MAX_BYTE_RATE_UP: self._max_byte_rate_up, - ATTR_MAX_BYTE_RATE_DOWN: self._max_byte_rate_down, - } - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Retrieve information from the FritzBox.""" - try: - self._is_linked = self._fstatus.is_linked - self._is_connected = self._fstatus.is_connected - self._external_ip = self._fstatus.external_ip - self._uptime = self._fstatus.uptime - self._bytes_sent = self._fstatus.bytes_sent - self._bytes_received = self._fstatus.bytes_received - transmission_rate = self._fstatus.transmission_rate - self._transmission_rate_up = transmission_rate[0] - self._transmission_rate_down = transmission_rate[1] - self._max_byte_rate_up = self._fstatus.max_byte_rate[0] - self._max_byte_rate_down = self._fstatus.max_byte_rate[1] - self._state = STATE_ONLINE if self._is_connected else STATE_OFFLINE - except RequestException as err: - self._state = STATE_UNAVAILABLE - _LOGGER.warning("Could not reach FRITZ!Box: %s", err) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 2b4d968fecaa9..23e595b71ce7c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -1 +1,211 @@ -"""The Fronius component.""" +"""The Fronius integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +from typing import Final, TypeVar + +from pyfronius import Fronius, FroniusError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo +from .coordinator import ( + FroniusCoordinatorBase, + FroniusInverterUpdateCoordinator, + FroniusLoggerUpdateCoordinator, + FroniusMeterUpdateCoordinator, + FroniusOhmpilotUpdateCoordinator, + FroniusPowerFlowUpdateCoordinator, + FroniusStorageUpdateCoordinator, +) + +_LOGGER: Final = logging.getLogger(__name__) +PLATFORMS: Final = [Platform.SENSOR] + +FroniusCoordinatorType = TypeVar("FroniusCoordinatorType", bound=FroniusCoordinatorBase) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up fronius from a config entry.""" + host = entry.data[CONF_HOST] + fronius = Fronius(async_get_clientsession(hass), host) + solar_net = FroniusSolarNet(hass, entry, fronius) + await solar_net.init_devices() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = solar_net + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + solar_net = hass.data[DOMAIN].pop(entry.entry_id) + while solar_net.cleanup_callbacks: + solar_net.cleanup_callbacks.pop()() + + return unload_ok + + +class FroniusSolarNet: + """The FroniusSolarNet class routes received values to sensor entities.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, fronius: Fronius + ) -> None: + """Initialize FroniusSolarNet class.""" + self.hass = hass + self.cleanup_callbacks: list[Callable[[], None]] = [] + self.config_entry = entry + self.coordinator_lock = asyncio.Lock() + self.fronius = fronius + self.host: str = entry.data[CONF_HOST] + # entry.unique_id is either logger uid or first inverter uid if no logger available + # prepended by "solar_net_" to have individual device for whole system (power_flow) + self.solar_net_device_id = f"solar_net_{entry.unique_id}" + self.system_device_info: DeviceInfo | None = None + + self.inverter_coordinators: list[FroniusInverterUpdateCoordinator] = [] + self.logger_coordinator: FroniusLoggerUpdateCoordinator | None = None + self.meter_coordinator: FroniusMeterUpdateCoordinator | None = None + self.ohmpilot_coordinator: FroniusOhmpilotUpdateCoordinator | None = None + self.power_flow_coordinator: FroniusPowerFlowUpdateCoordinator | None = None + self.storage_coordinator: FroniusStorageUpdateCoordinator | None = None + + async def init_devices(self) -> None: + """Initialize DataUpdateCoordinators for SolarNet devices.""" + if self.config_entry.data["is_logger"]: + self.logger_coordinator = FroniusLoggerUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_logger_{self.host}", + ) + await self.logger_coordinator.async_config_entry_first_refresh() + + # _create_solar_net_device uses data from self.logger_coordinator when available + self.system_device_info = await self._create_solar_net_device() + + _inverter_infos = await self._get_inverter_infos() + for inverter_info in _inverter_infos: + coordinator = FroniusInverterUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_inverter_{inverter_info.solar_net_id}_{self.host}", + inverter_info=inverter_info, + ) + await coordinator.async_config_entry_first_refresh() + self.inverter_coordinators.append(coordinator) + + self.meter_coordinator = await self._init_optional_coordinator( + FroniusMeterUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_meters_{self.host}", + ) + ) + + self.ohmpilot_coordinator = await self._init_optional_coordinator( + FroniusOhmpilotUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_ohmpilot_{self.host}", + ) + ) + + self.power_flow_coordinator = await self._init_optional_coordinator( + FroniusPowerFlowUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_power_flow_{self.host}", + ) + ) + + self.storage_coordinator = await self._init_optional_coordinator( + FroniusStorageUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_storages_{self.host}", + ) + ) + + async def _create_solar_net_device(self) -> DeviceInfo: + """Create a device for the Fronius SolarNet system.""" + solar_net_device: DeviceInfo = DeviceInfo( + configuration_url=self.fronius.url, + identifiers={(DOMAIN, self.solar_net_device_id)}, + manufacturer="Fronius", + name="SolarNet", + ) + if self.logger_coordinator: + _logger_info = self.logger_coordinator.data[SOLAR_NET_ID_SYSTEM] + solar_net_device[ATTR_MODEL] = _logger_info["product_type"]["value"] + solar_net_device[ATTR_SW_VERSION] = _logger_info["software_version"][ + "value" + ] + + device_registry = await dr.async_get_registry(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + **solar_net_device, + ) + return solar_net_device + + async def _get_inverter_infos(self) -> list[FroniusDeviceInfo]: + """Get information about the inverters in the SolarNet system.""" + try: + _inverter_info = await self.fronius.inverter_info() + except FroniusError as err: + raise ConfigEntryNotReady from err + + inverter_infos: list[FroniusDeviceInfo] = [] + for inverter in _inverter_info["inverters"]: + solar_net_id = inverter["device_id"]["value"] + unique_id = inverter["unique_id"]["value"] + device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=inverter["device_type"].get("manufacturer", "Fronius"), + model=inverter["device_type"].get( + "model", inverter["device_type"]["value"] + ), + name=inverter.get("custom_name", {}).get("value"), + via_device=(DOMAIN, self.solar_net_device_id), + ) + inverter_infos.append( + FroniusDeviceInfo( + device_info=device_info, + solar_net_id=solar_net_id, + unique_id=unique_id, + ) + ) + return inverter_infos + + @staticmethod + async def _init_optional_coordinator( + coordinator: FroniusCoordinatorType, + ) -> FroniusCoordinatorType | None: + """Initialize an update coordinator and return it if devices are found.""" + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + # ConfigEntryNotReady raised form FroniusError / KeyError in + # DataUpdateCoordinator if request not supported by the Fronius device + return None + # if no device for the request is installed an empty dict is returned + if not coordinator.data: + return None + return coordinator diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py new file mode 100644 index 0000000000000..ea590767359e6 --- /dev/null +++ b/homeassistant/components/fronius/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Fronius integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Final + +from pyfronius import Fronius, FroniusError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.const import CONF_HOST, CONF_RESOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, FroniusConfigEntryData + +_LOGGER: Final = logging.getLogger(__name__) + +DHCP_REQUEST_DELAY: Final = 60 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +def create_title(info: FroniusConfigEntryData) -> str: + """Return the title of the config flow.""" + return ( + f"SolarNet {'Datalogger' if info['is_logger'] else 'Inverter'}" + f" at {info['host']}" + ) + + +async def validate_host( + hass: HomeAssistant, host: str +) -> tuple[str, FroniusConfigEntryData]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + fronius = Fronius(async_get_clientsession(hass), host) + + try: + datalogger_info: dict[str, Any] + datalogger_info = await fronius.current_logger_info() + except FroniusError as err: + _LOGGER.debug(err) + else: + logger_uid: str = datalogger_info["unique_identifier"]["value"] + return logger_uid, FroniusConfigEntryData( + host=host, + is_logger=True, + ) + # Gen24 devices don't provide GetLoggerInfo + try: + inverter_info = await fronius.inverter_info() + first_inverter = next(inverter for inverter in inverter_info["inverters"]) + except FroniusError as err: + _LOGGER.debug(err) + raise CannotConnect from err + except StopIteration as err: + raise CannotConnect("No supported Fronius SolarNet device found.") from err + first_inverter_uid: str = first_inverter["unique_id"]["value"] + return first_inverter_uid, FroniusConfigEntryData( + host=host, + is_logger=False, + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fronius.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self.info: FroniusConfigEntryData + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured(updates=dict(info)) + + return self.async_create_entry(title=create_title(info), data=info) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, conf: dict) -> FlowResult: + """Import a configuration from config.yaml.""" + return await self.async_step_user(user_input={CONF_HOST: conf[CONF_RESOURCE]}) + + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle a flow initiated by the DHCP client.""" + for entry in self._async_current_entries(include_ignore=False): + if entry.data[CONF_HOST].lstrip("http://").rstrip("/").lower() in ( + discovery_info.ip, + discovery_info.hostname, + ): + return self.async_abort(reason="already_configured") + # Symo Datalogger devices need up to 1 minute at boot from DHCP request + # to respond to API requests (connection refused until then) + await asyncio.sleep(DHCP_REQUEST_DELAY) + try: + unique_id, self.info = await validate_host(self.hass, discovery_info.ip) + except CannotConnect: + return self.async_abort(reason="invalid_host") + + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured(updates=dict(self.info)) + + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Attempt to confim.""" + title = create_title(self.info) + if user_input is not None: + return self.async_create_entry(title=title, data=self.info) + + self._set_confirm_only() + self.context.update({"title_placeholders": {"device": title}}) + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + "device": title, + }, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py new file mode 100644 index 0000000000000..de3e0cc956351 --- /dev/null +++ b/homeassistant/components/fronius/const.py @@ -0,0 +1,25 @@ +"""Constants for the Fronius integration.""" +from typing import Final, NamedTuple, TypedDict + +from homeassistant.helpers.entity import DeviceInfo + +DOMAIN: Final = "fronius" + +SolarNetId = str +SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" +SOLAR_NET_ID_SYSTEM: SolarNetId = "system" + + +class FroniusConfigEntryData(TypedDict): + """ConfigEntry for the Fronius integration.""" + + host: str + is_logger: bool + + +class FroniusDeviceInfo(NamedTuple): + """Information about a Fronius inverter device.""" + + device_info: DeviceInfo + solar_net_id: SolarNetId + unique_id: str diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py new file mode 100644 index 0000000000000..7e0e1a59731cb --- /dev/null +++ b/homeassistant/components/fronius/coordinator.py @@ -0,0 +1,214 @@ +"""DataUpdateCoordinators for the Fronius integration.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import timedelta +from typing import TYPE_CHECKING, Any, Dict, TypeVar + +from pyfronius import BadStatusError, FroniusError + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + SOLAR_NET_ID_POWER_FLOW, + SOLAR_NET_ID_SYSTEM, + FroniusDeviceInfo, + SolarNetId, +) +from .sensor import ( + INVERTER_ENTITY_DESCRIPTIONS, + LOGGER_ENTITY_DESCRIPTIONS, + METER_ENTITY_DESCRIPTIONS, + OHMPILOT_ENTITY_DESCRIPTIONS, + POWER_FLOW_ENTITY_DESCRIPTIONS, + STORAGE_ENTITY_DESCRIPTIONS, +) + +if TYPE_CHECKING: + from . import FroniusSolarNet + from .sensor import _FroniusSensorEntity + + FroniusEntityType = TypeVar("FroniusEntityType", bound=_FroniusSensorEntity) + + +class FroniusCoordinatorBase( + ABC, DataUpdateCoordinator[Dict[SolarNetId, Dict[str, Any]]] +): + """Query Fronius endpoint and keep track of seen conditions.""" + + default_interval: timedelta + error_interval: timedelta + valid_descriptions: list[SensorEntityDescription] + + MAX_FAILED_UPDATES = 3 + + def __init__(self, *args: Any, solar_net: FroniusSolarNet, **kwargs: Any) -> None: + """Set up the FroniusCoordinatorBase class.""" + self._failed_update_count = 0 + self.solar_net = solar_net + # unregistered_keys are used to create entities in platform module + self.unregistered_keys: dict[SolarNetId, set[str]] = {} + super().__init__(*args, update_interval=self.default_interval, **kwargs) + + @abstractmethod + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + + async def _async_update_data(self) -> dict[SolarNetId, Any]: + """Fetch the latest data from the source.""" + async with self.solar_net.coordinator_lock: + try: + data = await self._update_method() + except FroniusError as err: + self._failed_update_count += 1 + if self._failed_update_count == self.MAX_FAILED_UPDATES: + self.update_interval = self.error_interval + raise UpdateFailed(err) from err + + if self._failed_update_count != 0: + self._failed_update_count = 0 + self.update_interval = self.default_interval + + for solar_net_id in data: + if solar_net_id not in self.unregistered_keys: + # id seen for the first time + self.unregistered_keys[solar_net_id] = { + desc.key for desc in self.valid_descriptions + } + return data + + @callback + def add_entities_for_seen_keys( + self, + async_add_entities: AddEntitiesCallback, + entity_constructor: type[FroniusEntityType], + ) -> None: + """ + Add entities for received keys and registers listener for future seen keys. + + Called from a platforms `async_setup_entry`. + """ + + @callback + def _add_entities_for_unregistered_keys() -> None: + """Add entities for keys seen for the first time.""" + new_entities: list = [] + for solar_net_id, device_data in self.data.items(): + for key in self.unregistered_keys[solar_net_id].intersection( + device_data + ): + if device_data[key]["value"] is None: + continue + new_entities.append(entity_constructor(self, key, solar_net_id)) + self.unregistered_keys[solar_net_id].remove(key) + if new_entities: + async_add_entities(new_entities) + + _add_entities_for_unregistered_keys() + self.solar_net.cleanup_callbacks.append( + self.async_add_listener(_add_entities_for_unregistered_keys) + ) + + +class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius device inverter endpoint and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS + + SILENT_RETRIES = 3 + + def __init__( + self, *args: Any, inverter_info: FroniusDeviceInfo, **kwargs: Any + ) -> None: + """Set up a Fronius inverter device scope coordinator.""" + super().__init__(*args, **kwargs) + self.inverter_info = inverter_info + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + # almost 1% of `current_inverter_data` requests on Symo devices result in + # `BadStatusError Code: 8 - LNRequestTimeout` due to flaky internal + # communication between the logger and the inverter. + for silent_retry in range(self.SILENT_RETRIES): + try: + data = await self.solar_net.fronius.current_inverter_data( + self.inverter_info.solar_net_id + ) + except BadStatusError as err: + if silent_retry == (self.SILENT_RETRIES - 1): + raise err + continue + break + # wrap a single devices data in a dict with solar_net_id key for + # FroniusCoordinatorBase _async_update_data and add_entities_for_seen_keys + return {self.inverter_info.solar_net_id: data} + + +class FroniusLoggerUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius logger info endpoint and keep track of seen conditions.""" + + default_interval = timedelta(hours=1) + error_interval = timedelta(hours=1) + valid_descriptions = LOGGER_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_logger_info() + return {SOLAR_NET_ID_SYSTEM: data} + + +class FroniusMeterUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius system meter endpoint and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = METER_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_system_meter_data() + return data["meters"] # type: ignore[no-any-return] + + +class FroniusOhmpilotUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius Ohmpilots and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_system_ohmpilot_data() + return data["ohmpilots"] # type: ignore[no-any-return] + + +class FroniusPowerFlowUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius power flow endpoint and keep track of seen conditions.""" + + default_interval = timedelta(seconds=10) + error_interval = timedelta(minutes=3) + valid_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_power_flow() + return {SOLAR_NET_ID_POWER_FLOW: data} + + +class FroniusStorageUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius system storage endpoint and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = STORAGE_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_system_storage_data() + return data["storages"] # type: ignore[no-any-return] diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 4f48bc1aecc05..d2f3fc2e0f351 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -1,8 +1,15 @@ { + "codeowners": ["@nielstron", "@farmio"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "0003AC*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/fronius", "domain": "fronius", + "iot_class": "local_polling", "name": "Fronius", - "documentation": "https://www.home-assistant.io/integrations/fronius", - "requirements": ["pyfronius==0.4.6"], - "codeowners": ["@nielstron"], - "iot_class": "local_polling" + "quality_scale": "platinum", + "requirements": ["pyfronius==0.7.1"] } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index a908f2605f8ca..67d86c1cc4886 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,301 +1,876 @@ """Support for Fronius devices.""" from __future__ import annotations -import copy -from datetime import timedelta import logging +from typing import TYPE_CHECKING, Any, Final -from pyfronius import Fronius import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_DEVICE, CONF_MONITORED_CONDITIONS, CONF_RESOURCE, - CONF_SCAN_INTERVAL, - CONF_SENSOR_TYPE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, + TEMP_CELSIUS, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval - -_LOGGER = logging.getLogger(__name__) - -CONF_SCOPE = "scope" - -TYPE_INVERTER = "inverter" -TYPE_STORAGE = "storage" -TYPE_METER = "meter" -TYPE_POWER_FLOW = "power_flow" -SCOPE_DEVICE = "device" -SCOPE_SYSTEM = "system" - -DEFAULT_SCOPE = SCOPE_DEVICE -DEFAULT_DEVICE = 0 -DEFAULT_INVERTER = 1 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) - -SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] -SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] - - -def _device_id_validator(config): - """Ensure that inverters have default id 1 and other devices 0.""" - config = copy.deepcopy(config) - for cond in config[CONF_MONITORED_CONDITIONS]: - if CONF_DEVICE not in cond: - if cond[CONF_SENSOR_TYPE] == TYPE_INVERTER: - cond[CONF_DEVICE] = DEFAULT_INVERTER - else: - cond[CONF_DEVICE] = DEFAULT_DEVICE - return config - - -PLATFORM_SCHEMA = vol.Schema( - vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_RESOURCE): cv.url, - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_SENSOR_TYPE): vol.In(SENSOR_TYPES), - vol.Optional(CONF_SCOPE, default=DEFAULT_SCOPE): vol.In( - SCOPE_TYPES - ), - vol.Optional(CONF_DEVICE): cv.positive_int, - } - ], - ), - } - ), - _device_id_validator, +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import FroniusSolarNet + from .coordinator import ( + FroniusCoordinatorBase, + FroniusInverterUpdateCoordinator, + FroniusLoggerUpdateCoordinator, + FroniusMeterUpdateCoordinator, + FroniusOhmpilotUpdateCoordinator, + FroniusPowerFlowUpdateCoordinator, + FroniusStorageUpdateCoordinator, ) -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up of Fronius platform.""" - session = async_get_clientsession(hass) - fronius = Fronius(session, config[CONF_RESOURCE]) - - scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - adapters = [] - # Creates all adapters for monitored conditions - for condition in config[CONF_MONITORED_CONDITIONS]: - - device = condition[CONF_DEVICE] - sensor_type = condition[CONF_SENSOR_TYPE] - scope = condition[CONF_SCOPE] - name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" - if sensor_type == TYPE_INVERTER: - if scope == SCOPE_SYSTEM: - adapter_cls = FroniusInverterSystem - else: - adapter_cls = FroniusInverterDevice - elif sensor_type == TYPE_METER: - if scope == SCOPE_SYSTEM: - adapter_cls = FroniusMeterSystem - else: - adapter_cls = FroniusMeterDevice - elif sensor_type == TYPE_POWER_FLOW: - adapter_cls = FroniusPowerFlow - else: - adapter_cls = FroniusStorage - - adapters.append(adapter_cls(fronius, name, device, async_add_entities)) - - # Creates a lamdba that fetches an update when called - def adapter_data_fetcher(data_adapter): - async def fetch_data(*_): - await data_adapter.async_update() - - return fetch_data - - # Set up the fetching in a fixed interval for each adapter - for adapter in adapters: - fetch = adapter_data_fetcher(adapter) - # fetch data once at set-up - await fetch() - async_track_time_interval(hass, fetch, scan_interval) - - -class FroniusAdapter: - """The Fronius sensor fetching component.""" - - def __init__(self, bridge, name, device, add_entities): - """Initialize the sensor.""" - self.bridge = bridge - self._name = name - self._device = device - self._fetched = {} - self._available = True - - self.sensors = set() - self._registered_sensors = set() - self._add_entities = add_entities - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def data(self): - """Return the state attributes.""" - return self._fetched - - @property - def available(self): - """Whether the fronius device is active.""" - return self._available - - async def async_update(self): - """Retrieve and update latest state.""" - try: - values = await self._update() - except ConnectionError: - # fronius devices are often powered by self-produced solar energy - # and henced turned off at night. - # Therefore we will not print multiple errors when connection fails - if self._available: - self._available = False - _LOGGER.error("Failed to update: connection error") - return - except ValueError: - _LOGGER.error( - "Failed to update: invalid response returned." - "Maybe the configured device is not supported" - ) - return - - self._available = True # reset connection failure - - attributes = self._fetched - # Copy data of current fronius device - for key, entry in values.items(): - # If the data is directly a sensor - if "value" in entry: - attributes[key] = entry - self._fetched = attributes - - # Add discovered value fields as sensors - # because some fields are only sent temporarily - new_sensors = [] - for key in attributes: - if key not in self.sensors: - self.sensors.add(key) - _LOGGER.info("Discovered %s, adding as sensor", key) - new_sensors.append(FroniusTemplateSensor(self, key)) - self._add_entities(new_sensors, True) - - # Schedule an update for all included sensors - for sensor in self._registered_sensors: - sensor.async_schedule_update_ha_state(True) - - async def _update(self) -> dict: - """Return values of interest.""" - - async def register(self, sensor): - """Register child sensor for update subscriptions.""" - self._registered_sensors.add(sensor) - - -class FroniusInverterSystem(FroniusAdapter): - """Adapter for the fronius inverter with system scope.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_system_inverter_data() - - -class FroniusInverterDevice(FroniusAdapter): - """Adapter for the fronius inverter with device scope.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_inverter_data(self._device) - -class FroniusStorage(FroniusAdapter): - """Adapter for the fronius battery storage.""" +_LOGGER: Final = logging.getLogger(__name__) - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_storage_data(self._device) +ELECTRIC_CHARGE_AMPERE_HOURS: Final = "Ah" +ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh" +POWER_VOLT_AMPERE_REACTIVE: Final = "var" +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_MONITORED_CONDITIONS): object, + } + ), +) -class FroniusMeterSystem(FroniusAdapter): - """Adapter for the fronius meter with system scope.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_system_meter_data() - - -class FroniusMeterDevice(FroniusAdapter): - """Adapter for the fronius meter with device scope.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_meter_data(self._device) - - -class FroniusPowerFlow(FroniusAdapter): - """Adapter for the fronius power flow.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_power_flow() - - -class FroniusTemplateSensor(SensorEntity): - """Sensor for the single values (e.g. pv power, ac power).""" - - def __init__(self, parent: FroniusAdapter, name): - """Initialize a singular value sensor.""" - self._name = name - self.parent = parent - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}" - - @property - def state(self): - """Return the current state.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def should_poll(self): - """Device should not be polled, returns False.""" - return False - @property - def available(self): - """Whether the fronius device is active.""" - return self.parent.available +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: None = None, +) -> None: + """Import Fronius configuration from yaml.""" + _LOGGER.warning( + "Loading Fronius via platform setup is deprecated. Please remove it from your yaml configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - async def async_update(self): - """Update the internal state.""" - state = self.parent.data.get(self._name) - self._state = state.get("value") - self._unit = state.get("unit") - async def async_added_to_hass(self): - """Register at parent component for updates.""" - await self.parent.register(self) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Fronius sensor entities based on a config entry.""" + solar_net: FroniusSolarNet = hass.data[DOMAIN][config_entry.entry_id] + for inverter_coordinator in solar_net.inverter_coordinators: + inverter_coordinator.add_entities_for_seen_keys( + async_add_entities, InverterSensor + ) + if solar_net.logger_coordinator is not None: + solar_net.logger_coordinator.add_entities_for_seen_keys( + async_add_entities, LoggerSensor + ) + if solar_net.meter_coordinator is not None: + solar_net.meter_coordinator.add_entities_for_seen_keys( + async_add_entities, MeterSensor + ) + if solar_net.ohmpilot_coordinator is not None: + solar_net.ohmpilot_coordinator.add_entities_for_seen_keys( + async_add_entities, OhmpilotSensor + ) + if solar_net.power_flow_coordinator is not None: + solar_net.power_flow_coordinator.add_entities_for_seen_keys( + async_add_entities, PowerFlowSensor + ) + if solar_net.storage_coordinator is not None: + solar_net.storage_coordinator.add_entities_for_seen_keys( + async_add_entities, StorageSensor + ) + + +INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_day", + name="Energy day", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_year", + name="Energy year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_total", + name="Energy total", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="frequency_ac", + name="Frequency AC", + native_unit_of_measurement=FREQUENCY_HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_ac", + name="AC current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current_dc", + name="DC current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="current_dc_2", + name="DC current 2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="power_ac", + name="AC power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac", + name="AC voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_dc", + name="DC voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_2", + name="DC voltage 2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + # device status entities + SensorEntityDescription( + key="inverter_state", + name="Inverter state", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="error_code", + name="Error code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="status_code", + name="Status code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="led_state", + name="LED state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="led_color", + name="LED color", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +] + +LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="co2_factor", + name="CO₂ factor", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:molecule-co2", + ), + SensorEntityDescription( + key="cash_factor", + name="Grid export tariff", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:cash-plus", + ), + SensorEntityDescription( + key="delivery_factor", + name="Grid import tariff", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:cash-minus", + ), +] + +METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="current_ac_phase_1", + name="Current AC phase 1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_ac_phase_2", + name="Current AC phase 2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_ac_phase_3", + name="Current AC phase 3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_reactive_ac_consumed", + name="Energy reactive AC consumed", + native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + icon="mdi:lightning-bolt-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_reactive_ac_produced", + name="Energy reactive AC produced", + native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + icon="mdi:lightning-bolt-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_real_ac_minus", + name="Energy real AC minus", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_real_ac_plus", + name="Energy real AC plus", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_real_consumed", + name="Energy real consumed", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_real_produced", + name="Energy real produced", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="frequency_phase_average", + name="Frequency phase average", + native_unit_of_measurement=FREQUENCY_HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="meter_location", + name="Meter location", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_apparent_phase_1", + name="Power apparent phase 1", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_apparent_phase_2", + name="Power apparent phase 2", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_apparent_phase_3", + name="Power apparent phase 3", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_apparent", + name="Power apparent", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor_phase_1", + name="Power factor phase 1", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor_phase_2", + name="Power factor phase 2", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor_phase_3", + name="Power factor phase 3", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor", + name="Power factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_reactive_phase_1", + name="Power reactive phase 1", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_reactive_phase_2", + name="Power reactive phase 2", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_reactive_phase_3", + name="Power reactive phase 3", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_reactive", + name="Power reactive", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real_phase_1", + name="Power real phase 1", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real_phase_2", + name="Power real phase 2", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real_phase_3", + name="Power real phase 3", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real", + name="Power real", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_1", + name="Voltage AC phase 1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_2", + name="Voltage AC phase 2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_3", + name="Voltage AC phase 3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_12", + name="Voltage AC phase 1-2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_23", + name="Voltage AC phase 2-3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_31", + name="Voltage AC phase 3-1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +] + +OHMPILOT_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_real_ac_consumed", + name="Energy consumed", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_real_ac", + name="Power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temperature_channel_1", + name="Temperature Channel 1", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="error_code", + name="Error code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="state_code", + name="State code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="state_message", + name="State message", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + +POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_day", + name="Energy day", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_year", + name="Energy year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_total", + name="Energy total", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="meter_mode", + name="Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_battery", + name="Power battery", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_grid", + name="Power grid", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_load", + name="Power load", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_photovoltaics", + name="Power photovoltaics", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="relative_autonomy", + name="Relative autonomy", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:home-circle-outline", + ), + SensorEntityDescription( + key="relative_self_consumption", + name="Relative self consumption", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:solar-power", + ), +] + +STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="capacity_maximum", + name="Capacity maximum", + native_unit_of_measurement=ELECTRIC_CHARGE_AMPERE_HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="capacity_designed", + name="Capacity designed", + native_unit_of_measurement=ELECTRIC_CHARGE_AMPERE_HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="current_dc", + name="Current DC", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc", + name="Voltage DC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_maximum_cell", + name="Voltage DC maximum cell", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_dc_minimum_cell", + name="Voltage DC minimum cell", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="state_of_charge", + name="State of charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temperature_cell", + name="Temperature cell", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + +class _FroniusSensorEntity(CoordinatorEntity, SensorEntity): + """Defines a Fronius coordinator entity.""" + + coordinator: FroniusCoordinatorBase + entity_descriptions: list[SensorEntityDescription] + _entity_id_prefix: str + + def __init__( + self, + coordinator: FroniusCoordinatorBase, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + super().__init__(coordinator) + self.entity_description = next( + desc for desc in self.entity_descriptions if desc.key == key + ) + # default entity_id added 2021.12 + # used for migration from non-unique_id entities of previous integration implementation + # when removed after migration period `_entity_id_prefix` will also no longer be needed + self.entity_id = f"{SENSOR_DOMAIN}.{key}_{DOMAIN}_{self._entity_id_prefix}_{coordinator.solar_net.host}" + self.solar_net_id = solar_net_id + self._attr_native_value = self._get_entity_value() + + def _device_data(self) -> dict[str, Any]: + """Extract information for SolarNet device from coordinator data.""" + return self.coordinator.data[self.solar_net_id] + + def _get_entity_value(self) -> Any: + """Extract entity value from coordinator. Raises KeyError if not included in latest update.""" + new_value = self.coordinator.data[self.solar_net_id][ + self.entity_description.key + ]["value"] + return round(new_value, 4) if isinstance(new_value, float) else new_value + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._attr_native_value = self._get_entity_value() + except KeyError: + return + self.async_write_ha_state() + + +class InverterSensor(_FroniusSensorEntity): + """Defines a Fronius inverter device sensor entity.""" + + entity_descriptions = INVERTER_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusInverterUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius inverter sensor.""" + self._entity_id_prefix = f"inverter_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + # device_info created in __init__ from a `GetInverterInfo` request + self._attr_device_info = coordinator.inverter_info.device_info + self._attr_unique_id = f"{coordinator.inverter_info.unique_id}-{key}" + + +class LoggerSensor(_FroniusSensorEntity): + """Defines a Fronius logger device sensor entity.""" + + entity_descriptions = LOGGER_ENTITY_DESCRIPTIONS + _entity_id_prefix = "logger_info_0" + + def __init__( + self, + coordinator: FroniusLoggerUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + super().__init__(coordinator, key, solar_net_id) + logger_data = self._device_data() + # Logger device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_native_unit_of_measurement = logger_data[key].get("unit") + self._attr_unique_id = f'{logger_data["unique_identifier"]["value"]}-{key}' + + +class MeterSensor(_FroniusSensorEntity): + """Defines a Fronius meter device sensor entity.""" + + entity_descriptions = METER_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusMeterUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + self._entity_id_prefix = f"meter_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + meter_data = self._device_data() + # S0 meters connected directly to inverters respond "n.a." as serial number + # `model` contains the inverter id: "S0 Meter at inverter 1" + if (meter_uid := meter_data["serial"]["value"]) == "n.a.": + meter_uid = ( + f"{coordinator.solar_net.solar_net_device_id}:" + f'{meter_data["model"]["value"]}' + ) - def __hash__(self): - """Hash sensor by hashing its name.""" - return hash(self.name) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, meter_uid)}, + manufacturer=meter_data["manufacturer"]["value"], + model=meter_data["model"]["value"], + name=meter_data["model"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) + self._attr_unique_id = f"{meter_uid}-{key}" + + +class OhmpilotSensor(_FroniusSensorEntity): + """Defines a Fronius Ohmpilot sensor entity.""" + + entity_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusOhmpilotUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + self._entity_id_prefix = f"ohmpilot_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + device_data = self._device_data() + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_data["serial"]["value"])}, + manufacturer=device_data["manufacturer"]["value"], + model=f"{device_data['model']['value']} {device_data['hardware']['value']}", + name=device_data["model"]["value"], + sw_version=device_data["software"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) + self._attr_unique_id = f'{device_data["serial"]["value"]}-{key}' + + +class PowerFlowSensor(_FroniusSensorEntity): + """Defines a Fronius power flow sensor entity.""" + + entity_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + _entity_id_prefix = "power_flow_0" + + def __init__( + self, + coordinator: FroniusPowerFlowUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius power flow sensor.""" + super().__init__(coordinator, key, solar_net_id) + # SolarNet device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_unique_id = ( + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{key}" + ) + + +class StorageSensor(_FroniusSensorEntity): + """Defines a Fronius storage device sensor entity.""" + + entity_descriptions = STORAGE_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusStorageUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius storage sensor.""" + self._entity_id_prefix = f"storage_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + storage_data = self._device_data() + + self._attr_unique_id = f'{storage_data["serial"]["value"]}-{key}' + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, storage_data["serial"]["value"])}, + manufacturer=storage_data["manufacturer"]["value"], + model=storage_data["model"]["value"], + name=storage_data["model"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json new file mode 100644 index 0000000000000..711e363eebaae --- /dev/null +++ b/homeassistant/components/fronius/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{device}", + "step": { + "user": { + "title": "Fronius SolarNet", + "description": "Configure the IP address or local hostname of your Fronius device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm_discovery": { + "description": "Do you want to add {device} to Home Assistant?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + } +} diff --git a/homeassistant/components/fronius/translations/bg.json b/homeassistant/components/fronius/translations/bg.json new file mode 100644 index 0000000000000..4c388f2ad58fd --- /dev/null +++ b/homeassistant/components/fronius/translations/bg.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u0435 {device} \u043a\u044a\u043c Home Assistant?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/ca.json b/homeassistant/components/fronius/translations/ca.json new file mode 100644 index 0000000000000..1c543140627a1 --- /dev/null +++ b/homeassistant/components/fronius/translations/ca.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "invalid_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Vols afegir {device} a Home Assistant?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Configura l'adre\u00e7a IP o el nom d'amfitri\u00f3 local del teu dispositiu Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/de.json b/homeassistant/components/fronius/translations/de.json new file mode 100644 index 0000000000000..eed094491a5fc --- /dev/null +++ b/homeassistant/components/fronius/translations/de.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "M\u00f6chtest du {device} zu Home Assistant hinzuf\u00fcgen?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Konfiguriere die IP-Adresse oder den lokalen Hostnamen deines Fronius-Ger\u00e4ts.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/en.json b/homeassistant/components/fronius/translations/en.json new file mode 100644 index 0000000000000..244949935e95d --- /dev/null +++ b/homeassistant/components/fronius/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "invalid_host": "Invalid hostname or IP address" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Do you want to add {device} to Home Assistant?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Configure the IP address or local hostname of your Fronius device.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/et.json b/homeassistant/components/fronius/translations/et.json new file mode 100644 index 0000000000000..5fd7f47154525 --- /dev/null +++ b/homeassistant/components/fronius/translations/et.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "invalid_host": "Vigane hostinimi v\u00f5i IP aadress" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Kas soovid lisada {device} Home Assistanti?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Seadista Froniuse seadme IP-aadress v\u00f5i kohalik hostinimi.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/fi.json b/homeassistant/components/fronius/translations/fi.json new file mode 100644 index 0000000000000..c8dd1e939ce64 --- /dev/null +++ b/homeassistant/components/fronius/translations/fi.json @@ -0,0 +1,10 @@ +{ + "config": { + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Haluatko lis\u00e4t\u00e4 laitteen {device} Home Assistantiin?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/fr.json b/homeassistant/components/fronius/translations/fr.json new file mode 100644 index 0000000000000..e7ea85962bdc5 --- /dev/null +++ b/homeassistant/components/fronius/translations/fr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Voulez-vous ajouter {device} \u00e0 Home Assistant\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurez l'adresse IP ou le nom d'h\u00f4te local de votre appareil Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/he.json b/homeassistant/components/fronius/translations/he.json new file mode 100644 index 0000000000000..76241415389fb --- /dev/null +++ b/homeassistant/components/fronius/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{device}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/hu.json b/homeassistant/components/fronius/translations/hu.json new file mode 100644 index 0000000000000..4307a00af0e9b --- /dev/null +++ b/homeassistant/components/fronius/translations/hu.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Szeretn\u00e9 hozz\u00e1adni Home Assistanthoz: {device}?" + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Adja meg a Fronius eszk\u00f6z\u00e9nek helyi c\u00edm\u00e9t (IP vagy hosztn\u00e9v).", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/id.json b/homeassistant/components/fronius/translations/id.json new file mode 100644 index 0000000000000..49fc8cac1047d --- /dev/null +++ b/homeassistant/components/fronius/translations/id.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "invalid_host": "Nama host atau alamat IP tidak valid" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Ingin menambahkan {device} to Home Assistant?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Konfigurasikan alamat IP atau nama host lokal perangkat Fronius Anda", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/it.json b/homeassistant/components/fronius/translations/it.json new file mode 100644 index 0000000000000..82d851bdee80a --- /dev/null +++ b/homeassistant/components/fronius/translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "invalid_host": "Nome host o indirizzo IP non valido" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Vuoi aggiungere {device} a Home Assistant?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Configura l'indirizzo IP o il nome host locale del proprio dispositivo Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/ja.json b/homeassistant/components/fronius/translations/ja.json new file mode 100644 index 0000000000000..07c7b0c3ed47c --- /dev/null +++ b/homeassistant/components/fronius/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "{device} \u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Fronius device\u306eIP\u30a2\u30c9\u30ec\u30b9\u307e\u305f\u306f\u30ed\u30fc\u30ab\u30eb\u30db\u30b9\u30c8\u540d\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/lt.json b/homeassistant/components/fronius/translations/lt.json new file mode 100644 index 0000000000000..826d1cae85fd8 --- /dev/null +++ b/homeassistant/components/fronius/translations/lt.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u012erenginys paruo\u0161tas naudojimui" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/nl.json b/homeassistant/components/fronius/translations/nl.json new file mode 100644 index 0000000000000..d71683ad9224a --- /dev/null +++ b/homeassistant/components/fronius/translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "invalid_host": "Ongeldige hostnaam of IP-adres" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Wilt u {device} toevoegen aan Home Assistant?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Configureer het IP-adres of de lokale hostnaam van uw Fronius-apparaat.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/no.json b/homeassistant/components/fronius/translations/no.json new file mode 100644 index 0000000000000..d934909a2fa50 --- /dev/null +++ b/homeassistant/components/fronius/translations/no.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "invalid_host": "Ugyldig vertsnavn eller IP-adresse" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Vil du legge til {device} i Home Assistant?" + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Konfigurer IP-adressen eller det lokale vertsnavnet til Fronius-enheten.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/pl.json b/homeassistant/components/fronius/translations/pl.json new file mode 100644 index 0000000000000..a0f7104d77510 --- /dev/null +++ b/homeassistant/components/fronius/translations/pl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "invalid_host": "Nieprawid\u0142owa nazwa hosta lub adres IP" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Czy chcesz doda\u0107 {device} do Home Assistanta?" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "description": "Skonfiguruj adres IP lub lokaln\u0105 nazw\u0119 hosta urz\u0105dzenia Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/ru.json b/homeassistant/components/fronius/translations/ru.json new file mode 100644 index 0000000000000..473834c87970b --- /dev/null +++ b/homeassistant/components/fronius/translations/ru.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "invalid_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c {device} \u0432 Home Assistant?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 \u0438\u043b\u0438 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0412\u0430\u0448\u0435\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/sl.json b/homeassistant/components/fronius/translations/sl.json new file mode 100644 index 0000000000000..0eec93b817d33 --- /dev/null +++ b/homeassistant/components/fronius/translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/tr.json b/homeassistant/components/fronius/translations/tr.json new file mode 100644 index 0000000000000..90cdbb00deb97 --- /dev/null +++ b/homeassistant/components/fronius/translations/tr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Home Assistant'a {device} eklemek istiyor musunuz?" + }, + "user": { + "data": { + "host": "Sunucu" + }, + "description": "Fronius cihaz\u0131n\u0131z\u0131n IP adresini veya yerel ana bilgisayar ad\u0131n\u0131 yap\u0131land\u0131r\u0131n.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/zh-Hant.json b/homeassistant/components/fronius/translations/zh-Hant.json new file mode 100644 index 0000000000000..f7e507f183599 --- /dev/null +++ b/homeassistant/components/fronius/translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "\u662f\u5426\u8981\u5c07 {device} \u65b0\u589e\u81f3 Home Assistant\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8a2d\u5b9a Fronius \u88dd\u7f6e IP \u4f4d\u5740\u6216\u672c\u5730\u4e3b\u6a5f\u540d\u3002", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ed339b9dc8b31..005384b6beb79 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,13 +1,14 @@ """Handle the frontend for Home Assistant.""" from __future__ import annotations +from collections.abc import Iterator from functools import lru_cache import json import logging import mimetypes import os import pathlib -from typing import Any +from typing import Any, TypedDict, cast from aiohttp import hdrs, web, web_urldispatcher import jinja2 @@ -16,18 +17,18 @@ from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import CONF_MODE, CONF_NAME, EVENT_THEMES_UPDATED -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv from homeassistant.helpers.translation import async_get_translations +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration, bind_hass from .storage import async_setup_frontend_storage -# mypy: allow-untyped-defs, no-check-untyped-defs - # Fix mimetypes for borked Windows machines # https://github.com/home-assistant/frontend/issues/3336 mimetypes.add_type("text/css", ".css") @@ -36,6 +37,9 @@ DOMAIN = "frontend" CONF_THEMES = "themes" +CONF_THEMES_MODES = "modes" +CONF_THEMES_LIGHT = "light" +CONF_THEMES_DARK = "dark" CONF_EXTRA_HTML_URL = "extra_html_url" CONF_EXTRA_HTML_URL_ES5 = "extra_html_url_es5" CONF_EXTRA_MODULE_URL = "extra_module_url" @@ -66,14 +70,39 @@ _LOGGER = logging.getLogger(__name__) +EXTENDED_THEME_SCHEMA = vol.Schema( + { + # Theme variables that apply to all modes + cv.string: cv.string, + # Mode specific theme variables + vol.Optional(CONF_THEMES_MODES): vol.Schema( + { + vol.Optional(CONF_THEMES_LIGHT): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}), + } + ), + } +) + +THEME_SCHEMA = vol.Schema( + { + cv.string: ( + vol.Any( + # Legacy theme scheme + {cv.string: cv.string}, + # New extended schema with mode support + EXTENDED_THEME_SCHEMA, + ) + ) + } +) + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Optional(CONF_FRONTEND_REPO): cv.isdir, - vol.Optional(CONF_THEMES): vol.Schema( - {cv.string: {cv.string: cv.string}} - ), + vol.Optional(CONF_THEMES): THEME_SCHEMA, vol.Optional(CONF_EXTRA_MODULE_URL): vol.All( cv.ensure_list, [cv.string] ), @@ -163,15 +192,15 @@ class UrlManager: on hass.data """ - def __init__(self, urls): + def __init__(self, urls: list[str]) -> None: """Init the url manager.""" self.urls = frozenset(urls) - def add(self, url): + def add(self, url: str) -> None: """Add a url to the set.""" self.urls = frozenset([*self.urls, url]) - def remove(self, url): + def remove(self, url: str) -> None: """Remove a url from the set.""" self.urls = self.urls - {url} @@ -180,7 +209,7 @@ class Panel: """Abstract class for panels.""" # Name of the webcomponent - component_name: str | None = None + component_name: str # Icon to show in the sidebar sidebar_icon: str | None = None @@ -199,13 +228,13 @@ class Panel: def __init__( self, - component_name, - sidebar_title, - sidebar_icon, - frontend_url_path, - config, - require_admin, - ): + component_name: str, + sidebar_title: str | None, + sidebar_icon: str | None, + frontend_url_path: str | None, + config: dict[str, Any] | None, + require_admin: bool, + ) -> None: """Initialize a built-in panel.""" self.component_name = component_name self.sidebar_title = sidebar_title @@ -215,7 +244,7 @@ def __init__( self.require_admin = require_admin @callback - def to_response(self): + def to_response(self) -> PanelRespons: """Panel as dictionary.""" return { "component_name": self.component_name, @@ -230,16 +259,16 @@ def to_response(self): @bind_hass @callback def async_register_built_in_panel( - hass, - component_name, - sidebar_title=None, - sidebar_icon=None, - frontend_url_path=None, - config=None, - require_admin=False, + hass: HomeAssistant, + component_name: str, + sidebar_title: str | None = None, + sidebar_icon: str | None = None, + frontend_url_path: str | None = None, + config: dict[str, Any] | None = None, + require_admin: bool = False, *, - update=False, -): + update: bool = False, +) -> None: """Register a built-in panel.""" panel = Panel( component_name, @@ -262,7 +291,7 @@ def async_register_built_in_panel( @bind_hass @callback -def async_remove_panel(hass, frontend_url_path): +def async_remove_panel(hass: HomeAssistant, frontend_url_path: str) -> None: """Remove a built-in panel.""" panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) @@ -272,18 +301,18 @@ def async_remove_panel(hass, frontend_url_path): hass.bus.async_fire(EVENT_PANELS_UPDATED) -def add_extra_js_url(hass, url, es5=False): +def add_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: """Register extra js or module url to load.""" key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL hass.data[key].add(url) -def add_manifest_json_key(key, val): +def add_manifest_json_key(key: str, val: Any) -> None: """Add a keyval to the manifest.json.""" MANIFEST_JSON.update_key(key, val) -def _frontend_root(dev_repo_path): +def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: """Return root path to the frontend files.""" if dev_repo_path is not None: return pathlib.Path(dev_repo_path) / "hass_frontend" @@ -291,17 +320,17 @@ def _frontend_root(dev_repo_path): # pylint: disable=import-outside-toplevel import hass_frontend - return hass_frontend.where() + return cast(pathlib.Path, hass_frontend.where()) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) hass.components.websocket_api.async_register_command(websocket_get_panels) hass.components.websocket_api.async_register_command(websocket_get_themes) hass.components.websocket_api.async_register_command(websocket_get_translations) hass.components.websocket_api.async_register_command(websocket_get_version) - hass.http.register_view(ManifestJSONView) + hass.http.register_view(ManifestJSONView()) conf = config.get(DOMAIN, {}) @@ -368,7 +397,9 @@ async def async_setup(hass, config): return True -async def _async_setup_themes(hass, themes): +async def _async_setup_themes( + hass: HomeAssistant, themes: dict[str, Any] | None +) -> None: """Set up themes data and services.""" hass.data[DATA_THEMES] = themes or {} @@ -389,7 +420,7 @@ async def _async_setup_themes(hass, themes): hass.data[DATA_DEFAULT_DARK_THEME] = dark_theme_name @callback - def update_theme_and_fire_event(): + def update_theme_and_fire_event() -> None: """Update theme_color in manifest.""" name = hass.data[DATA_DEFAULT_THEME] themes = hass.data[DATA_THEMES] @@ -406,7 +437,7 @@ def update_theme_and_fire_event(): hass.bus.async_fire(EVENT_THEMES_UPDATED) @callback - def set_theme(call): + def set_theme(call: ServiceCall) -> None: """Set backend-preferred theme.""" name = call.data[CONF_NAME] mode = call.data.get("mode", "light") @@ -438,7 +469,7 @@ def set_theme(call): ) update_theme_and_fire_event() - async def reload_themes(_): + async def reload_themes(_: ServiceCall) -> None: """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config[DOMAIN].get(CONF_THEMES, {}) @@ -472,19 +503,19 @@ async def reload_themes(_): @callback @lru_cache(maxsize=1) -def _async_render_index_cached(template, **kwargs): +def _async_render_index_cached(template: jinja2.Template, **kwargs: Any) -> str: return template.render(**kwargs) class IndexView(web_urldispatcher.AbstractResource): """Serve the frontend.""" - def __init__(self, repo_path, hass): + def __init__(self, repo_path: str | None, hass: HomeAssistant) -> None: """Initialize the frontend view.""" super().__init__(name="frontend:index") self.repo_path = repo_path self.hass = hass - self._template_cache = None + self._template_cache: jinja2.Template | None = None @property def canonical(self) -> str: @@ -492,7 +523,7 @@ def canonical(self) -> str: return "/" @property - def _route(self): + def _route(self) -> web_urldispatcher.ResourceRoute: """Return the index route.""" return web_urldispatcher.ResourceRoute("GET", self.get, self) @@ -524,7 +555,7 @@ def add_prefix(self, prefix: str) -> None: Required for subapplications support. """ - def get_info(self): + def get_info(self) -> dict[str, list[str]]: # type: ignore[override] """Return a dict with additional info useful for introspection.""" return {"panels": list(self.hass.data[DATA_PANELS])} @@ -534,11 +565,12 @@ def freeze(self) -> None: def raw_match(self, path: str) -> bool: """Perform a raw match against path.""" - def get_template(self): + def get_template(self) -> jinja2.Template: """Get template.""" - tpl = self._template_cache - if tpl is None: - with open(str(_frontend_root(self.repo_path) / "index.html")) as file: + if (tpl := self._template_cache) is None: + with (_frontend_root(self.repo_path) / "index.html").open( + encoding="utf8" + ) as file: tpl = jinja2.Template(file.read()) # Cache template if not running from repository @@ -572,7 +604,7 @@ def __len__(self) -> int: """Return length of resource.""" return 1 - def __iter__(self): + def __iter__(self) -> Iterator[web_urldispatcher.ResourceRoute]: """Iterate over routes.""" return iter([self._route]) @@ -585,7 +617,7 @@ class ManifestJSONView(HomeAssistantView): name = "manifestjson" @callback - def get(self, request): # pylint: disable=no-self-use + def get(self, request: web.Request) -> web.Response: # pylint: disable=no-self-use """Return the manifest.json.""" return web.Response( text=MANIFEST_JSON.json, content_type="application/manifest+json" @@ -594,7 +626,9 @@ def get(self, request): # pylint: disable=no-self-use @callback @websocket_api.websocket_command({"type": "get_panels"}) -def websocket_get_panels(hass, connection, msg): +def websocket_get_panels( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: """Handle get panels command.""" user_is_admin = connection.user.is_admin panels = { @@ -608,7 +642,9 @@ def websocket_get_panels(hass, connection, msg): @callback @websocket_api.websocket_command({"type": "frontend/get_themes"}) -def websocket_get_themes(hass, connection, msg): +def websocket_get_themes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: """Handle get themes command.""" if hass.config.safe_mode: connection.send_message( @@ -649,7 +685,9 @@ def websocket_get_themes(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_get_translations(hass, connection, msg): +async def websocket_get_translations( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: """Handle get translations command.""" resources = await async_get_translations( hass, @@ -665,7 +703,9 @@ async def websocket_get_translations(hass, connection, msg): @websocket_api.websocket_command({"type": "frontend/get_version"}) @websocket_api.async_response -async def websocket_get_version(hass, connection, msg): +async def websocket_get_version( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: """Handle get version command.""" integration = await async_get_integration(hass, "frontend") @@ -679,3 +719,14 @@ async def websocket_get_version(hass, connection, msg): connection.send_error(msg["id"], "unknown_version", "Version not found") else: connection.send_result(msg["id"], {"version": frontend}) + + +class PanelRespons(TypedDict): + """Represent the panel response type.""" + + component_name: str + icon: str | None + title: str | None + config: dict[str, Any] | None + url_path: str | None + require_admin: bool diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 65927e6da0cfd..6ae1709e418d9 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210504.0" + "home-assistant-frontend==20211220.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 0f4948f4bf994..478202a4a0af0 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -13,9 +13,8 @@ set_theme: text: mode: name: Mode - description: The mode the theme is for, either 'dark' or 'light'. + description: The mode the theme is for. default: "light" - example: "dark" selector: select: options: diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index b37945b5e072f..d7aabbec9b8a5 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,34 +1,40 @@ """API for persistent storage for the frontend.""" +from __future__ import annotations + +from collections.abc import Callable from functools import wraps +from typing import Any import voluptuous as vol from homeassistant.components import websocket_api - -# mypy: allow-untyped-calls, allow-untyped-defs +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store DATA_STORAGE = "frontend_storage" STORAGE_VERSION_USER_DATA = 1 -async def async_setup_frontend_storage(hass): +async def async_setup_frontend_storage(hass: HomeAssistant) -> None: """Set up frontend storage.""" hass.data[DATA_STORAGE] = ({}, {}) hass.components.websocket_api.async_register_command(websocket_set_user_data) hass.components.websocket_api.async_register_command(websocket_get_user_data) -def with_store(orig_func): +def with_store(orig_func: Callable) -> Callable: """Decorate function to provide data.""" @wraps(orig_func) - async def with_store_func(hass, connection, msg): + async def with_store_func( + hass: HomeAssistant, connection: ActiveConnection, msg: dict + ) -> None: """Provide user specific data and store to function.""" stores, data = hass.data[DATA_STORAGE] user_id = connection.user.id - store = stores.get(user_id) - if store is None: + if (store := stores.get(user_id)) is None: store = stores[user_id] = hass.helpers.storage.Store( STORAGE_VERSION_USER_DATA, f"frontend.user_data_{connection.user.id}" ) @@ -50,7 +56,13 @@ async def with_store_func(hass, connection, msg): ) @websocket_api.async_response @with_store -async def websocket_set_user_data(hass, connection, msg, store, data): +async def websocket_set_user_data( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + store: Store, + data: dict[str, Any], +) -> None: """Handle set global data command. Async friendly. @@ -65,7 +77,13 @@ async def websocket_set_user_data(hass, connection, msg, store, data): ) @websocket_api.async_response @with_store -async def websocket_get_user_data(hass, connection, msg, store, data): +async def websocket_get_user_data( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + store: Store, + data: dict[str, Any], +) -> None: """Handle get global data command. Async friendly. diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py new file mode 100644 index 0000000000000..01dc6b17545b7 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -0,0 +1,61 @@ +"""The Garages Amsterdam integration.""" +from datetime import timedelta +import logging + +import async_timeout +from garages_amsterdam import GaragesAmsterdam + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Garages Amsterdam from a config entry.""" + await get_coordinator(hass) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Garages Amsterdam config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if len(hass.config_entries.async_entries(DOMAIN)) == 1: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def get_coordinator( + hass: HomeAssistant, +) -> DataUpdateCoordinator: + """Get the data update coordinator.""" + if DOMAIN in hass.data: + return hass.data[DOMAIN] + + async def async_get_garages(): + async with async_timeout.timeout(10): + return { + garage.garage_name: garage + for garage in await GaragesAmsterdam( + session=aiohttp_client.async_get_clientsession(hass) + ).all_garages() + } + + coordinator = DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_garages, + update_interval=timedelta(minutes=10), + ) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN] = coordinator + return coordinator diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py new file mode 100644 index 0000000000000..5b4441466243d --- /dev/null +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -0,0 +1,61 @@ +"""Binary Sensor platform for Garages Amsterdam.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import get_coordinator +from .const import ATTRIBUTION + +BINARY_SENSORS = { + "state", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator = await get_coordinator(hass) + + async_add_entities( + GaragesamsterdamBinarySensor( + coordinator, config_entry.data["garage_name"], info_type + ) + for info_type in BINARY_SENSORS + ) + + +class GaragesamsterdamBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Binary Sensor representing garages amsterdam data.""" + + _attr_attribution = ATTRIBUTION + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__( + self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str + ) -> None: + """Initialize garages amsterdam binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{garage_name}-{info_type}" + self._garage_name = garage_name + self._info_type = info_type + self._attr_name = garage_name + + @property + def is_on(self) -> bool: + """If the binary sensor is currently on or off.""" + return ( + getattr(self.coordinator.data[self._garage_name], self._info_type) != "ok" + ) diff --git a/homeassistant/components/garages_amsterdam/config_flow.py b/homeassistant/components/garages_amsterdam/config_flow.py new file mode 100644 index 0000000000000..c8a61f9a1608d --- /dev/null +++ b/homeassistant/components/garages_amsterdam/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Garages Amsterdam integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientResponseError +from garages_amsterdam import GaragesAmsterdam +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Garages Amsterdam.""" + + VERSION = 1 + _options: list[str] | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._options is None: + self._options = [] + try: + api_data = await GaragesAmsterdam( + session=aiohttp_client.async_get_clientsession(self.hass) + ).all_garages() + except ClientResponseError: + _LOGGER.error("Unexpected response from server") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + for garage in sorted(api_data, key=lambda garage: garage.garage_name): + self._options.append(garage.garage_name) + + if user_input is not None: + await self.async_set_unique_id(user_input["garage_name"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input["garage_name"], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required("garage_name"): vol.In(self._options)} + ), + ) diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py new file mode 100644 index 0000000000000..ae7801a9abd96 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/const.py @@ -0,0 +1,4 @@ +"""Constants for the Garages Amsterdam integration.""" + +DOMAIN = "garages_amsterdam" +ATTRIBUTION = f'{"Data provided by municipality of Amsterdam"}' diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json new file mode 100644 index 0000000000000..aedfa3cca6515 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "garages_amsterdam", + "name": "Garages Amsterdam", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", + "requirements": ["garages-amsterdam==3.0.0"], + "codeowners": ["@klaasnicolaas"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py new file mode 100644 index 0000000000000..252f010dfdb93 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -0,0 +1,72 @@ +"""Sensor platform for Garages Amsterdam.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import get_coordinator +from .const import ATTRIBUTION + +SENSORS = { + "free_space_short": "mdi:car", + "free_space_long": "mdi:car", + "short_capacity": "mdi:car", + "long_capacity": "mdi:car", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator = await get_coordinator(hass) + + entities: list[GaragesamsterdamSensor] = [] + + for info_type in SENSORS: + if getattr(coordinator.data[config_entry.data["garage_name"]], info_type) != "": + entities.append( + GaragesamsterdamSensor( + coordinator, config_entry.data["garage_name"], info_type + ) + ) + + async_add_entities(entities) + + +class GaragesamsterdamSensor(CoordinatorEntity, SensorEntity): + """Sensor representing garages amsterdam data.""" + + _attr_attribution = ATTRIBUTION + _attr_native_unit_of_measurement = "cars" + + def __init__( + self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str + ) -> None: + """Initialize garages amsterdam sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{garage_name}-{info_type}" + self._garage_name = garage_name + self._info_type = info_type + self._attr_name = f"{garage_name} - {info_type}".replace("_", " ") + self._attr_icon = SENSORS[info_type] + + @property + def available(self) -> bool: + """Return if sensor is available.""" + return self.coordinator.last_update_success and ( + self._garage_name in self.coordinator.data + ) + + @property + def native_value(self) -> str: + """Return the state of the sensor.""" + return getattr(self.coordinator.data[self._garage_name], self._info_type) diff --git a/homeassistant/components/garages_amsterdam/strings.json b/homeassistant/components/garages_amsterdam/strings.json new file mode 100644 index 0000000000000..c8c3968aa5973 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/strings.json @@ -0,0 +1,16 @@ +{ + "title": "Garages Amsterdam", + "config": { + "step": { + "user": { + "title": "Pick a garage to monitor", + "data": { "garage_name": "Garage name" } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/garages_amsterdam/translations/bg.json b/homeassistant/components/garages_amsterdam/translations/bg.json new file mode 100644 index 0000000000000..122ff7a647469 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/bg.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/ca.json b/homeassistant/components/garages_amsterdam/translations/ca.json new file mode 100644 index 0000000000000..328054bafdfd3 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "garage_name": "Nom del garatge" + }, + "title": "Tria un garatge a controlar" + } + } + }, + "title": "Garatges Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/de.json b/homeassistant/components/garages_amsterdam/translations/de.json new file mode 100644 index 0000000000000..8a35e2f2e263f --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "garage_name": "Name der Garage" + }, + "title": "W\u00e4hle eine Garage zur \u00dcberwachung aus" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/en.json b/homeassistant/components/garages_amsterdam/translations/en.json new file mode 100644 index 0000000000000..03efd75777361 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "garage_name": "Garage name" + }, + "title": "Pick a garage to monitor" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/es-419.json b/homeassistant/components/garages_amsterdam/translations/es-419.json new file mode 100644 index 0000000000000..ef74816d2fc10 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "garage_name": "Nombre del garaje" + }, + "title": "Elija un garaje para monitorear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/es.json b/homeassistant/components/garages_amsterdam/translations/es.json new file mode 100644 index 0000000000000..79433b6b854fc --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "garage_name": "Nombre del garaje" + }, + "title": "Elige un garaje para vigilar" + } + } + }, + "title": "Garajes Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/et.json b/homeassistant/components/garages_amsterdam/translations/et.json new file mode 100644 index 0000000000000..027743da89d0c --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/et.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Tundmatu viga" + }, + "step": { + "user": { + "data": { + "garage_name": "Garaa\u017ei nimi" + }, + "title": "Vali j\u00e4lgitav garaa\u017e" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/fr.json b/homeassistant/components/garages_amsterdam/translations/fr.json new file mode 100644 index 0000000000000..68530899d1e78 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "garage_name": "Nom du garage" + }, + "title": "Choisisser un garage \u00e0 surveiller" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/he.json b/homeassistant/components/garages_amsterdam/translations/he.json new file mode 100644 index 0000000000000..6440400378346 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/he.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/hu.json b/homeassistant/components/garages_amsterdam/translations/hu.json new file mode 100644 index 0000000000000..66bc29c02fa21 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "garage_name": "Gar\u00e1zs neve" + }, + "title": "V\u00e1lasszon egy gar\u00e1zst a megfigyel\u00e9shez" + } + } + }, + "title": "Gar\u00e1zsok Amszterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/id.json b/homeassistant/components/garages_amsterdam/translations/id.json new file mode 100644 index 0000000000000..f12cfd6fc8029 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/id.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "garage_name": "Nama garasi" + }, + "title": "Pilih garasi untuk dipantau" + } + } + }, + "title": "Garasi Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/it.json b/homeassistant/components/garages_amsterdam/translations/it.json new file mode 100644 index 0000000000000..4124c1c07a5c1 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "garage_name": "Nome del garage" + }, + "title": "Scegli un garage da monitorare" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/ja.json b/homeassistant/components/garages_amsterdam/translations/ja.json new file mode 100644 index 0000000000000..778dd7077a1d8 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/ja.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "garage_name": "\u30ac\u30ec\u30fc\u30b8\u540d" + }, + "title": "\u76e3\u8996\u3059\u308b\u30ac\u30ec\u30fc\u30b8\u3092\u9078\u629e" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/nl.json b/homeassistant/components/garages_amsterdam/translations/nl.json new file mode 100644 index 0000000000000..c4c4c9ed86cfc --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "garage_name": "Garage naam" + }, + "title": "Kies een garage om te monitoren" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/no.json b/homeassistant/components/garages_amsterdam/translations/no.json new file mode 100644 index 0000000000000..d93564e3f18c7 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "garage_name": "Garasjens navn" + }, + "title": "Velg en garasje \u00e5 overv\u00e5ke" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/pl.json b/homeassistant/components/garages_amsterdam/translations/pl.json new file mode 100644 index 0000000000000..a9f220d9bfcad --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "garage_name": "Nazwa parkingu" + }, + "title": "Wybierz parking do monitorowania" + } + } + }, + "title": "Parkingi Amsterdamie" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/ru.json b/homeassistant/components/garages_amsterdam/translations/ru.json new file mode 100644 index 0000000000000..c4629647d05ea --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "garage_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0433\u0430\u0440\u0430\u0436\u0430" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0433\u0430\u0440\u0430\u0436 \u0434\u043b\u044f \u043d\u0430\u0431\u043b\u044e\u0434\u0435\u043d\u0438\u044f" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/tr.json b/homeassistant/components/garages_amsterdam/translations/tr.json new file mode 100644 index 0000000000000..49ddee0ef7b97 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/tr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "garage_name": "Garaj ad\u0131" + }, + "title": "\u0130zlemek i\u00e7in bir garaj se\u00e7in" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/zh-Hant.json b/homeassistant/components/garages_amsterdam/translations/zh-Hant.json new file mode 100644 index 0000000000000..1c039792da195 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "garage_name": "\u8eca\u5eab\u540d\u7a31" + }, + "title": "\u9078\u64c7\u6240\u8981\u76e3\u8996\u7684\u8eca\u5eab" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py deleted file mode 100644 index 4ac157707fc45..0000000000000 --- a/homeassistant/components/garmin_connect/__init__.py +++ /dev/null @@ -1,110 +0,0 @@ -"""The Garmin Connect integration.""" -from datetime import date, timedelta -import logging - -from garminconnect import ( - Garmin, - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.util import Throttle - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = ["sensor"] -MIN_SCAN_INTERVAL = timedelta(minutes=10) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - """Set up Garmin Connect from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - - garmin_client = Garmin(username, password) - - try: - await hass.async_add_executor_job(garmin_client.login) - except ( - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, - ) as err: - _LOGGER.error("Error occurred during Garmin Connect login request: %s", err) - return False - except (GarminConnectConnectionError) as err: - _LOGGER.error( - "Connection error occurred during Garmin Connect login request: %s", err - ) - raise ConfigEntryNotReady from err - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error occurred during Garmin Connect login request") - return False - - garmin_data = GarminConnectData(hass, garmin_client) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = garmin_data - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - - -class GarminConnectData: - """Define an object to hold sensor data.""" - - def __init__(self, hass, client): - """Initialize.""" - self.hass = hass - self.client = client - self.data = None - - async def _get_combined_alarms_of_all_devices(self): - """Combine the list of active alarms from all garmin devices.""" - alarms = [] - devices = await self.hass.async_add_executor_job(self.client.get_devices) - for device in devices: - device_settings = await self.hass.async_add_executor_job( - self.client.get_device_settings, device["deviceId"] - ) - alarms += device_settings["alarms"] - return alarms - - @Throttle(MIN_SCAN_INTERVAL) - async def async_update(self): - """Update data via library.""" - today = date.today() - - try: - self.data = await self.hass.async_add_executor_job( - self.client.get_stats_and_body, today.isoformat() - ) - self.data["nextAlarm"] = await self._get_combined_alarms_of_all_devices() - except ( - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, - GarminConnectConnectionError, - ) as err: - _LOGGER.error( - "Error occurred during Garmin Connect get activity request: %s", err - ) - return - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Unknown error occurred during Garmin Connect get activity request" - ) - return diff --git a/homeassistant/components/garmin_connect/alarm_util.py b/homeassistant/components/garmin_connect/alarm_util.py deleted file mode 100644 index 4964d70e886bb..0000000000000 --- a/homeassistant/components/garmin_connect/alarm_util.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Utility method for converting Garmin Connect alarms to python datetime.""" -from datetime import date, datetime, timedelta -import logging - -_LOGGER = logging.getLogger(__name__) - -DAY_TO_NUMBER = { - "Mo": 1, - "M": 1, - "Tu": 2, - "We": 3, - "W": 3, - "Th": 4, - "Fr": 5, - "F": 5, - "Sa": 6, - "Su": 7, -} - - -def calculate_next_active_alarms(alarms): - """ - Calculate garmin next active alarms from settings. - - Alarms are sorted by time - """ - active_alarms = [] - _LOGGER.debug(alarms) - - for alarm_setting in alarms: - if alarm_setting["alarmMode"] != "ON": - continue - for day in alarm_setting["alarmDays"]: - alarm_time = alarm_setting["alarmTime"] - if day == "ONCE": - midnight = datetime.combine(date.today(), datetime.min.time()) - alarm = midnight + timedelta(minutes=alarm_time) - if alarm < datetime.now(): - alarm += timedelta(days=1) - else: - start_of_week = datetime.combine( - date.today() - timedelta(days=datetime.today().isoweekday() % 7), - datetime.min.time(), - ) - days_to_add = DAY_TO_NUMBER[day] % 7 - alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add) - if alarm < datetime.now(): - alarm += timedelta(days=7) - active_alarms.append(alarm.isoformat()) - return sorted(active_alarms) if active_alarms else None diff --git a/homeassistant/components/garmin_connect/config_flow.py b/homeassistant/components/garmin_connect/config_flow.py deleted file mode 100644 index 218a98ba9a428..0000000000000 --- a/homeassistant/components/garmin_connect/config_flow.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Config flow for Garmin Connect integration.""" -import logging - -from garminconnect import ( - Garmin, - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -class GarminConnectConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Garmin Connect.""" - - VERSION = 1 - - async def _show_setup_form(self, errors=None): - """Show the setup form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), - errors=errors or {}, - ) - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - if user_input is None: - return await self._show_setup_form() - - garmin_client = Garmin(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - - errors = {} - try: - await self.hass.async_add_executor_job(garmin_client.login) - except GarminConnectConnectionError: - errors["base"] = "cannot_connect" - return await self._show_setup_form(errors) - except GarminConnectAuthenticationError: - errors["base"] = "invalid_auth" - return await self._show_setup_form(errors) - except GarminConnectTooManyRequestsError: - errors["base"] = "too_many_requests" - return await self._show_setup_form(errors) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return await self._show_setup_form(errors) - - unique_id = garmin_client.get_full_name() - - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=unique_id, - data={ - CONF_ID: unique_id, - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - }, - ) diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py deleted file mode 100644 index 991ac90526a9b..0000000000000 --- a/homeassistant/components/garmin_connect/const.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Constants for the Garmin Connect integration.""" -from homeassistant.const import ( - DEVICE_CLASS_TIMESTAMP, - LENGTH_METERS, - MASS_KILOGRAMS, - PERCENTAGE, - TIME_MINUTES, -) - -DOMAIN = "garmin_connect" -ATTRIBUTION = "Data provided by garmin.com" - -GARMIN_ENTITY_LIST = { - "totalSteps": ["Total Steps", "steps", "mdi:walk", None, True], - "dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, True], - "totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, True], - "activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, True], - "bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, True], - "consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, False], - "burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, True], - "remainingKilocalories": [ - "Remaining KiloCalories", - "kcal", - "mdi:food", - None, - False, - ], - "netRemainingKilocalories": [ - "Net Remaining KiloCalories", - "kcal", - "mdi:food", - None, - False, - ], - "netCalorieGoal": ["Net Calorie Goal", "cal", "mdi:food", None, False], - "totalDistanceMeters": [ - "Total Distance Mtr", - LENGTH_METERS, - "mdi:walk", - None, - True, - ], - "wellnessStartTimeLocal": [ - "Wellness Start Time", - None, - "mdi:clock", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "wellnessEndTimeLocal": [ - "Wellness End Time", - None, - "mdi:clock", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "wellnessDescription": ["Wellness Description", "", "mdi:clock", None, False], - "wellnessDistanceMeters": [ - "Wellness Distance Mtr", - LENGTH_METERS, - "mdi:walk", - None, - False, - ], - "wellnessActiveKilocalories": [ - "Wellness Active KiloCalories", - "kcal", - "mdi:food", - None, - False, - ], - "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False], - "highlyActiveSeconds": [ - "Highly Active Time", - TIME_MINUTES, - "mdi:fire", - None, - False, - ], - "activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True], - "sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True], - "sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True], - "measurableAwakeDuration": [ - "Awake Duration", - TIME_MINUTES, - "mdi:sleep", - None, - True, - ], - "measurableAsleepDuration": [ - "Sleep Duration", - TIME_MINUTES, - "mdi:sleep", - None, - True, - ], - "floorsAscendedInMeters": [ - "Floors Ascended Mtr", - LENGTH_METERS, - "mdi:stairs", - None, - False, - ], - "floorsDescendedInMeters": [ - "Floors Descended Mtr", - LENGTH_METERS, - "mdi:stairs", - None, - False, - ], - "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True], - "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True], - "userFloorsAscendedGoal": [ - "Floors Ascended Goal", - "floors", - "mdi:stairs", - None, - True, - ], - "minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, True], - "maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, True], - "restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, True], - "minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], - "maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], - "abnormalHeartRateAlertsCount": [ - "Abnormal HR Counts", - "", - "mdi:heart-pulse", - None, - False, - ], - "lastSevenDaysAvgRestingHeartRate": [ - "Last 7 Days Avg Heart Rate", - "bpm", - "mdi:heart-pulse", - None, - False, - ], - "averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True], - "maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True], - "stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False], - "stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False], - "restStressDuration": [ - "Rest Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "activityStressDuration": [ - "Activity Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "uncategorizedStressDuration": [ - "Uncat. Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "totalStressDuration": [ - "Total Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "lowStressDuration": [ - "Low Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "mediumStressDuration": [ - "Medium Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "highStressDuration": [ - "High Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "stressPercentage": [ - "Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "restStressPercentage": [ - "Rest Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "activityStressPercentage": [ - "Activity Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "uncategorizedStressPercentage": [ - "Uncat. Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "lowStressPercentage": [ - "Low Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "mediumStressPercentage": [ - "Medium Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "highStressPercentage": [ - "High Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "moderateIntensityMinutes": [ - "Moderate Intensity", - TIME_MINUTES, - "mdi:flash-alert", - None, - False, - ], - "vigorousIntensityMinutes": [ - "Vigorous Intensity", - TIME_MINUTES, - "mdi:run-fast", - None, - False, - ], - "intensityMinutesGoal": [ - "Intensity Goal", - TIME_MINUTES, - "mdi:run-fast", - None, - False, - ], - "bodyBatteryChargedValue": [ - "Body Battery Charged", - PERCENTAGE, - "mdi:battery-charging-100", - None, - True, - ], - "bodyBatteryDrainedValue": [ - "Body Battery Drained", - PERCENTAGE, - "mdi:battery-alert-variant-outline", - None, - True, - ], - "bodyBatteryHighestValue": [ - "Body Battery Highest", - PERCENTAGE, - "mdi:battery-heart", - None, - True, - ], - "bodyBatteryLowestValue": [ - "Body Battery Lowest", - PERCENTAGE, - "mdi:battery-heart-outline", - None, - True, - ], - "bodyBatteryMostRecentValue": [ - "Body Battery Most Recent", - PERCENTAGE, - "mdi:battery-positive", - None, - True, - ], - "averageSpo2": ["Average SPO2", PERCENTAGE, "mdi:diabetes", None, True], - "lowestSpo2": ["Lowest SPO2", PERCENTAGE, "mdi:diabetes", None, True], - "latestSpo2": ["Latest SPO2", PERCENTAGE, "mdi:diabetes", None, True], - "latestSpo2ReadingTimeLocal": [ - "Latest SPO2 Time", - None, - "mdi:diabetes", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "averageMonitoringEnvironmentAltitude": [ - "Average Altitude", - PERCENTAGE, - "mdi:image-filter-hdr", - None, - False, - ], - "highestRespirationValue": [ - "Highest Respiration", - "brpm", - "mdi:progress-clock", - None, - False, - ], - "lowestRespirationValue": [ - "Lowest Respiration", - "brpm", - "mdi:progress-clock", - None, - False, - ], - "latestRespirationValue": [ - "Latest Respiration", - "brpm", - "mdi:progress-clock", - None, - False, - ], - "latestRespirationTimeGMT": [ - "Latest Respiration Update", - None, - "mdi:progress-clock", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "weight": ["Weight", MASS_KILOGRAMS, "mdi:weight-kilogram", None, False], - "bmi": ["BMI", "", "mdi:food", None, False], - "bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, False], - "bodyWater": ["Body Water", PERCENTAGE, "mdi:water-percent", None, False], - "bodyMass": ["Body Mass", MASS_KILOGRAMS, "mdi:food", None, False], - "muscleMass": ["Muscle Mass", MASS_KILOGRAMS, "mdi:dumbbell", None, False], - "physiqueRating": ["Physique Rating", "", "mdi:numeric", None, False], - "visceralFat": ["Visceral Fat", "", "mdi:food", None, False], - "metabolicAge": ["Metabolic Age", "", "mdi:calendar-heart", None, False], - "nextAlarm": ["Next Alarm Time", None, "mdi:alarm", DEVICE_CLASS_TIMESTAMP, True], -} diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json deleted file mode 100644 index 913e85de95494..0000000000000 --- a/homeassistant/components/garmin_connect/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "garmin_connect", - "name": "Garmin Connect", - "documentation": "https://www.home-assistant.io/integrations/garmin_connect", - "requirements": ["garminconnect==0.1.19"], - "codeowners": ["@cyberjunky"], - "config_flow": true, - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/garmin_connect/sensor.py b/homeassistant/components/garmin_connect/sensor.py deleted file mode 100644 index 0d946d5e88ee1..0000000000000 --- a/homeassistant/components/garmin_connect/sensor.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Platform for Garmin Connect integration.""" -from __future__ import annotations - -import logging - -from garminconnect import ( - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo - -from .alarm_util import calculate_next_active_alarms -from .const import ATTRIBUTION, DOMAIN, GARMIN_ENTITY_LIST - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -) -> None: - """Set up Garmin Connect sensor based on a config entry.""" - garmin_data = hass.data[DOMAIN][entry.entry_id] - unique_id = entry.data[CONF_ID] - - try: - await garmin_data.async_update() - except ( - GarminConnectConnectionError, - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, - ) as err: - _LOGGER.error("Error occurred during Garmin Connect Client update: %s", err) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error occurred during Garmin Connect Client update") - - entities = [] - for ( - sensor_type, - (name, unit, icon, device_class, enabled_by_default), - ) in GARMIN_ENTITY_LIST.items(): - - _LOGGER.debug( - "Registering entity: %s, %s, %s, %s, %s, %s", - sensor_type, - name, - unit, - icon, - device_class, - enabled_by_default, - ) - entities.append( - GarminConnectSensor( - garmin_data, - unique_id, - sensor_type, - name, - unit, - icon, - device_class, - enabled_by_default, - ) - ) - - async_add_entities(entities, True) - - -class GarminConnectSensor(SensorEntity): - """Representation of a Garmin Connect Sensor.""" - - def __init__( - self, - data, - unique_id, - sensor_type, - name, - unit, - icon, - device_class, - enabled_default: bool = True, - ): - """Initialize.""" - self._data = data - self._unique_id = unique_id - self._type = sensor_type - self._name = name - self._unit = unit - self._icon = icon - self._device_class = device_class - self._enabled_default = enabled_default - self._available = True - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return f"{self._unique_id}_{self._type}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def extra_state_attributes(self): - """Return attributes for sensor.""" - if not self._data.data: - return {} - attributes = { - "source": self._data.data["source"], - "last_synced": self._data.data["lastSyncTimestampGMT"], - ATTR_ATTRIBUTION: ATTRIBUTION, - } - if self._type == "nextAlarm": - attributes["next_alarms"] = calculate_next_active_alarms( - self._data.data[self._type] - ) - return attributes - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return { - "identifiers": {(DOMAIN, self._unique_id)}, - "name": "Garmin Connect", - "manufacturer": "Garmin Connect", - } - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - async def async_update(self): - """Update the data from Garmin Connect.""" - if not self.enabled: - return - - await self._data.async_update() - data = self._data.data - if not data: - _LOGGER.error("Didn't receive data from Garmin Connect") - return - if data.get(self._type) is None: - _LOGGER.debug("Entity type %s not set in fetched data", self._type) - self._available = False - return - self._available = True - - if "Duration" in self._type or "Seconds" in self._type: - self._state = data[self._type] // 60 - elif "Mass" in self._type or self._type == "weight": - self._state = round((data[self._type] / 1000), 2) - elif ( - self._type == "bodyFat" or self._type == "bodyWater" or self._type == "bmi" - ): - self._state = round(data[self._type], 2) - elif self._type == "nextAlarm": - active_alarms = calculate_next_active_alarms(data[self._type]) - if active_alarms: - self._state = active_alarms[0] - else: - self._available = False - else: - self._state = data[self._type] - - _LOGGER.debug( - "Entity %s set to state %s %s", self._type, self._state, self._unit - ) diff --git a/homeassistant/components/garmin_connect/strings.json b/homeassistant/components/garmin_connect/strings.json deleted file mode 100644 index 0ec7a3ce04c39..0000000000000 --- a/homeassistant/components/garmin_connect/strings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "too_many_requests": "Too many requests, retry later.", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "description": "Enter your credentials.", - "title": "Garmin Connect" - } - } - } -} diff --git a/homeassistant/components/garmin_connect/translations/ca.json b/homeassistant/components/garmin_connect/translations/ca.json deleted file mode 100644 index 73b12090fcf0f..0000000000000 --- a/homeassistant/components/garmin_connect/translations/ca.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key::common::config_flow::abort::already_configured_account%]" - }, - "error": { - "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "too_many_requests": "Massa sol\u00b7licituds, torna-ho a intentar m\u00e9s tard.", - "unknown": "Error inesperat" - }, - "step": { - "user": { - "data": { - "password": "Contrasenya", - "username": "Nom d'usuari" - }, - "description": "Introdueix les teves credencials.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/cs.json b/homeassistant/components/garmin_connect/translations/cs.json deleted file mode 100644 index 86b0ce1ddef6d..0000000000000 --- a/homeassistant/components/garmin_connect/translations/cs.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" - }, - "error": { - "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", - "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", - "too_many_requests": "P\u0159\u00edli\u0161 mnoho po\u017eadavk\u016f, opakujte to pozd\u011bji.", - "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" - }, - "step": { - "user": { - "data": { - "password": "Heslo", - "username": "U\u017eivatelsk\u00e9 jm\u00e9no" - }, - "description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/da.json b/homeassistant/components/garmin_connect/translations/da.json deleted file mode 100644 index f664ad0e1f449..0000000000000 --- a/homeassistant/components/garmin_connect/translations/da.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Denne konto er allerede konfigureret." - }, - "error": { - "cannot_connect": "Kunne ikke oprette forbindelse - pr\u00f8v igen.", - "invalid_auth": "Ugyldig godkendelse.", - "too_many_requests": "For mange anmodninger - pr\u00f8v igen senere.", - "unknown": "Uventet fejl." - }, - "step": { - "user": { - "data": { - "password": "Adgangskode", - "username": "Brugernavn" - }, - "description": "Indtast dine legitimationsoplysninger.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/de.json b/homeassistant/components/garmin_connect/translations/de.json deleted file mode 100644 index 9186f753a7724..0000000000000 --- a/homeassistant/components/garmin_connect/translations/de.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Dieses Konto ist bereits konfiguriert." - }, - "error": { - "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung", - "too_many_requests": "Zu viele Anfragen, versuche es sp\u00e4ter erneut.", - "unknown": "Unerwarteter Fehler" - }, - "step": { - "user": { - "data": { - "password": "Passwort", - "username": "Benutzername" - }, - "description": "Geben Sie Ihre Zugangsdaten ein.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/en.json b/homeassistant/components/garmin_connect/translations/en.json deleted file mode 100644 index c1b563d38f3e2..0000000000000 --- a/homeassistant/components/garmin_connect/translations/en.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "too_many_requests": "Too many requests, retry later.", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Username" - }, - "description": "Enter your credentials.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/es-419.json b/homeassistant/components/garmin_connect/translations/es-419.json deleted file mode 100644 index 42263ce0780ad..0000000000000 --- a/homeassistant/components/garmin_connect/translations/es-419.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Esta cuenta ya est\u00e1 configurada." - }, - "error": { - "cannot_connect": "No se pudo conectar, intente nuevamente.", - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", - "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", - "unknown": "Error inesperado." - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Nombre de usuario" - }, - "description": "Ingrese sus credenciales.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/es.json b/homeassistant/components/garmin_connect/translations/es.json deleted file mode 100644 index bef92af294829..0000000000000 --- a/homeassistant/components/garmin_connect/translations/es.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya ha sido configurada" - }, - "error": { - "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", - "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", - "unknown": "Error inesperado" - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Usuario" - }, - "description": "Introduzca sus credenciales.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/et.json b/homeassistant/components/garmin_connect/translations/et.json deleted file mode 100644 index eeaefe927008c..0000000000000 --- a/homeassistant/components/garmin_connect/translations/et.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto on juba seadistatud" - }, - "error": { - "cannot_connect": "\u00dchendamine nurjus", - "invalid_auth": "Tuvastamine nurjus", - "too_many_requests": "Liiga palju taotlusi, proovi hiljem uuesti.", - "unknown": "Tundmatu viga" - }, - "step": { - "user": { - "data": { - "password": "Salas\u00f5na", - "username": "Kasutajanimi" - }, - "description": "Sisesta oma mandaat.", - "title": "" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/fr.json b/homeassistant/components/garmin_connect/translations/fr.json deleted file mode 100644 index ce97ccccf1bed..0000000000000 --- a/homeassistant/components/garmin_connect/translations/fr.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." - }, - "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer.", - "invalid_auth": "Authentification non valide.", - "too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.", - "unknown": "Erreur inattendue." - }, - "step": { - "user": { - "data": { - "password": "Mot de passe", - "username": "Nom d'utilisateur" - }, - "description": "Entrez vos informations d'identification.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/he.json b/homeassistant/components/garmin_connect/translations/he.json deleted file mode 100644 index ac90b3264eab3..0000000000000 --- a/homeassistant/components/garmin_connect/translations/he.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/hu.json b/homeassistant/components/garmin_connect/translations/hu.json deleted file mode 100644 index ae518acf001c4..0000000000000 --- a/homeassistant/components/garmin_connect/translations/hu.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" - }, - "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "too_many_requests": "T\u00fal sok k\u00e9r\u00e9s, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra.", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" - }, - "step": { - "user": { - "data": { - "password": "Jelsz\u00f3", - "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - }, - "description": "Adja meg a hiteles\u00edt\u0151 adatait.", - "title": "Garmin Csatlakoz\u00e1s" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/id.json b/homeassistant/components/garmin_connect/translations/id.json deleted file mode 100644 index 2746075723486..0000000000000 --- a/homeassistant/components/garmin_connect/translations/id.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Akun sudah dikonfigurasi" - }, - "error": { - "cannot_connect": "Gagal terhubung", - "invalid_auth": "Autentikasi tidak valid", - "too_many_requests": "Terlalu banyak permintaan, coba lagi nanti.", - "unknown": "Kesalahan yang tidak diharapkan" - }, - "step": { - "user": { - "data": { - "password": "Kata Sandi", - "username": "Nama Pengguna" - }, - "description": "Masukkan kredensial Anda.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/it.json b/homeassistant/components/garmin_connect/translations/it.json deleted file mode 100644 index 791de295a80a5..0000000000000 --- a/homeassistant/components/garmin_connect/translations/it.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" - }, - "error": { - "cannot_connect": "Impossibile connettersi", - "invalid_auth": "Autenticazione non valida", - "too_many_requests": "Troppe richieste, riprovare pi\u00f9 tardi.", - "unknown": "Errore imprevisto" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Nome utente" - }, - "description": "Inserisci le tue credenziali", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/ko.json b/homeassistant/components/garmin_connect/translations/ko.json deleted file mode 100644 index 4d5330a824f13..0000000000000 --- a/homeassistant/components/garmin_connect/translations/ko.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" - }, - "step": { - "user": { - "data": { - "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" - }, - "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/lb.json b/homeassistant/components/garmin_connect/translations/lb.json deleted file mode 100644 index 583942b1575e8..0000000000000 --- a/homeassistant/components/garmin_connect/translations/lb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kont ass scho konfigur\u00e9iert" - }, - "error": { - "cannot_connect": "Feeler beim verbannen", - "invalid_auth": "Ong\u00eblteg Authentifikatioun", - "too_many_requests": "Ze vill Ufroen, prob\u00e9iert sp\u00e9ider nach emol.", - "unknown": "Onerwaarte Feeler" - }, - "step": { - "user": { - "data": { - "password": "Passwuert", - "username": "Benotzernumm" - }, - "description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/lv.json b/homeassistant/components/garmin_connect/translations/lv.json deleted file mode 100644 index 2c205bdd324b1..0000000000000 --- a/homeassistant/components/garmin_connect/translations/lv.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Parole", - "username": "Lietot\u0101jv\u0101rds" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/nl.json b/homeassistant/components/garmin_connect/translations/nl.json deleted file mode 100644 index e751aaf1b5c69..0000000000000 --- a/homeassistant/components/garmin_connect/translations/nl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is al geconfigureerd" - }, - "error": { - "cannot_connect": "Kan geen verbinding maken", - "invalid_auth": "Ongeldige authenticatie", - "too_many_requests": "Te veel aanvragen, probeer het later opnieuw.", - "unknown": "Onverwachte fout" - }, - "step": { - "user": { - "data": { - "password": "Wachtwoord", - "username": "Gebruikersnaam" - }, - "description": "Voer uw gegevens in", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/no.json b/homeassistant/components/garmin_connect/translations/no.json deleted file mode 100644 index 41cc222bb7326..0000000000000 --- a/homeassistant/components/garmin_connect/translations/no.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigurert" - }, - "error": { - "cannot_connect": "Tilkobling mislyktes", - "invalid_auth": "Ugyldig godkjenning", - "too_many_requests": "For mange foresp\u00f8rsler, pr\u00f8v p\u00e5 nytt senere.", - "unknown": "Uventet feil" - }, - "step": { - "user": { - "data": { - "password": "Passord", - "username": "Brukernavn" - }, - "description": "Fyll inn legitimasjonen din.", - "title": "" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pl.json b/homeassistant/components/garmin_connect/translations/pl.json deleted file mode 100644 index 715258e15f9f4..0000000000000 --- a/homeassistant/components/garmin_connect/translations/pl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" - }, - "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "invalid_auth": "Niepoprawne uwierzytelnienie", - "too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej", - "unknown": "Nieoczekiwany b\u0142\u0105d" - }, - "step": { - "user": { - "data": { - "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika" - }, - "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pt-BR.json b/homeassistant/components/garmin_connect/translations/pt-BR.json deleted file mode 100644 index 157ac3f0477f4..0000000000000 --- a/homeassistant/components/garmin_connect/translations/pt-BR.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "config": { - "step": { - "user": { - "description": "Digite suas credenciais.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pt.json b/homeassistant/components/garmin_connect/translations/pt.json deleted file mode 100644 index 2d9b2f9e9c533..0000000000000 --- a/homeassistant/components/garmin_connect/translations/pt.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Conta j\u00e1 configurada" - }, - "error": { - "cannot_connect": "Falha na liga\u00e7\u00e3o", - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", - "unknown": "Erro inesperado" - }, - "step": { - "user": { - "data": { - "password": "Palavra-passe", - "username": "Nome de Utilizador" - }, - "description": "Introduza as suas credenciais.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/ru.json b/homeassistant/components/garmin_connect/translations/ru.json deleted file mode 100644 index 066c337309f7c..0000000000000 --- a/homeassistant/components/garmin_connect/translations/ru.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." - }, - "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", - "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" - }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/sl.json b/homeassistant/components/garmin_connect/translations/sl.json deleted file mode 100644 index 594cbffeaa72f..0000000000000 --- a/homeassistant/components/garmin_connect/translations/sl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ta ra\u010dun je \u017ee konfiguriran." - }, - "error": { - "cannot_connect": "Povezava ni uspela, poskusite znova.", - "invalid_auth": "Neveljavna avtentikacija.", - "too_many_requests": "Preve\u010d zahtev, poskusite pozneje.", - "unknown": "Nepri\u010dakovana napaka." - }, - "step": { - "user": { - "data": { - "password": "Geslo", - "username": "Uporabni\u0161ko ime" - }, - "description": "Vnesite svoje poverilnice.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/sv.json b/homeassistant/components/garmin_connect/translations/sv.json deleted file mode 100644 index 0f11ab2a8b963..0000000000000 --- a/homeassistant/components/garmin_connect/translations/sv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Det h\u00e4r kontot har redan konfigurerats." - }, - "error": { - "cannot_connect": "Kunde inte ansluta, var god f\u00f6rs\u00f6k igen.", - "invalid_auth": "Ogiltig autentisering.", - "too_many_requests": "F\u00f6r m\u00e5nga f\u00f6rfr\u00e5gningar, f\u00f6rs\u00f6k igen senare.", - "unknown": "Ov\u00e4ntat fel." - }, - "step": { - "user": { - "data": { - "password": "L\u00f6senord", - "username": "Anv\u00e4ndarnamn" - }, - "description": "Ange dina anv\u00e4ndaruppgifter.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/uk.json b/homeassistant/components/garmin_connect/translations/uk.json deleted file mode 100644 index aef0632b0f17d..0000000000000 --- a/homeassistant/components/garmin_connect/translations/uk.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u0426\u0435\u0439 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant." - }, - "error": { - "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", - "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f.", - "too_many_requests": "\u0417\u0430\u043d\u0430\u0434\u0442\u043e \u0431\u0430\u0433\u0430\u0442\u043e \u0437\u0430\u043f\u0438\u0442\u0456\u0432, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437 \u043f\u0456\u0437\u043d\u0456\u0448\u0435.", - "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" - }, - "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0412\u0430\u0448\u0456 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0456 \u0434\u0430\u043d\u0456.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/zh-Hans.json b/homeassistant/components/garmin_connect/translations/zh-Hans.json deleted file mode 100644 index a5f4ff11f09ef..0000000000000 --- a/homeassistant/components/garmin_connect/translations/zh-Hans.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "username": "\u7528\u6237\u540d" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/zh-Hant.json b/homeassistant/components/garmin_connect/translations/zh-Hant.json deleted file mode 100644 index cbf928152aa70..0000000000000 --- a/homeassistant/components/garmin_connect/translations/zh-Hant.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" - }, - "error": { - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "too_many_requests": "\u8acb\u6c42\u6b21\u6578\u904e\u591a\uff0c\u8acb\u7a0d\u5f8c\u91cd\u8a66\u3002", - "unknown": "\u672a\u9810\u671f\u932f\u8aa4" - }, - "step": { - "user": { - "data": { - "password": "\u5bc6\u78bc", - "username": "\u4f7f\u7528\u8005\u540d\u7a31" - }, - "description": "\u8f38\u5165\u6191\u8b49\u3002", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json index 55ea7d946822a..8caa2f9120482 100644 --- a/homeassistant/components/gc100/manifest.json +++ b/homeassistant/components/gc100/manifest.json @@ -2,7 +2,7 @@ "domain": "gc100", "name": "Global Cach\u00e9 GC-100", "documentation": "https://www.home-assistant.io/integrations/gc100", - "requirements": ["python-gc100==1.0.3a"], + "requirements": ["python-gc100==1.0.3a0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py index 5d5c83f013e32..551c8be5810cf 100644 --- a/homeassistant/components/gdacs/const.py +++ b/homeassistant/components/gdacs/const.py @@ -3,9 +3,11 @@ from aio_georss_gdacs.consts import EVENT_TYPE_MAP +from homeassistant.const import Platform + DOMAIN = "gdacs" -PLATFORMS = ("sensor", "geo_location") +PLATFORMS = [Platform.SENSOR, Platform.GEO_LOCATION] FEED = "feed" diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 7e3dc7484bb94..fb0782de5252a 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -138,8 +138,7 @@ async def async_update(self): def _update_from_feed(self, feed_entry): """Update the internal state from the provided feed entry.""" - event_name = feed_entry.event_name - if not event_name: + if not (event_name := feed_entry.event_name): # Earthquakes usually don't have an event name. event_name = f"{feed_entry.country} ({feed_entry.event_id})" self._title = f"{feed_entry.event_type}: {event_name}" diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index 26743a69d682a..65407e85848bd 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -3,7 +3,7 @@ "name": "Global Disaster Alert and Coordination System (GDACS)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gdacs", - "requirements": ["aio_georss_gdacs==0.4"], + "requirements": ["aio_georss_gdacs==0.5"], "codeowners": ["@exxamalte"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 2e4759088fc5b..8b4c60046db9c 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -105,7 +105,7 @@ def _update_from_status_info(self, status_info): self._removed = status_info.removed @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._total @@ -125,7 +125,7 @@ def icon(self): return DEFAULT_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEFAULT_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/gdacs/translations/bg.json b/homeassistant/components/gdacs/translations/bg.json new file mode 100644 index 0000000000000..80a7cc489a9b0 --- /dev/null +++ b/homeassistant/components/gdacs/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/fr.json b/homeassistant/components/gdacs/translations/fr.json index df44a1d9fa5f2..b1e77bf4d432d 100644 --- a/homeassistant/components/gdacs/translations/fr.json +++ b/homeassistant/components/gdacs/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/gdacs/translations/he.json b/homeassistant/components/gdacs/translations/he.json new file mode 100644 index 0000000000000..48a6eeeea33b1 --- /dev/null +++ b/homeassistant/components/gdacs/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/ja.json b/homeassistant/components/gdacs/translations/ja.json new file mode 100644 index 0000000000000..de9079d249f83 --- /dev/null +++ b/homeassistant/components/gdacs/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f84" + }, + "title": "\u30d5\u30a3\u30eb\u30bf\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/tr.json b/homeassistant/components/gdacs/translations/tr.json index aeb6a5a345e28..a5f849405ea05 100644 --- a/homeassistant/components/gdacs/translations/tr.json +++ b/homeassistant/components/gdacs/translations/tr.json @@ -7,7 +7,8 @@ "user": { "data": { "radius": "Yar\u0131\u00e7ap" - } + }, + "title": "Filtre ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 doldurun." } } } diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 1ec7f0874e093..b6e08ea8582c0 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio import logging @@ -118,52 +120,43 @@ def frame_interval(self): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" return asyncio.run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() - async def async_camera_image(self): - """Wrap _async_camera_image with an asyncio.shield.""" - # Shield the request because of https://github.com/encode/httpx/issues/1461 - try: - self._last_url, self._last_image = await asyncio.shield( - self._async_camera_image() - ) - except asyncio.CancelledError as err: - _LOGGER.warning("Timeout getting camera image from %s", self._name) - raise err - return self._last_image - - async def _async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: url = self._still_image_url.async_render(parse_result=False) except TemplateError as err: _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) - return self._last_url, self._last_image + return self._last_image if url == self._last_url and self._limit_refetch: - return self._last_url, self._last_image - response = None + return self._last_image + try: async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) response = await async_client.get( url, auth=self._auth, timeout=GET_IMAGE_TIMEOUT ) response.raise_for_status() - image = response.content + self._last_image = response.content except httpx.TimeoutException: _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_url, self._last_image + return self._last_image except (httpx.RequestError, httpx.HTTPStatusError) as err: _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - return self._last_url, self._last_image - finally: - if response: - await response.aclose() - return url, image + return self._last_image + + self._last_url = url + return self._last_image @property def name(self): diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 8ab7bec48ac70..ab6aa18c4d2c3 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -1,6 +1,6 @@ { "domain": "generic", - "name": "Generic", + "name": "Generic Camera", "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/generic/services.yaml b/homeassistant/components/generic/services.yaml index afde1990ceffa..a05a9e3415dae 100644 --- a/homeassistant/components/generic/services.yaml +++ b/homeassistant/components/generic/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all generic entities. diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py new file mode 100644 index 0000000000000..20877f63369ab --- /dev/null +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -0,0 +1,71 @@ +"""The generic_hygrostat component.""" + +import voluptuous as vol + +from homeassistant.components.humidifier import HumidifierDeviceClass +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, discovery + +DOMAIN = "generic_hygrostat" + +CONF_HUMIDIFIER = "humidifier" +CONF_SENSOR = "target_sensor" +CONF_MIN_HUMIDITY = "min_humidity" +CONF_MAX_HUMIDITY = "max_humidity" +CONF_TARGET_HUMIDITY = "target_humidity" +CONF_DEVICE_CLASS = "device_class" +CONF_MIN_DUR = "min_cycle_duration" +CONF_DRY_TOLERANCE = "dry_tolerance" +CONF_WET_TOLERANCE = "wet_tolerance" +CONF_KEEP_ALIVE = "keep_alive" +CONF_INITIAL_STATE = "initial_state" +CONF_AWAY_HUMIDITY = "away_humidity" +CONF_AWAY_FIXED = "away_fixed" +CONF_STALE_DURATION = "sensor_stale_duration" + +DEFAULT_TOLERANCE = 3 +DEFAULT_NAME = "Generic Hygrostat" + +HYGROSTAT_SCHEMA = vol.Schema( + { + vol.Required(CONF_HUMIDIFIER): cv.entity_id, + vol.Required(CONF_SENSOR): cv.entity_id, + vol.Optional(CONF_DEVICE_CLASS): vol.In( + [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER] + ), + vol.Optional(CONF_MAX_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MIN_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DRY_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_WET_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_TARGET_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_KEEP_ALIVE): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_STATE): cv.boolean, + vol.Optional(CONF_AWAY_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_AWAY_FIXED): cv.boolean, + vol.Optional(CONF_STALE_DURATION): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [HYGROSTAT_SCHEMA])}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Generic Hygrostat component.""" + if DOMAIN not in config: + return True + + for hygrostat_conf in config[DOMAIN]: + hass.async_create_task( + discovery.async_load_platform( + hass, "humidifier", DOMAIN, hygrostat_conf, config + ) + ) + + return True diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py new file mode 100644 index 0000000000000..7a92eb79d51b9 --- /dev/null +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -0,0 +1,467 @@ +"""Adds support for generic hygrostat units.""" +import asyncio +import logging + +from homeassistant.components.humidifier import ( + PLATFORM_SCHEMA, + HumidifierDeviceClass, + HumidifierEntity, +) +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + MODE_AWAY, + MODE_NORMAL, + SUPPORT_MODES, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import DOMAIN as HA_DOMAIN, callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from . import ( + CONF_AWAY_FIXED, + CONF_AWAY_HUMIDITY, + CONF_DEVICE_CLASS, + CONF_DRY_TOLERANCE, + CONF_HUMIDIFIER, + CONF_INITIAL_STATE, + CONF_KEEP_ALIVE, + CONF_MAX_HUMIDITY, + CONF_MIN_DUR, + CONF_MIN_HUMIDITY, + CONF_SENSOR, + CONF_STALE_DURATION, + CONF_TARGET_HUMIDITY, + CONF_WET_TOLERANCE, + HYGROSTAT_SCHEMA, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_SAVED_HUMIDITY = "saved_humidity" + +SUPPORT_FLAGS = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(HYGROSTAT_SCHEMA.schema) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the generic hygrostat platform.""" + if discovery_info: + config = discovery_info + name = config[CONF_NAME] + switch_entity_id = config[CONF_HUMIDIFIER] + sensor_entity_id = config[CONF_SENSOR] + min_humidity = config.get(CONF_MIN_HUMIDITY) + max_humidity = config.get(CONF_MAX_HUMIDITY) + target_humidity = config.get(CONF_TARGET_HUMIDITY) + device_class = config.get(CONF_DEVICE_CLASS) + min_cycle_duration = config.get(CONF_MIN_DUR) + sensor_stale_duration = config.get(CONF_STALE_DURATION) + dry_tolerance = config[CONF_DRY_TOLERANCE] + wet_tolerance = config[CONF_WET_TOLERANCE] + keep_alive = config.get(CONF_KEEP_ALIVE) + initial_state = config.get(CONF_INITIAL_STATE) + away_humidity = config.get(CONF_AWAY_HUMIDITY) + away_fixed = config.get(CONF_AWAY_FIXED) + + async_add_entities( + [ + GenericHygrostat( + name, + switch_entity_id, + sensor_entity_id, + min_humidity, + max_humidity, + target_humidity, + device_class, + min_cycle_duration, + dry_tolerance, + wet_tolerance, + keep_alive, + initial_state, + away_humidity, + away_fixed, + sensor_stale_duration, + ) + ] + ) + + +class GenericHygrostat(HumidifierEntity, RestoreEntity): + """Representation of a Generic Hygrostat device.""" + + def __init__( + self, + name, + switch_entity_id, + sensor_entity_id, + min_humidity, + max_humidity, + target_humidity, + device_class, + min_cycle_duration, + dry_tolerance, + wet_tolerance, + keep_alive, + initial_state, + away_humidity, + away_fixed, + sensor_stale_duration, + ): + """Initialize the hygrostat.""" + self._name = name + self._switch_entity_id = switch_entity_id + self._sensor_entity_id = sensor_entity_id + self._device_class = device_class + self._min_cycle_duration = min_cycle_duration + self._dry_tolerance = dry_tolerance + self._wet_tolerance = wet_tolerance + self._keep_alive = keep_alive + self._state = initial_state + self._saved_target_humidity = away_humidity or target_humidity + self._active = False + self._cur_humidity = None + self._humidity_lock = asyncio.Lock() + self._min_humidity = min_humidity + self._max_humidity = max_humidity + self._target_humidity = target_humidity + self._support_flags = SUPPORT_FLAGS + if away_humidity: + self._support_flags = SUPPORT_FLAGS | SUPPORT_MODES + self._away_humidity = away_humidity + self._away_fixed = away_fixed + self._sensor_stale_duration = sensor_stale_duration + self._remove_stale_tracking = None + self._is_away = False + if not self._device_class: + self._device_class = HumidifierDeviceClass.HUMIDIFIER + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + # Add listener + async_track_state_change( + self.hass, self._sensor_entity_id, self._async_sensor_changed + ) + async_track_state_change( + self.hass, self._switch_entity_id, self._async_switch_changed + ) + + if self._keep_alive: + async_track_time_interval(self.hass, self._async_operate, self._keep_alive) + + @callback + async def _async_startup(event): + """Init on startup.""" + sensor_state = self.hass.states.get(self._sensor_entity_id) + await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) + + if (old_state := await self.async_get_last_state()) is not None: + if old_state.attributes.get(ATTR_MODE) == MODE_AWAY: + self._is_away = True + self._saved_target_humidity = self._target_humidity + self._target_humidity = self._away_humidity or self._target_humidity + if old_state.attributes.get(ATTR_HUMIDITY): + self._target_humidity = int(old_state.attributes[ATTR_HUMIDITY]) + if old_state.attributes.get(ATTR_SAVED_HUMIDITY): + self._saved_target_humidity = int( + old_state.attributes[ATTR_SAVED_HUMIDITY] + ) + if old_state.state: + self._state = old_state.state == STATE_ON + if self._target_humidity is None: + if self._device_class == HumidifierDeviceClass.HUMIDIFIER: + self._target_humidity = self.min_humidity + else: + self._target_humidity = self.max_humidity + _LOGGER.warning( + "No previously saved humidity, setting to %s", self._target_humidity + ) + if self._state is None: + self._state = False + + await _async_startup(None) # init the sensor + + @property + def available(self): + """Return True if entity is available.""" + return self._active + + @property + def extra_state_attributes(self): + """Return the optional state attributes.""" + if self._saved_target_humidity: + return {ATTR_SAVED_HUMIDITY: self._saved_target_humidity} + return None + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the hygrostat.""" + return self._name + + @property + def is_on(self): + """Return true if the hygrostat is on.""" + return self._state + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def mode(self): + """Return the current mode.""" + if self._away_humidity is None: + return None + if self._is_away: + return MODE_AWAY + return MODE_NORMAL + + @property + def available_modes(self): + """Return a list of available modes.""" + if self._away_humidity: + return [MODE_NORMAL, MODE_AWAY] + return None + + @property + def device_class(self): + """Return the device class of the humidifier.""" + return self._device_class + + async def async_turn_on(self, **kwargs): + """Turn hygrostat on.""" + if not self._active: + return + self._state = True + await self._async_operate(force=True) + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn hygrostat off.""" + if not self._active: + return + self._state = False + if self._is_device_active: + await self._async_device_turn_off() + await self.async_update_ha_state() + + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + if humidity is None: + return + + if self._is_away and self._away_fixed: + self._saved_target_humidity = humidity + await self.async_update_ha_state() + return + + self._target_humidity = humidity + await self._async_operate(force=True) + await self.async_update_ha_state() + + @property + def min_humidity(self): + """Return the minimum humidity.""" + if self._min_humidity: + return self._min_humidity + + # get default humidity from super class + return super().min_humidity + + @property + def max_humidity(self): + """Return the maximum humidity.""" + if self._max_humidity: + return self._max_humidity + + # Get default humidity from super class + return super().max_humidity + + @callback + async def _async_sensor_changed(self, entity_id, old_state, new_state): + """Handle ambient humidity changes.""" + if new_state is None: + return + + if self._sensor_stale_duration: + if self._remove_stale_tracking: + self._remove_stale_tracking() + self._remove_stale_tracking = async_track_time_interval( + self.hass, + self._async_sensor_not_responding, + self._sensor_stale_duration, + ) + + await self._async_update_humidity(new_state.state) + await self._async_operate() + await self.async_update_ha_state() + + @callback + async def _async_sensor_not_responding(self, now=None): + """Handle sensor stale event.""" + + _LOGGER.debug( + "Sensor has not been updated for %s", + now - self.hass.states.get(self._sensor_entity_id).last_updated, + ) + _LOGGER.warning("Sensor is stalled, call the emergency stop") + await self._async_update_humidity("Stalled") + + @callback + def _async_switch_changed(self, entity_id, old_state, new_state): + """Handle humidifier switch state changes.""" + if new_state is None: + return + self.async_schedule_update_ha_state() + + async def _async_update_humidity(self, humidity): + """Update hygrostat with latest state from sensor.""" + try: + self._cur_humidity = float(humidity) + except ValueError as ex: + _LOGGER.warning("Unable to update from sensor: %s", ex) + self._cur_humidity = None + self._active = False + if self._is_device_active: + await self._async_device_turn_off() + + async def _async_operate(self, time=None, force=False): + """Check if we need to turn humidifying on or off.""" + async with self._humidity_lock: + if not self._active and None not in ( + self._cur_humidity, + self._target_humidity, + ): + self._active = True + force = True + _LOGGER.info( + "Obtained current and target humidity. " + "Generic hygrostat active. %s, %s", + self._cur_humidity, + self._target_humidity, + ) + + if not self._active or not self._state: + return + + if not force and time is None: + # If the `force` argument is True, we + # ignore `min_cycle_duration`. + # If the `time` argument is not none, we were invoked for + # keep-alive purposes, and `min_cycle_duration` is irrelevant. + if self._min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state( + self.hass, + self._switch_entity_id, + current_state, + self._min_cycle_duration, + ) + if not long_enough: + return + + if force: + # Ignore the tolerance when switched on manually + dry_tolerance = 0 + wet_tolerance = 0 + else: + dry_tolerance = self._dry_tolerance + wet_tolerance = self._wet_tolerance + + too_dry = self._target_humidity - self._cur_humidity >= dry_tolerance + too_wet = self._cur_humidity - self._target_humidity >= wet_tolerance + if self._is_device_active: + if ( + self._device_class == HumidifierDeviceClass.HUMIDIFIER and too_wet + ) or ( + self._device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_dry + ): + _LOGGER.info("Turning off humidifier %s", self._switch_entity_id) + await self._async_device_turn_off() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_on() + else: + if ( + self._device_class == HumidifierDeviceClass.HUMIDIFIER and too_dry + ) or ( + self._device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_wet + ): + _LOGGER.info("Turning on humidifier %s", self._switch_entity_id) + await self._async_device_turn_on() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_off() + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + return self.hass.states.is_state(self._switch_entity_id, STATE_ON) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + async def _async_device_turn_on(self): + """Turn humidifier toggleable device on.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) + + async def _async_device_turn_off(self): + """Turn humidifier toggleable device off.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) + + async def async_set_mode(self, mode: str): + """Set new mode. + + This method must be run in the event loop and returns a coroutine. + """ + if self._away_humidity is None: + return + if mode == MODE_AWAY and not self._is_away: + self._is_away = True + if not self._saved_target_humidity: + self._saved_target_humidity = self._away_humidity + self._saved_target_humidity, self._target_humidity = ( + self._target_humidity, + self._saved_target_humidity, + ) + await self._async_operate(force=True) + elif mode == MODE_NORMAL and self._is_away: + self._is_away = False + self._saved_target_humidity, self._target_humidity = ( + self._target_humidity, + self._saved_target_humidity, + ) + await self._async_operate(force=True) + + await self.async_update_ha_state() diff --git a/homeassistant/components/generic_hygrostat/manifest.json b/homeassistant/components/generic_hygrostat/manifest.json new file mode 100644 index 0000000000000..5874097dc84f9 --- /dev/null +++ b/homeassistant/components/generic_hygrostat/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "generic_hygrostat", + "name": "Generic hygrostat", + "documentation": "https://www.home-assistant.io/integrations/generic_hygrostat", + "codeowners": ["@Shulyaka"], + "quality_scale": "internal", + "iot_class": "local_polling" +} diff --git a/tests/fixtures/rest/configuration_empty.yaml b/homeassistant/components/generic_hygrostat/services.yaml similarity index 100% rename from tests/fixtures/rest/configuration_empty.yaml rename to homeassistant/components/generic_hygrostat/services.yaml diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index e83852d122fa0..67d4be92c9516 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -15,8 +15,12 @@ HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF, + PRESET_ACTIVITY, PRESET_AWAY, + PRESET_COMFORT, + PRESET_HOME, PRESET_NONE, + PRESET_SLEEP, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) @@ -64,10 +68,20 @@ CONF_HOT_TOLERANCE = "hot_tolerance" CONF_KEEP_ALIVE = "keep_alive" CONF_INITIAL_HVAC_MODE = "initial_hvac_mode" -CONF_AWAY_TEMP = "away_temp" CONF_PRECISION = "precision" SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE +CONF_PRESETS = { + p: f"{p}_temp" + for p in ( + PRESET_AWAY, + PRESET_COMFORT, + PRESET_HOME, + PRESET_SLEEP, + PRESET_ACTIVITY, + ) +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HEATER): cv.entity_id, @@ -84,13 +98,12 @@ vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In( [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] ), - vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), vol.Optional(CONF_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), vol.Optional(CONF_UNIQUE_ID): cv.string, } -) +).extend({vol.Optional(v): vol.Coerce(float) for (k, v) in CONF_PRESETS.items()}) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -110,7 +123,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) initial_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE) - away_temp = config.get(CONF_AWAY_TEMP) + presets = { + key: config[value] for key, value in CONF_PRESETS.items() if value in config + } precision = config.get(CONF_PRECISION) unit = hass.config.units.temperature_unit unique_id = config.get(CONF_UNIQUE_ID) @@ -130,7 +145,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hot_tolerance, keep_alive, initial_hvac_mode, - away_temp, + presets, precision, unit, unique_id, @@ -156,7 +171,7 @@ def __init__( hot_tolerance, keep_alive, initial_hvac_mode, - away_temp, + presets, precision, unit, unique_id, @@ -171,7 +186,7 @@ def __init__( self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive self._hvac_mode = initial_hvac_mode - self._saved_target_temp = target_temp or away_temp + self._saved_target_temp = target_temp or next(iter(presets.values()), None) self._temp_precision = precision if self.ac_mode: self._hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF] @@ -182,14 +197,17 @@ def __init__( self._temp_lock = asyncio.Lock() self._min_temp = min_temp self._max_temp = max_temp + self._attr_preset_mode = PRESET_NONE self._target_temp = target_temp self._unit = unit self._unique_id = unique_id self._support_flags = SUPPORT_FLAGS - if away_temp: + if len(presets): self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE - self._away_temp = away_temp - self._is_away = False + self._attr_preset_modes = [PRESET_NONE] + list(presets.keys()) + else: + self._attr_preset_modes = [PRESET_NONE] + self._presets = presets async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -224,6 +242,12 @@ def _async_startup(*_): ): self._async_update_temp(sensor_state) self.async_write_ha_state() + switch_state = self.hass.states.get(self.heater_entity_id) + if switch_state and switch_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self.hass.create_task(self._check_switch_initial_state()) if self.hass.state == CoreState.running: _async_startup() @@ -231,8 +255,7 @@ def _async_startup(*_): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) # Check If we have an old state - old_state = await self.async_get_last_state() - if old_state is not None: + if (old_state := await self.async_get_last_state()) is not None: # If we have no initial temperature, restore if self._target_temp is None: # If we have a previously saved temperature @@ -247,8 +270,8 @@ def _async_startup(*_): ) else: self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) - if old_state.attributes.get(ATTR_PRESET_MODE) == PRESET_AWAY: - self._is_away = True + if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes: + self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) if not self._hvac_mode and old_state.state: self._hvac_mode = old_state.state @@ -267,14 +290,6 @@ def _async_startup(*_): if not self._hvac_mode: self._hvac_mode = HVAC_MODE_OFF - # Prevent the device from keep running if HVAC_MODE_OFF - if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: - await self._async_heater_turn_off() - _LOGGER.warning( - "The climate mode is OFF, but the switch device is ON. Turning off device %s", - self.heater_entity_id, - ) - @property def should_poll(self): """Return the polling state.""" @@ -343,16 +358,6 @@ def hvac_modes(self): """List of available operation modes.""" return self._hvac_list - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - return PRESET_AWAY if self._is_away else PRESET_NONE - - @property - def preset_modes(self): - """Return a list of available preset modes or PRESET_NONE if _away_temp is undefined.""" - return [PRESET_NONE, PRESET_AWAY] if self._away_temp else PRESET_NONE - async def async_set_hvac_mode(self, hvac_mode): """Set hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: @@ -373,8 +378,7 @@ async def async_set_hvac_mode(self, hvac_mode): async def async_set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return self._target_temp = temperature await self._async_control_heating(force=True) @@ -408,12 +412,24 @@ async def _async_sensor_changed(self, event): await self._async_control_heating() self.async_write_ha_state() + async def _check_switch_initial_state(self): + """Prevent the device from keep running if HVAC_MODE_OFF.""" + if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: + _LOGGER.warning( + "The climate mode is OFF, but the switch device is ON. Turning off device %s", + self.heater_entity_id, + ) + await self._async_heater_turn_off() + @callback def _async_switch_changed(self, event): """Handle heater switch state changes.""" new_state = event.data.get("new_state") + old_state = event.data.get("old_state") if new_state is None: return + if old_state is None: + self.hass.create_task(self._check_switch_initial_state()) self.async_write_ha_state() @callback @@ -433,7 +449,6 @@ async def _async_control_heating(self, time=None, force=False): if not self._active and None not in ( self._cur_temp, self._target_temp, - self._is_device_active, ): self._active = True _LOGGER.info( @@ -521,14 +536,22 @@ async def _async_heater_turn_off(self): async def async_set_preset_mode(self, preset_mode: str): """Set new preset mode.""" - if preset_mode == PRESET_AWAY and not self._is_away: - self._is_away = True - self._saved_target_temp = self._target_temp - self._target_temp = self._away_temp - await self._async_control_heating(force=True) - elif preset_mode == PRESET_NONE and self._is_away: - self._is_away = False + if preset_mode not in (self._attr_preset_modes or []): + raise ValueError( + f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" + ) + if preset_mode == self._attr_preset_mode: + # I don't think we need to call async_write_ha_state if we didn't change the state + return + if preset_mode == PRESET_NONE: + self._attr_preset_mode = PRESET_NONE self._target_temp = self._saved_target_temp await self._async_control_heating(force=True) + else: + if self._attr_preset_mode == PRESET_NONE: + self._saved_target_temp = self._target_temp + self._attr_preset_mode = preset_mode + self._target_temp = self._presets[preset_mode] + await self._async_control_heating(force=True) self.async_write_ha_state() diff --git a/homeassistant/components/generic_thermostat/services.yaml b/homeassistant/components/generic_thermostat/services.yaml index fedcd26825304..ef6745bd36f1f 100644 --- a/homeassistant/components/generic_thermostat/services.yaml +++ b/homeassistant/components/generic_thermostat/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all generic_thermostat entities. diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bf5fc03ded5fc..cad80e8d707ca 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -120,7 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) - for platform in ["climate", "water_heater", "sensor", "binary_sensor", "switch"]: + for platform in ("climate", "water_heater", "sensor", "binary_sensor", "switch"): hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) setup_service_functions(hass, broker) diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 0c96ec595b607..c179fe7a588b7 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -4,8 +4,8 @@ from datetime import timedelta from typing import Any -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -76,15 +76,15 @@ def icon(self) -> str: @property def device_class(self) -> str: """Return the device class of the sensor.""" - return DEVICE_CLASS_BATTERY + return SensorDeviceClass.BATTERY @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of the sensor.""" return PERCENTAGE @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" level = self._device.data["state"][self._state_attr] return level if level != 255 else 0 @@ -105,7 +105,7 @@ def __init__(self, broker, level) -> None: self._issues = [] @property - def state(self) -> str: + def native_value(self) -> str: """Return the number of issues.""" return len(self._issues) diff --git a/homeassistant/components/geniushub/services.yaml b/homeassistant/components/geniushub/services.yaml index fa46c1d4c0998..1edcf243cc0ad 100644 --- a/homeassistant/components/geniushub/services.yaml +++ b/homeassistant/components/geniushub/services.yaml @@ -2,39 +2,73 @@ # Describes the format for available services set_zone_mode: + name: Set zone mode description: >- Set the zone to an operating mode. fields: entity_id: + name: Entity description: The zone's entity_id. - example: climate.kitchen + required: true + selector: + entity: + integration: geniushub + domain: climate mode: + name: Mode description: "One of: off, timer or footprint." - example: timer + required: true + selector: + select: + options: + - 'off' + - 'timer' + - 'footprint' set_zone_override: + name: Set zone override description: >- - Override the zone's setpoint for a given duration. + Override the zone's set point for a given duration. fields: entity_id: + name: Entity description: The zone's entity_id. - example: climate.bathroom + required: true + selector: + entity: + integration: geniushub + domain: climate temperature: - description: The target temperature, to 0.1 C. - example: 19.2 + name: Temperature + description: The target temperature. + required: true + selector: + number: + min: 4 + max: 28 + step: 0.1 + unit_of_measurement: '°' duration: + name: Duration description: >- The duration of the override. Optional, default 1 hour, maximum 24 hours. example: '{"minutes": 135}' + selector: + object: set_switch_override: + name: Set switch override description: >- Override switch for a given duration. + target: + entity: + integration: geniushub + domain: switch fields: - entity_id: - description: The zone's entity_id. - example: switch.study duration: + name: Duration description: >- The duration of the override. Optional, default 1 hour, maximum 24 hours. example: '{"minutes": 135}' + selector: + object: diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 2666f3d365b1c..aebf64d21bd65 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -3,7 +3,7 @@ import voluptuous as vol -from homeassistant.components.switch import DEVICE_CLASS_OUTLET, SwitchEntity +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.typing import ConfigType @@ -55,7 +55,7 @@ class GeniusSwitch(GeniusZone, SwitchEntity): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_OUTLET + return SwitchDeviceClass.OUTLET @property def is_on(self) -> bool: diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 5d898ee99d501..aba5abff67c3a 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -2,7 +2,7 @@ "domain": "geo_json_events", "name": "GeoJSON", "documentation": "https://www.home-assistant.io/integrations/geo_json_events", - "requirements": ["geojson_client==0.4"], - "codeowners": [], + "requirements": ["geojson_client==0.6"], + "codeowners": ["@exxamalte"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 11294e73f6351..c32917cb5cd6d 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -5,7 +5,9 @@ import logging from typing import final +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -36,14 +38,16 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN].async_setup_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN].async_unload_entry(entry) + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) class GeolocationEvent(Entity): diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 3cdefccfeab0d..e57c7a9aec631 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -1,20 +1,25 @@ """Offer geolocation automation rules.""" +import logging + import voluptuous as vol -from homeassistant.components.geo_location import DOMAIN from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE from homeassistant.core import HassJob, callback from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.config_validation import entity_domain from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered +from . import DOMAIN + # mypy: allow-untyped-defs, no-check-untyped-defs +_LOGGER = logging.getLogger(__name__) + EVENT_ENTER = "enter" EVENT_LEAVE = "leave" DEFAULT_EVENT = EVENT_ENTER -TRIGGER_SCHEMA = vol.Schema( +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "geo_location", vol.Required(CONF_SOURCE): cv.string, @@ -33,7 +38,7 @@ def source_match(state, source): async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None + trigger_data = automation_info["trigger_data"] source = config.get(CONF_SOURCE).lower() zone_entity_id = config.get(CONF_ZONE) trigger_event = config.get(CONF_EVENT) @@ -48,7 +53,13 @@ def state_change_listener(event): if not source_match(from_state, source) and not source_match(to_state, source): return - zone_state = hass.states.get(zone_entity_id) + if (zone_state := hass.states.get(zone_entity_id)) is None: + _LOGGER.warning( + "Unable to execute automation %s: Zone %s not found", + automation_info["name"], + zone_entity_id, + ) + return from_match = ( condition.zone(hass, zone_state, from_state) if from_state else False @@ -67,6 +78,7 @@ def state_change_listener(event): job, { "trigger": { + **trigger_data, "platform": "geo_location", "source": source, "entity_id": event.data.get("entity_id"), @@ -75,7 +87,6 @@ def state_change_listener(event): "zone": zone_state, "event": trigger_event, "description": f"geo_location - {source}", - "id": trigger_id, } }, event.context, diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index e7ac2948237b1..6a470e1ddbdbb 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -2,7 +2,7 @@ "domain": "geo_rss_events", "name": "GeoRSS", "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", - "requirements": ["georss_generic_client==0.4"], + "requirements": ["georss_generic_client==0.6"], "codeowners": ["@exxamalte"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index df5f11850fdef..f579712160327 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -121,12 +121,12 @@ def name(self): return f"{self._service_name} {'Any' if self._category is None else self._category}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 1cbaea2373371..1191b72ba3f2b 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -1,16 +1,16 @@ """Support for Geofency.""" +from http import HTTPStatus + from aiohttp import web import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_NAME, CONF_WEBHOOK_ID, - HTTP_OK, - HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, + Platform, ) from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv @@ -19,7 +19,7 @@ from .const import DOMAIN -PLATFORMS = [DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER] CONF_MOBILE_BEACONS = "mobile_beacons" @@ -89,7 +89,9 @@ async def handle_webhook(hass, webhook_id, request): try: data = WEBHOOK_SCHEMA(dict(await request.post())) except vol.MultipleInvalid as error: - return web.Response(text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY) + return web.Response( + text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + ) if _is_mobile_beacon(data, hass.data[DOMAIN]["beacons"]): return _set_location(hass, data, None) @@ -129,7 +131,7 @@ def _set_location(hass, data, location_name): data, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 5a58e73d44a42..2904d1a1b1d8f 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -5,6 +5,7 @@ from homeassistant.core import callback from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE @@ -86,9 +87,9 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - return {"name": self._name, "identifiers": {(GF_DOMAIN, self._unique_id)}} + return DeviceInfo(identifiers={(GF_DOMAIN, self._unique_id)}, name=self._name) @property def source_type(self): @@ -105,9 +106,7 @@ async def async_added_to_hass(self): if self._attributes: return - state = await self.async_get_last_state() - - if state is None: + if (state := await self.async_get_last_state()) is None: self._gps = (None, None) return diff --git a/homeassistant/components/geofency/translations/bg.json b/homeassistant/components/geofency/translations/bg.json index de2e8af5d9708..916336f37a40b 100644 --- a/homeassistant/components/geofency/translations/bg.json +++ b/homeassistant/components/geofency/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, "create_entry": { "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 Geofency. \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." }, diff --git a/homeassistant/components/geofency/translations/he.json b/homeassistant/components/geofency/translations/he.json new file mode 100644 index 0000000000000..ebee9aee97649 --- /dev/null +++ b/homeassistant/components/geofency/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "webhook_not_internet_accessible": "\u05de\u05d5\u05e4\u05e2 \u05d4-Home Assistant \u05e9\u05dc\u05da \u05e6\u05e8\u05d9\u05da \u05dc\u05d4\u05d9\u05d5\u05ea \u05e0\u05d2\u05d9\u05e9 \u05de\u05d4\u05d0\u05d9\u05e0\u05d8\u05e8\u05e0\u05d8 \u05db\u05d3\u05d9 \u05dc\u05e7\u05d1\u05dc \u05d4\u05d5\u05d3\u05e2\u05d5\u05ea webhook." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/translations/hu.json b/homeassistant/components/geofency/translations/hu.json index 826b943e2f80a..1b3f17fe700cb 100644 --- a/homeassistant/components/geofency/translations/hu.json +++ b/homeassistant/components/geofency/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a Geofencyben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." + "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtano a webhook funkci\u00f3t a Geofencyben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1lja: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Geofency Webhookot?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Geofency Webhookot?", "title": "A Geofency Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/geofency/translations/it.json b/homeassistant/components/geofency/translations/it.json index 48f0456a2bca0..a72adf25c5017 100644 --- a/homeassistant/components/geofency/translations/it.json +++ b/homeassistant/components/geofency/translations/it.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "L'istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi webhook." }, "create_entry": { - "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in Geofency.\n\n Compila le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli." + "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in Geofency.\n\n Compila le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Metodo: POST \n\n Vedi [la documentazione]({docs_url}) per ulteriori dettagli." }, "step": { "user": { diff --git a/homeassistant/components/geofency/translations/ja.json b/homeassistant/components/geofency/translations/ja.json new file mode 100644 index 0000000000000..ba80a1979948a --- /dev/null +++ b/homeassistant/components/geofency/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001Geofency\u306ewebhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "Geofency Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "Geofency Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/translations/tr.json b/homeassistant/components/geofency/translations/tr.json index 84adcdf8225c4..4cd04c64d7b3e 100644 --- a/homeassistant/components/geofency/translations/tr.json +++ b/homeassistant/components/geofency/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in Geofency'de webhook \u00f6zelli\u011fini ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Geofency Webhook'u kurmak istedi\u011finizden emin misiniz?", + "title": "Geofency Webhook'u kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index 43818b55f6f1d..f3303d551ce9c 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -1,9 +1,11 @@ """Define constants for the GeoNet NZ Quakes integration.""" from datetime import timedelta +from homeassistant.const import Platform + DOMAIN = "geonetnz_quakes" -PLATFORMS = ("sensor", "geo_location") +PLATFORMS = [Platform.SENSOR, Platform.GEO_LOCATION] CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" CONF_MMI = "mmi" diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 64a78c02d25de..5668cd6cb3f06 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -3,7 +3,7 @@ "name": "GeoNet NZ Quakes", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes", - "requirements": ["aio_geojson_geonetnz_quakes==0.12"], + "requirements": ["aio_geojson_geonetnz_quakes==0.13"], "codeowners": ["@exxamalte"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 94c7965663af3..605f56b1272cd 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -106,7 +106,7 @@ def _update_from_status_info(self, status_info): self._removed = status_info.removed @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._total @@ -126,7 +126,7 @@ def icon(self): return DEFAULT_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEFAULT_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/geonetnz_quakes/translations/cs.json b/homeassistant/components/geonetnz_quakes/translations/cs.json index 0ddca983798fe..3613280d3e506 100644 --- a/homeassistant/components/geonetnz_quakes/translations/cs.json +++ b/homeassistant/components/geonetnz_quakes/translations/cs.json @@ -6,8 +6,10 @@ "step": { "user": { "data": { + "mmi": "MMI", "radius": "Polom\u011br" - } + }, + "title": "Vypl\u0148te \u00fadaje filtru." } } } diff --git a/homeassistant/components/geonetnz_quakes/translations/de.json b/homeassistant/components/geonetnz_quakes/translations/de.json index 583712c6c4ea4..2bfc3f2dbbd4c 100644 --- a/homeassistant/components/geonetnz_quakes/translations/de.json +++ b/homeassistant/components/geonetnz_quakes/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Der Standort ist bereits konfiguriert." + "already_configured": "Der Dienst ist bereits konfiguriert" }, "step": { "user": { diff --git a/homeassistant/components/geonetnz_quakes/translations/fr.json b/homeassistant/components/geonetnz_quakes/translations/fr.json index e448f9993bf2e..aeb3763ce46cc 100644 --- a/homeassistant/components/geonetnz_quakes/translations/fr.json +++ b/homeassistant/components/geonetnz_quakes/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/geonetnz_quakes/translations/he.json b/homeassistant/components/geonetnz_quakes/translations/he.json new file mode 100644 index 0000000000000..7718605a5882b --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, + "step": { + "user": { + "data": { + "radius": "\u05e8\u05d3\u05d9\u05d5\u05e1" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/hu.json b/homeassistant/components/geonetnz_quakes/translations/hu.json index 21a38c18e2834..c67444ec274e0 100644 --- a/homeassistant/components/geonetnz_quakes/translations/hu.json +++ b/homeassistant/components/geonetnz_quakes/translations/hu.json @@ -6,9 +6,10 @@ "step": { "user": { "data": { + "mmi": "MMI", "radius": "Sug\u00e1r" }, - "title": "T\u00f6ltsd ki a sz\u0171r\u0151 adatait." + "title": "T\u00f6ltse ki a sz\u0171r\u0151 adatait." } } } diff --git a/homeassistant/components/geonetnz_quakes/translations/ja.json b/homeassistant/components/geonetnz_quakes/translations/ja.json new file mode 100644 index 0000000000000..8948e9c4e4a60 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\u534a\u5f84" + }, + "title": "\u30d5\u30a3\u30eb\u30bf\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/tr.json b/homeassistant/components/geonetnz_quakes/translations/tr.json index 717f6d72b94e5..a7d80261b8b71 100644 --- a/homeassistant/components/geonetnz_quakes/translations/tr.json +++ b/homeassistant/components/geonetnz_quakes/translations/tr.json @@ -2,6 +2,15 @@ "config": { "abort": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Yar\u0131\u00e7ap" + }, + "title": "Filtre ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 doldurun." + } } } } \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py index b70d224a685b5..3a23084aa1f1e 100644 --- a/homeassistant/components/geonetnz_volcano/const.py +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -1,6 +1,8 @@ """Define constants for the GeoNet NZ Volcano integration.""" from datetime import timedelta +from homeassistant.const import Platform + DOMAIN = "geonetnz_volcano" FEED = "feed" @@ -15,4 +17,4 @@ DEFAULT_RADIUS = 50.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index ed0ebccf6201d..dbd793c49b336 100644 --- a/homeassistant/components/geonetnz_volcano/manifest.json +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -3,7 +3,7 @@ "name": "GeoNet NZ Volcano", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geonetnz_volcano", - "requirements": ["aio_geojson_geonetnz_volcano==0.5"], + "requirements": ["aio_geojson_geonetnz_volcano==0.6"], "codeowners": ["@exxamalte"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index c0cc68014378a..fc9f0f30b2cd0 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -130,7 +130,7 @@ def _update_from_feed(self, feed_entry, last_update, last_update_successful): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._alert_level @@ -145,7 +145,7 @@ def name(self) -> str | None: return f"Volcano {self._title}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "alert level" diff --git a/homeassistant/components/geonetnz_volcano/translations/bg.json b/homeassistant/components/geonetnz_volcano/translations/bg.json index 042696219fcdd..17f38fa0971a5 100644 --- a/homeassistant/components/geonetnz_volcano/translations/bg.json +++ b/homeassistant/components/geonetnz_volcano/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/geonetnz_volcano/translations/he.json b/homeassistant/components/geonetnz_volcano/translations/he.json new file mode 100644 index 0000000000000..59a1fbe0eed28 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/ja.json b/homeassistant/components/geonetnz_volcano/translations/ja.json new file mode 100644 index 0000000000000..c674e4e43dd80 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f84" + }, + "title": "\u30d5\u30a3\u30eb\u30bf\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/tr.json b/homeassistant/components/geonetnz_volcano/translations/tr.json index 980be33356859..5cb07962f5bab 100644 --- a/homeassistant/components/geonetnz_volcano/translations/tr.json +++ b/homeassistant/components/geonetnz_volcano/translations/tr.json @@ -7,7 +7,8 @@ "user": { "data": { "radius": "Yar\u0131\u00e7ap" - } + }, + "title": "Filtre ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 doldurun." } } } diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 9c4b76d8009f6..8348fa6567b5c 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -1,24 +1,46 @@ """The GIOS component.""" +from __future__ import annotations + import logging +from typing import Any, Dict, cast +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["air_quality"] +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up GIOS as config entry.""" - station_id = entry.data[CONF_STATION_ID] - _LOGGER.debug("Using station_id: %s", station_id) + station_id: int = entry.data[CONF_STATION_ID] + _LOGGER.debug("Using station_id: %d", station_id) + + # We used to use int as config_entry unique_id, convert this to str. + if isinstance(entry.unique_id, int): # type: ignore[unreachable] + hass.config_entries.async_update_entry(entry, unique_id=str(station_id)) # type: ignore[unreachable] + + # We used to use int in device_entry identifiers, convert this to str. + device_registry = await async_get_registry(hass) + old_ids = (DOMAIN, station_id) + device_entry = device_registry.async_get_device({old_ids}) # type: ignore[arg-type] + if device_entry and entry.entry_id in device_entry.config_entries: + new_ids = (DOMAIN, str(station_id)) + device_registry.async_update_device(device_entry.id, new_identifiers={new_ids}) websession = async_get_clientsession(hass) @@ -30,29 +52,44 @@ async def async_setup_entry(hass, entry): hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # Remove air_quality entities from registry if they exist + ent_reg = entity_registry.async_get(hass) + unique_id = str(coordinator.gios.station_id) + if entity_id := ent_reg.async_get_entity_id( + AIR_QUALITY_PLATFORM, DOMAIN, unique_id + ): + _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) + ent_reg.async_remove(entity_id) + return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok class GiosDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold GIOS data.""" - def __init__(self, hass, session, station_id): + def __init__( + self, hass: HomeAssistant, session: ClientSession, station_id: int + ) -> None: """Class to manage fetching GIOS data API.""" self.gios = Gios(station_id, session) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: - with timeout(API_TIMEOUT): - return await self.gios.async_update() + async with timeout(API_TIMEOUT): + return cast(Dict[str, Any], await self.gios.async_update()) except ( ApiError, NoStationError, diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py deleted file mode 100644 index 9e4df19e7ad73..0000000000000 --- a/homeassistant/components/gios/air_quality.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Support for the GIOS service.""" -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.const import CONF_NAME -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ( - API_AQI, - API_CO, - API_NO2, - API_O3, - API_PM10, - API_PM25, - API_SO2, - ATTR_STATION, - ATTRIBUTION, - DEFAULT_NAME, - DOMAIN, - ICONS_MAP, - MANUFACTURER, - SENSOR_MAP, -) - -PARALLEL_UPDATES = 1 - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Add a GIOS entities from a config_entry.""" - name = config_entry.data[CONF_NAME] - - coordinator = hass.data[DOMAIN][config_entry.entry_id] - - async_add_entities([GiosAirQuality(coordinator, name)], False) - - -def round_state(func): - """Round state.""" - - def _decorator(self): - res = func(self) - if isinstance(res, float): - return round(res) - return res - - return _decorator - - -class GiosAirQuality(CoordinatorEntity, AirQualityEntity): - """Define an GIOS sensor.""" - - def __init__(self, coordinator, name): - """Initialize.""" - super().__init__(coordinator) - self._name = name - self._attrs = {} - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def icon(self): - """Return the icon.""" - if self.air_quality_index in ICONS_MAP: - return ICONS_MAP[self.air_quality_index] - return "mdi:blur" - - @property - def air_quality_index(self): - """Return the air quality index.""" - return self._get_sensor_value(API_AQI) - - @property - @round_state - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return self._get_sensor_value(API_PM25) - - @property - @round_state - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self._get_sensor_value(API_PM10) - - @property - @round_state - def ozone(self): - """Return the O3 (ozone) level.""" - return self._get_sensor_value(API_O3) - - @property - @round_state - def carbon_monoxide(self): - """Return the CO (carbon monoxide) level.""" - return self._get_sensor_value(API_CO) - - @property - @round_state - def sulphur_dioxide(self): - """Return the SO2 (sulphur dioxide) level.""" - return self._get_sensor_value(API_SO2) - - @property - @round_state - def nitrogen_dioxide(self): - """Return the NO2 (nitrogen dioxide) level.""" - return self._get_sensor_value(API_NO2) - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self.coordinator.gios.station_id - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.coordinator.gios.station_id)}, - "name": DEFAULT_NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - # Different measuring stations have different sets of sensors. We don't know - # what data we will get. - for sensor in SENSOR_MAP: - if sensor in self.coordinator.data: - self._attrs[f"{SENSOR_MAP[sensor]}_index"] = self.coordinator.data[ - sensor - ]["index"] - self._attrs[ATTR_STATION] = self.coordinator.gios.station_name - return self._attrs - - def _get_sensor_value(self, sensor): - """Return value of specified sensor.""" - if sensor in self.coordinator.data: - return self.coordinator.data[sensor]["value"] - return None diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index b351fafc0c1ba..0fa5052e1291b 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -1,5 +1,8 @@ """Adds config flow for GIOS.""" +from __future__ import annotations + import asyncio +from typing import Any from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout @@ -8,16 +11,10 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, CONF_STATION_ID, DEFAULT_NAME, DOMAIN - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_STATION_ID): int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - } -) +from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -25,25 +22,28 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: try: await self.async_set_unique_id( - user_input[CONF_STATION_ID], raise_on_progress=False + str(user_input[CONF_STATION_ID]), raise_on_progress=False ) self._abort_if_unique_id_configured() websession = async_get_clientsession(self.hass) - with timeout(API_TIMEOUT): + async with timeout(API_TIMEOUT): gios = Gios(user_input[CONF_STATION_ID], websession) await gios.async_update() + assert gios.station_name is not None return self.async_create_entry( - title=user_input[CONF_STATION_ID], + title=gios.station_name, data=user_input, ) except (ApiError, ClientConnectorError, asyncio.TimeoutError): @@ -54,5 +54,14 @@ async def async_step_user(self, user_input=None): errors[CONF_STATION_ID] = "invalid_sensors_data" return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_STATION_ID): int, + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + } + ), + errors=errors, ) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 4d3d7e139ce51..858a756e3e32c 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -1,54 +1,93 @@ """Constants for GIOS integration.""" +from __future__ import annotations + from datetime import timedelta +from typing import Final -from homeassistant.components.air_quality import ( - ATTR_CO, - ATTR_NO2, - ATTR_OZONE, - ATTR_PM_2_5, - ATTR_PM_10, - ATTR_SO2, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + +from .model import GiosSensorEntityDescription -ATTRIBUTION = "Data provided by GIOŚ" +ATTRIBUTION: Final = "Data provided by GIOŚ" -ATTR_STATION = "station" -CONF_STATION_ID = "station_id" -DEFAULT_NAME = "GIOŚ" +CONF_STATION_ID: Final = "station_id" +DEFAULT_NAME: Final = "GIOŚ" # Term of service GIOŚ allow downloading data no more than twice an hour. -SCAN_INTERVAL = timedelta(minutes=30) -DOMAIN = "gios" -MANUFACTURER = "Główny Inspektorat Ochrony Środowiska" - -API_AQI = "aqi" -API_CO = "co" -API_NO2 = "no2" -API_O3 = "o3" -API_PM10 = "pm10" -API_PM25 = "pm2.5" -API_SO2 = "so2" - -API_TIMEOUT = 30 - -AQI_GOOD = "dobry" -AQI_MODERATE = "umiarkowany" -AQI_POOR = "dostateczny" -AQI_VERY_GOOD = "bardzo dobry" -AQI_VERY_POOR = "zły" - -ICONS_MAP = { - AQI_VERY_GOOD: "mdi:emoticon-excited", - AQI_GOOD: "mdi:emoticon-happy", - AQI_MODERATE: "mdi:emoticon-neutral", - AQI_POOR: "mdi:emoticon-sad", - AQI_VERY_POOR: "mdi:emoticon-dead", -} - -SENSOR_MAP = { - API_CO: ATTR_CO, - API_NO2: ATTR_NO2, - API_O3: ATTR_OZONE, - API_PM10: ATTR_PM_10, - API_PM25: ATTR_PM_2_5, - API_SO2: ATTR_SO2, -} +SCAN_INTERVAL: Final = timedelta(minutes=30) +DOMAIN: Final = "gios" +MANUFACTURER: Final = "Główny Inspektorat Ochrony Środowiska" + +URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" + +API_TIMEOUT: Final = 30 + +ATTR_INDEX: Final = "index" +ATTR_STATION: Final = "station" + +ATTR_C6H6: Final = "c6h6" +ATTR_CO: Final = "co" +ATTR_NO2: Final = "no2" +ATTR_O3: Final = "o3" +ATTR_PM10: Final = "pm10" +ATTR_PM25: Final = "pm25" +ATTR_SO2: Final = "so2" +ATTR_AQI: Final = "aqi" + +SENSOR_TYPES: Final[tuple[GiosSensorEntityDescription, ...]] = ( + GiosSensorEntityDescription( + key=ATTR_AQI, + name="AQI", + device_class=SensorDeviceClass.AQI, + value=None, + ), + GiosSensorEntityDescription( + key=ATTR_C6H6, + name="C6H6", + icon="mdi:molecule", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_CO, + name="CO", + device_class=SensorDeviceClass.CO, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_NO2, + name="NO2", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_O3, + name="O3", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_PM10, + name="PM10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_PM25, + name="PM2.5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_SO2, + name="SO2", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), +) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 3dfb2a168dbc1..0e7227797d2b2 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -3,7 +3,7 @@ "name": "GIO\u015a", "documentation": "https://www.home-assistant.io/integrations/gios", "codeowners": ["@bieniu"], - "requirements": ["gios==1.0.1"], + "requirements": ["gios==2.1.0"], "config_flow": true, "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/gios/model.py b/homeassistant/components/gios/model.py new file mode 100644 index 0000000000000..0f5d992590b13 --- /dev/null +++ b/homeassistant/components/gios/model.py @@ -0,0 +1,14 @@ +"""Type definitions for GIOS integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class GiosSensorEntityDescription(SensorEntityDescription): + """Class describing GIOS sensor entities.""" + + value: Callable | None = round diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py new file mode 100644 index 0000000000000..ff589d34791ce --- /dev/null +++ b/homeassistant/components/gios/sensor.py @@ -0,0 +1,136 @@ +"""Support for the GIOS service.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from homeassistant.components.sensor import DOMAIN as PLATFORM, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_NAME, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GiosDataUpdateCoordinator +from .const import ( + ATTR_AQI, + ATTR_INDEX, + ATTR_PM25, + ATTR_STATION, + ATTRIBUTION, + DEFAULT_NAME, + DOMAIN, + MANUFACTURER, + SENSOR_TYPES, + URL, +) +from .model import GiosSensorEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add a GIOS entities from a config_entry.""" + name = entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][entry.entry_id] + + # Due to the change of the attribute name of one sensor, it is necessary to migrate + # the unique_id to the new name. + entity_registry = await async_get_registry(hass) + old_unique_id = f"{coordinator.gios.station_id}-pm2.5" + if entity_id := entity_registry.async_get_entity_id( + PLATFORM, DOMAIN, old_unique_id + ): + new_unique_id = f"{coordinator.gios.station_id}-{ATTR_PM25}" + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + sensors: list[GiosSensor | GiosAqiSensor] = [] + + for description in SENSOR_TYPES: + if getattr(coordinator.data, description.key) is None: + continue + if description.key == ATTR_AQI: + sensors.append(GiosAqiSensor(name, coordinator, description)) + else: + sensors.append(GiosSensor(name, coordinator, description)) + async_add_entities(sensors) + + +class GiosSensor(CoordinatorEntity, SensorEntity): + """Define an GIOS sensor.""" + + coordinator: GiosDataUpdateCoordinator + entity_description: GiosSensorEntityDescription + + def __init__( + self, + name: str, + coordinator: GiosDataUpdateCoordinator, + description: GiosSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(coordinator.gios.station_id))}, + manufacturer=MANUFACTURER, + name=DEFAULT_NAME, + configuration_url=URL.format(station_id=coordinator.gios.station_id), + ) + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" + self._attrs: dict[str, Any] = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_STATION: self.coordinator.gios.station_name, + } + self.entity_description = description + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + self._attrs[ATTR_NAME] = getattr( + self.coordinator.data, self.entity_description.key + ).name + self._attrs[ATTR_INDEX] = getattr( + self.coordinator.data, self.entity_description.key + ).index + return self._attrs + + @property + def native_value(self) -> StateType: + """Return the state.""" + state = getattr(self.coordinator.data, self.entity_description.key).value + assert self.entity_description.value is not None + return cast(StateType, self.entity_description.value(state)) + + +class GiosAqiSensor(GiosSensor): + """Define an GIOS AQI sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state.""" + return cast( + StateType, getattr(self.coordinator.data, self.entity_description.key).value + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + available = super().available + return available and bool( + getattr(self.coordinator.data, self.entity_description.key) + ) diff --git a/homeassistant/components/gios/system_health.py b/homeassistant/components/gios/system_health.py index 391a8c1affe45..589dc428bcba2 100644 --- a/homeassistant/components/gios/system_health.py +++ b/homeassistant/components/gios/system_health.py @@ -1,8 +1,12 @@ """Provide info to system health.""" +from __future__ import annotations + +from typing import Any, Final + from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback -API_ENDPOINT = "http://api.gios.gov.pl/" +API_ENDPOINT: Final = "http://api.gios.gov.pl/" @callback @@ -13,7 +17,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" return { "can_reach_server": system_health.async_check_can_reach_url(hass, API_ENDPOINT) diff --git a/homeassistant/components/gios/translations/bg.json b/homeassistant/components/gios/translations/bg.json new file mode 100644 index 0000000000000..85c36ea1383d6 --- /dev/null +++ b/homeassistant/components/gios/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/de.json b/homeassistant/components/gios/translations/de.json index e1351278f381a..995481876018f 100644 --- a/homeassistant/components/gios/translations/de.json +++ b/homeassistant/components/gios/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "GIO\u015a integration f\u00fcr diese Messstation ist bereits konfiguriert. " + "already_configured": "Standort ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/gios/translations/fr.json b/homeassistant/components/gios/translations/fr.json index 2b02b5cfea086..af107914c06f3 100644 --- a/homeassistant/components/gios/translations/fr.json +++ b/homeassistant/components/gios/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration GIO\u015a pour cette station de mesure est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter au serveur GIOS", + "cannot_connect": "\u00c9chec de connexion", "invalid_sensors_data": "Donn\u00e9es des capteurs non valides pour cette station de mesure.", "wrong_station_id": "L'identifiant de la station de mesure n'est pas correct." }, "step": { "user": { "data": { - "name": "Nom de l'int\u00e9gration", + "name": "Nom", "station_id": "Identifiant de la station de mesure" }, "description": "Mettre en place l'int\u00e9gration de la qualit\u00e9 de l'air GIO\u015a (Inspection g\u00e9n\u00e9rale polonaise de la protection de l'environnement). Si vous avez besoin d'aide pour la configuration, regardez ici: https://www.home-assistant.io/integrations/gios", diff --git a/homeassistant/components/gios/translations/he.json b/homeassistant/components/gios/translations/he.json new file mode 100644 index 0000000000000..5bae816fc44d1 --- /dev/null +++ b/homeassistant/components/gios/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/hu.json b/homeassistant/components/gios/translations/hu.json index b35904e9d7623..9454aceb13dec 100644 --- a/homeassistant/components/gios/translations/hu.json +++ b/homeassistant/components/gios/translations/hu.json @@ -18,5 +18,10 @@ "title": "GIO\u015a (Lengyel K\u00f6rnyezetv\u00e9delmi F\u0151fel\u00fcgyel\u0151s\u00e9g)" } } + }, + "system_health": { + "info": { + "can_reach_server": "\u00c9rje el a GIO\u015a szervert" + } } } \ No newline at end of file diff --git a/homeassistant/components/gios/translations/it.json b/homeassistant/components/gios/translations/it.json index 5d1e99d17f400..cb1f1cea6b0b3 100644 --- a/homeassistant/components/gios/translations/it.json +++ b/homeassistant/components/gios/translations/it.json @@ -14,7 +14,7 @@ "name": "Nome", "station_id": "ID della stazione di misura" }, - "description": "Impostare l'integrazione della qualit\u00e0 dell'aria GIO\u015a (Ispettorato capo polacco di protezione ambientale). Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/gios", + "description": "Imposta l'integrazione della qualit\u00e0 dell'aria GIO\u015a (Ispettorato capo polacco di protezione ambientale). Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/gios", "title": "GIO\u015a (Ispettorato capo polacco di protezione ambientale)" } } diff --git a/homeassistant/components/gios/translations/ja.json b/homeassistant/components/gios/translations/ja.json new file mode 100644 index 0000000000000..ac05abbf9237d --- /dev/null +++ b/homeassistant/components/gios/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_sensors_data": "\u3053\u306e\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306e\u30bb\u30f3\u30b5\u30fc\u306e\u30c7\u30fc\u30bf\u304c\u7121\u52b9\u3067\u3059\u3002", + "wrong_station_id": "\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306eID\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "data": { + "name": "\u540d\u524d", + "station_id": "\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306eID" + }, + "description": "GIO\u015a(Polish Chief Inspectorate Of Environmental Protection)\u306e\u5927\u6c17\u8cea\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u3064\u3044\u3066\u30d8\u30eb\u30d7\u304c\u5fc5\u8981\u306a\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/gios \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "GIO\u015a\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u30a2\u30af\u30bb\u30b9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/tr.json b/homeassistant/components/gios/translations/tr.json index 590aec1894cc3..c0444ed99edf7 100644 --- a/homeassistant/components/gios/translations/tr.json +++ b/homeassistant/components/gios/translations/tr.json @@ -4,7 +4,24 @@ "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_sensors_data": "Bu \u00f6l\u00e7\u00fcm istasyonu i\u00e7in ge\u00e7ersiz sens\u00f6r verileri.", + "wrong_station_id": "\u00d6l\u00e7\u00fcm istasyonunun kimli\u011fi do\u011fru de\u011fil." + }, + "step": { + "user": { + "data": { + "name": "Ad", + "station_id": "\u00d6l\u00e7\u00fcm istasyonunun kimli\u011fi" + }, + "description": "GIO\u015a (Polonya \u00c7evre Koruma Ba\u015f M\u00fcfetti\u015fli\u011fi) hava kalitesi entegrasyonunu kurun. Yap\u0131land\u0131rmayla ilgili yard\u0131ma ihtiyac\u0131n\u0131z varsa buraya bak\u0131n: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polonya \u00c7evre Koruma Ba\u015f M\u00fcfetti\u015fli\u011fi)" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "GIO\u015a sunucusuna ula\u015f\u0131n" } } } \ No newline at end of file diff --git a/homeassistant/components/gios/translations/zh-Hant.json b/homeassistant/components/gios/translations/zh-Hant.json index d72bc9bc01573..98a62385ee1a6 100644 --- a/homeassistant/components/gios/translations/zh-Hant.json +++ b/homeassistant/components/gios/translations/zh-Hant.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_sensors_data": "\u6b64\u76e3\u6e2c\u7ad9\u50b3\u611f\u5668\u8cc7\u6599\u7121\u6548\u3002", + "invalid_sensors_data": "\u6b64\u76e3\u6e2c\u7ad9\u611f\u6e2c\u5668\u8cc7\u6599\u7121\u6548\u3002", "wrong_station_id": "\u76e3\u6e2c\u7ad9 ID \u4e0d\u6b63\u78ba\u3002" }, "step": { diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index d4405196b7ac7..b6b575ef7ce8f 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -2,7 +2,12 @@ "domain": "github", "name": "GitHub", "documentation": "https://www.home-assistant.io/integrations/github", - "requirements": ["PyGithub==1.43.8"], - "codeowners": [], + "requirements": [ + "aiogithubapi==21.11.0" + ], + "codeowners": [ + "@timmo001", + "@ludeeus" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index c7812fa621d6b..53c28fcdaae99 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -1,8 +1,11 @@ -"""Support for GitHub.""" +"""Sensor platform for the GitHub integratiom.""" +from __future__ import annotations + +import asyncio from datetime import timedelta import logging -import github +from aiogithubapi import GitHubAPI, GitHubException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -13,6 +16,7 @@ CONF_PATH, CONF_URL, ) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -52,34 +56,29 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the GitHub sensor platform.""" sensors = [] + session = async_get_clientsession(hass) for repository in config[CONF_REPOS]: data = GitHubData( repository=repository, - access_token=config.get(CONF_ACCESS_TOKEN), + access_token=config[CONF_ACCESS_TOKEN], + session=session, server_url=config.get(CONF_URL), ) - if data.setup_error is True: - _LOGGER.error( - "Error setting up GitHub platform. %s", - "Check previous errors for details", - ) - else: - sensors.append(GitHubSensor(data)) - add_entities(sensors, True) + sensors.append(GitHubSensor(data)) + async_add_entities(sensors, True) class GitHubSensor(SensorEntity): """Representation of a GitHub sensor.""" + _attr_icon = "mdi:github" + def __init__(self, github_data): """Initialize the GitHub sensor.""" - self._unique_id = github_data.repository_path - self._name = None - self._state = None - self._available = False + self._attr_unique_id = github_data.repository_path self._repository_path = None self._latest_commit_message = None self._latest_commit_sha = None @@ -97,32 +96,47 @@ def __init__(self, github_data): self._views_unique = None self._github_data = github_data - @property - def name(self): - """Return the name of the sensor.""" - return self._name + async def async_update(self): + """Collect updated data from GitHub API.""" + await self._github_data.async_update() + self._attr_available = self._github_data.available + if not self.available: + return - @property - def unique_id(self): - """Return unique ID for the sensor.""" - return self._unique_id + self._attr_name = self._github_data.name + self._attr_native_value = self._github_data.last_commit.sha[0:7] - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._latest_commit_message = self._github_data.last_commit.commit.message + self._latest_commit_sha = self._github_data.last_commit.sha + self._stargazers = self._github_data.repository_response.data.stargazers_count + self._forks = self._github_data.repository_response.data.forks_count - @property - def available(self): - """Return True if entity is available.""" - return self._available + self._pull_request_count = len(self._github_data.pulls_response.data) + self._open_issue_count = ( + self._github_data.repository_response.data.open_issues_count or 0 + ) - self._pull_request_count - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attrs = { - ATTR_PATH: self._repository_path, - ATTR_NAME: self._name, + if self._github_data.last_release: + self._latest_release_tag = self._github_data.last_release.tag_name + self._latest_release_url = self._github_data.last_release.html_url + + if self._github_data.last_issue: + self._latest_open_issue_url = self._github_data.last_issue.html_url + + if self._github_data.last_pull_request: + self._latest_open_pr_url = self._github_data.last_pull_request.html_url + + if self._github_data.clones_response: + self._clones = self._github_data.clones_response.data.count + self._clones_unique = self._github_data.clones_response.data.uniques + + if self._github_data.views_response: + self._views = self._github_data.views_response.data.count + self._views_unique = self._github_data.views_response.data.uniques + + self._attr_extra_state_attributes = { + ATTR_PATH: self._github_data.repository_path, + ATTR_NAME: self.name, ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, ATTR_LATEST_RELEASE_URL: self._latest_release_url, @@ -134,138 +148,138 @@ def extra_state_attributes(self): ATTR_FORKS: self._forks, } if self._latest_release_tag is not None: - attrs[ATTR_LATEST_RELEASE_TAG] = self._latest_release_tag + self._attr_extra_state_attributes[ + ATTR_LATEST_RELEASE_TAG + ] = self._latest_release_tag if self._clones is not None: - attrs[ATTR_CLONES] = self._clones + self._attr_extra_state_attributes[ATTR_CLONES] = self._clones if self._clones_unique is not None: - attrs[ATTR_CLONES_UNIQUE] = self._clones_unique + self._attr_extra_state_attributes[ATTR_CLONES_UNIQUE] = self._clones_unique if self._views is not None: - attrs[ATTR_VIEWS] = self._views + self._attr_extra_state_attributes[ATTR_VIEWS] = self._views if self._views_unique is not None: - attrs[ATTR_VIEWS_UNIQUE] = self._views_unique - return attrs - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:github" - - def update(self): - """Collect updated data from GitHub API.""" - self._github_data.update() - - self._name = self._github_data.name - self._repository_path = self._github_data.repository_path - self._available = self._github_data.available - self._latest_commit_message = self._github_data.latest_commit_message - self._latest_commit_sha = self._github_data.latest_commit_sha - if self._github_data.latest_release_url is not None: - self._latest_release_tag = self._github_data.latest_release_url.split( - "tag/" - )[1] - else: - self._latest_release_tag = None - self._latest_release_url = self._github_data.latest_release_url - self._state = self._github_data.latest_commit_sha[0:7] - self._open_issue_count = self._github_data.open_issue_count - self._latest_open_issue_url = self._github_data.latest_open_issue_url - self._pull_request_count = self._github_data.pull_request_count - self._latest_open_pr_url = self._github_data.latest_open_pr_url - self._stargazers = self._github_data.stargazers - self._forks = self._github_data.forks - self._clones = self._github_data.clones - self._clones_unique = self._github_data.clones_unique - self._views = self._github_data.views - self._views_unique = self._github_data.views_unique + self._attr_extra_state_attributes[ATTR_VIEWS_UNIQUE] = self._views_unique class GitHubData: """GitHub Data object.""" - def __init__(self, repository, access_token=None, server_url=None): + def __init__(self, repository, access_token, session, server_url=None): """Set up GitHub.""" - self._github = github + self._repository = repository + self.repository_path = repository[CONF_PATH] + self._github = GitHubAPI( + token=access_token, session=session, **{"base_url": server_url} + ) - self.setup_error = False + self.available = False + self.repository_response = None + self.commit_response = None + self.issues_response = None + self.pulls_response = None + self.releases_response = None + self.views_response = None + self.clones_response = None + @property + def name(self): + """Return the name of the sensor.""" + return self._repository.get(CONF_NAME, self.repository_response.data.name) + + @property + def last_commit(self): + """Return the last issue.""" + return self.commit_response.data[0] if self.commit_response.data else None + + @property + def last_issue(self): + """Return the last issue.""" + return self.issues_response.data[0] if self.issues_response.data else None + + @property + def last_pull_request(self): + """Return the last pull request.""" + return self.pulls_response.data[0] if self.pulls_response.data else None + + @property + def last_release(self): + """Return the last release.""" + return self.releases_response.data[0] if self.releases_response.data else None + + async def async_update(self): + """Update GitHub data.""" try: - if server_url is not None: - server_url += "/api/v3" - self._github_obj = github.Github(access_token, base_url=server_url) - else: - self._github_obj = github.Github(access_token) + await asyncio.gather( + self._update_repository(), + self._update_commit(), + self._update_issues(), + self._update_pulls(), + self._update_releases(), + ) - self.repository_path = repository[CONF_PATH] + if self.repository_response.data.permissions.push: + await asyncio.gather( + self._update_clones(), + self._update_views(), + ) - repo = self._github_obj.get_repo(self.repository_path) - except self._github.GithubException as err: + self.available = True + except GitHubException as err: _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) - self.setup_error = True - return + self.available = False - self.name = repository.get(CONF_NAME, repo.name) - self.available = False - self.latest_commit_message = None - self.latest_commit_sha = None - self.latest_release_url = None - self.open_issue_count = None - self.latest_open_issue_url = None - self.pull_request_count = None - self.latest_open_pr_url = None - self.stargazers = None - self.forks = None - self.clones = None - self.clones_unique = None - self.views = None - self.views_unique = None - - def update(self): - """Update GitHub Sensor.""" - try: - repo = self._github_obj.get_repo(self.repository_path) - - self.stargazers = repo.stargazers_count - self.forks = repo.forks_count - - open_pull_requests = repo.get_pulls(state="open", sort="created") - if open_pull_requests is not None: - self.pull_request_count = open_pull_requests.totalCount - if open_pull_requests.totalCount > 0: - self.latest_open_pr_url = open_pull_requests[0].html_url - - open_issues = repo.get_issues(state="open", sort="created") - if open_issues is not None: - if self.pull_request_count is None: - self.open_issue_count = open_issues.totalCount - else: - # pull requests are treated as issues too so we need to reduce the received count - self.open_issue_count = ( - open_issues.totalCount - self.pull_request_count - ) + async def _update_repository(self): + """Update repository data.""" + self.repository_response = await self._github.repos.get(self.repository_path) - if open_issues.totalCount > 0: - self.latest_open_issue_url = open_issues[0].html_url + async def _update_commit(self): + """Update commit data.""" + self.commit_response = await self._github.repos.list_commits( + self.repository_path, **{"params": {"per_page": 1}} + ) - latest_commit = repo.get_commits()[0] - self.latest_commit_sha = latest_commit.sha - self.latest_commit_message = latest_commit.commit.message + async def _update_issues(self): + """Update issues data.""" + self.issues_response = await self._github.repos.issues.list( + self.repository_path + ) - releases = repo.get_releases() - if releases and releases.totalCount > 0: - self.latest_release_url = releases[0].html_url + async def _update_releases(self): + """Update releases data.""" + self.releases_response = await self._github.repos.releases.list( + self.repository_path + ) - if repo.permissions.push: - clones = repo.get_clones_traffic() - if clones is not None: - self.clones = clones.get("count") - self.clones_unique = clones.get("uniques") + async def _update_clones(self): + """Update clones data.""" + self.clones_response = await self._github.repos.traffic.clones( + self.repository_path + ) - views = repo.get_views_traffic() - if views is not None: - self.views = views.get("count") - self.views_unique = views.get("uniques") + async def _update_views(self): + """Update views data.""" + self.views_response = await self._github.repos.traffic.views( + self.repository_path + ) - self.available = True - except self._github.GithubException as err: - _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) - self.available = False + async def _update_pulls(self): + """Update pulls data.""" + response = await self._github.repos.pulls.list( + self.repository_path, **{"params": {"per_page": 100}} + ) + if not response.is_last_page: + results = await asyncio.gather( + *( + self._github.repos.pulls.list( + self.repository_path, + **{"params": {"per_page": 100, "page": page_number}}, + ) + for page_number in range( + response.next_page_number, response.last_page_number + 1 + ) + ) + ) + for result in results: + response.data.extend(result.data) + + self.pulls_response = response diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 0b619853348b2..e63e07d6c8526 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -88,7 +88,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 20b68b2e5a9b0..9e13e155f275c 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -65,12 +65,12 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index a2969c032f888..a2f662c499921 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -15,13 +15,15 @@ CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) -from homeassistant.core import Config, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_VERSION, @@ -36,7 +38,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] GLANCES_SCHEMA = vol.All( vol.Schema( @@ -59,7 +61,7 @@ ) -async def async_setup(hass: HomeAssistant, config: Config) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Configure Glances using config flow only.""" if DOMAIN in config: for entry in config[DOMAIN]: diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 981c3727b8e5b..f00f3ca42f846 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -43,10 +43,6 @@ async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == data[CONF_HOST]: - raise AlreadyConfigured - if data[CONF_VERSION] not in SUPPORTED_VERSIONS: raise WrongVersion try: @@ -71,13 +67,12 @@ async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: await validate_input(self.hass, user_input) return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) - except AlreadyConfigured: - return self.async_abort(reason="already_configured") except CannotConnect: errors["base"] = "cannot_connect" except WrongVersion: @@ -121,9 +116,5 @@ class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" -class AlreadyConfigured(exceptions.HomeAssistantError): - """Error to indicate host is already configured.""" - - class WrongVersion(exceptions.HomeAssistantError): """Error to indicate the selected version is wrong.""" diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 18865a232d778..a25ae1b46608a 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,6 +1,10 @@ """Constants for Glances component.""" +from __future__ import annotations + +from dataclasses import dataclass import sys +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, PERCENTAGE, TEMP_CELSIUS DOMAIN = "glances" @@ -20,32 +24,180 @@ else: CPU_ICON = "mdi:cpu-32-bit" -SENSOR_TYPES = { - "disk_use_percent": ["fs", "used percent", PERCENTAGE, "mdi:harddisk"], - "disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk"], - "disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk"], - "memory_use_percent": ["mem", "RAM used percent", PERCENTAGE, "mdi:memory"], - "memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory"], - "memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory"], - "swap_use_percent": ["memswap", "Swap used percent", PERCENTAGE, "mdi:memory"], - "swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory"], - "swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory"], - "processor_load": ["load", "CPU load", "15 min", CPU_ICON], - "process_running": ["processcount", "Running", "Count", CPU_ICON], - "process_total": ["processcount", "Total", "Count", CPU_ICON], - "process_thread": ["processcount", "Thread", "Count", CPU_ICON], - "process_sleeping": ["processcount", "Sleeping", "Count", CPU_ICON], - "cpu_use_percent": ["cpu", "CPU used", PERCENTAGE, CPU_ICON], - "temperature_core": ["sensors", "Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_hdd": ["sensors", "Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "fan_speed": ["sensors", "Fan speed", "RPM", "mdi:fan"], - "battery": ["sensors", "Charge", PERCENTAGE, "mdi:battery"], - "docker_active": ["docker", "Containers active", "", "mdi:docker"], - "docker_cpu_use": ["docker", "Containers CPU used", PERCENTAGE, "mdi:docker"], - "docker_memory_use": [ - "docker", - "Containers RAM used", - DATA_MEBIBYTES, - "mdi:docker", - ], -} + +@dataclass +class GlancesSensorEntityDescription(SensorEntityDescription): + """Describe Glances sensor entity.""" + + type: str | None = None + name_suffix: str | None = None + + +SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( + GlancesSensorEntityDescription( + key="disk_use_percent", + type="fs", + name_suffix="used percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="disk_use", + type="fs", + name_suffix="used", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="disk_free", + type="fs", + name_suffix="free", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="memory_use_percent", + type="mem", + name_suffix="RAM used percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="memory_use", + type="mem", + name_suffix="RAM used", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="memory_free", + type="mem", + name_suffix="RAM free", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="swap_use_percent", + type="memswap", + name_suffix="Swap used percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="swap_use", + type="memswap", + name_suffix="Swap used", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="swap_free", + type="memswap", + name_suffix="Swap free", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="processor_load", + type="load", + name_suffix="CPU load", + native_unit_of_measurement="15 min", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_running", + type="processcount", + name_suffix="Running", + native_unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_total", + type="processcount", + name_suffix="Total", + native_unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_thread", + type="processcount", + name_suffix="Thread", + native_unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_sleeping", + type="processcount", + name_suffix="Sleeping", + native_unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="cpu_use_percent", + type="cpu", + name_suffix="CPU used", + native_unit_of_measurement=PERCENTAGE, + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="temperature_core", + type="sensors", + name_suffix="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + GlancesSensorEntityDescription( + key="temperature_hdd", + type="sensors", + name_suffix="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + GlancesSensorEntityDescription( + key="fan_speed", + type="sensors", + name_suffix="Fan speed", + native_unit_of_measurement="RPM", + icon="mdi:fan", + ), + GlancesSensorEntityDescription( + key="battery", + type="sensors", + name_suffix="Charge", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + GlancesSensorEntityDescription( + key="docker_active", + type="docker", + name_suffix="Containers active", + native_unit_of_measurement="", + icon="mdi:docker", + ), + GlancesSensorEntityDescription( + key="docker_cpu_use", + type="docker", + name_suffix="Containers CPU used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:docker", + ), + GlancesSensorEntityDescription( + key="docker_memory_use", + type="docker", + name_suffix="Containers RAM used", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:docker", + ), + GlancesSensorEntityDescription( + key="used", + type="raid", + name_suffix="Raid used", + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="available", + type="raid", + name_suffix="Raid available", + icon="mdi:harddisk", + ), +) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 7e599af414c3c..b0960b3531aed 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -4,7 +4,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES +from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES, GlancesSensorEntityDescription async def async_setup_entry(hass, config_entry, async_add_entities): @@ -14,45 +14,40 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = config_entry.data[CONF_NAME] dev = [] - for sensor_type, sensor_details in SENSOR_TYPES.items(): - if sensor_details[0] not in client.api.data: - continue - if sensor_details[0] == "fs": + for description in SENSOR_TYPES: + if description.type == "fs": # fs will provide a list of disks attached - for disk in client.api.data[sensor_details[0]]: + for disk in client.api.data[description.type]: dev.append( GlancesSensor( client, name, disk["mnt_point"], - SENSOR_TYPES[sensor_type][1], - sensor_type, - SENSOR_TYPES[sensor_type], + description, ) ) - elif sensor_details[0] == "sensors": + elif description.type == "sensors": # sensors will provide temp for different devices - for sensor in client.api.data[sensor_details[0]]: - if sensor["type"] == sensor_type: + for sensor in client.api.data[description.type]: + if sensor["type"] == description.key: dev.append( GlancesSensor( client, name, sensor["label"], - SENSOR_TYPES[sensor_type][1], - sensor_type, - SENSOR_TYPES[sensor_type], + description, ) ) - elif client.api.data[sensor_details[0]]: + elif description.type == "raid": + for raid_device in client.api.data[description.type]: + dev.append(GlancesSensor(client, name, raid_device, description)) + elif client.api.data[description.type]: dev.append( GlancesSensor( client, name, "", - SENSOR_TYPES[sensor_type][1], - sensor_type, - SENSOR_TYPES[sensor_type], + description, ) ) @@ -62,52 +57,36 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GlancesSensor(SensorEntity): """Implementation of a Glances sensor.""" + entity_description: GlancesSensorEntityDescription + def __init__( self, glances_data, name, sensor_name_prefix, - sensor_name_suffix, - sensor_type, - sensor_details, + description: GlancesSensorEntityDescription, ): """Initialize the sensor.""" self.glances_data = glances_data self._sensor_name_prefix = sensor_name_prefix - self._sensor_name_suffix = sensor_name_suffix - self._name = name - self.type = sensor_type self._state = None - self.sensor_details = sensor_details self.unsub_update = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name_prefix} {self._sensor_name_suffix}" + self.entity_description = description + self._attr_name = f"{name} {sensor_name_prefix} {description.name_suffix}" @property def unique_id(self): """Set unique_id for sensor.""" return f"{self.glances_data.host}-{self.name}" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self.sensor_details[3] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.sensor_details[2] - @property def available(self): """Could the device be accessed during the last update call.""" return self.glances_data.available @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state @@ -134,16 +113,15 @@ async def will_remove_from_hass(self): async def async_update(self): # noqa: C901 """Get the latest data from REST API.""" - value = self.glances_data.api.data - if value is None: + if (value := self.glances_data.api.data) is None: return - if self.sensor_details[0] == "fs": + if self.entity_description.type == "fs": for var in value["fs"]: if var["mnt_point"] == self._sensor_name_prefix: disk = var break - if self.type == "disk_free": + if self.entity_description.key == "disk_free": try: self._state = round(disk["free"] / 1024 ** 3, 1) except KeyError: @@ -151,67 +129,67 @@ async def async_update(self): # noqa: C901 (disk["size"] - disk["used"]) / 1024 ** 3, 1, ) - elif self.type == "disk_use": + elif self.entity_description.key == "disk_use": self._state = round(disk["used"] / 1024 ** 3, 1) - elif self.type == "disk_use_percent": + elif self.entity_description.key == "disk_use_percent": self._state = disk["percent"] - elif self.type == "battery": + elif self.entity_description.key == "battery": for sensor in value["sensors"]: if ( sensor["type"] == "battery" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "fan_speed": + elif self.entity_description.key == "fan_speed": for sensor in value["sensors"]: if ( sensor["type"] == "fan_speed" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "temperature_core": + elif self.entity_description.key == "temperature_core": for sensor in value["sensors"]: if ( sensor["type"] == "temperature_core" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "temperature_hdd": + elif self.entity_description.key == "temperature_hdd": for sensor in value["sensors"]: if ( sensor["type"] == "temperature_hdd" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "memory_use_percent": + elif self.entity_description.key == "memory_use_percent": self._state = value["mem"]["percent"] - elif self.type == "memory_use": + elif self.entity_description.key == "memory_use": self._state = round(value["mem"]["used"] / 1024 ** 2, 1) - elif self.type == "memory_free": + elif self.entity_description.key == "memory_free": self._state = round(value["mem"]["free"] / 1024 ** 2, 1) - elif self.type == "swap_use_percent": + elif self.entity_description.key == "swap_use_percent": self._state = value["memswap"]["percent"] - elif self.type == "swap_use": + elif self.entity_description.key == "swap_use": self._state = round(value["memswap"]["used"] / 1024 ** 3, 1) - elif self.type == "swap_free": + elif self.entity_description.key == "swap_free": self._state = round(value["memswap"]["free"] / 1024 ** 3, 1) - elif self.type == "processor_load": + elif self.entity_description.key == "processor_load": # Windows systems don't provide load details try: self._state = value["load"]["min15"] except KeyError: self._state = value["cpu"]["total"] - elif self.type == "process_running": + elif self.entity_description.key == "process_running": self._state = value["processcount"]["running"] - elif self.type == "process_total": + elif self.entity_description.key == "process_total": self._state = value["processcount"]["total"] - elif self.type == "process_thread": + elif self.entity_description.key == "process_thread": self._state = value["processcount"]["thread"] - elif self.type == "process_sleeping": + elif self.entity_description.key == "process_sleeping": self._state = value["processcount"]["sleeping"] - elif self.type == "cpu_use_percent": + elif self.entity_description.key == "cpu_use_percent": self._state = value["quicklook"]["cpu"] - elif self.type == "docker_active": + elif self.entity_description.key == "docker_active": count = 0 try: for container in value["docker"]["containers"]: @@ -220,7 +198,7 @@ async def async_update(self): # noqa: C901 self._state = count except KeyError: self._state = count - elif self.type == "docker_cpu_use": + elif self.entity_description.key == "docker_cpu_use": cpu_use = 0.0 try: for container in value["docker"]["containers"]: @@ -229,7 +207,7 @@ async def async_update(self): # noqa: C901 self._state = round(cpu_use, 1) except KeyError: self._state = STATE_UNAVAILABLE - elif self.type == "docker_memory_use": + elif self.entity_description.key == "docker_memory_use": mem_use = 0.0 try: for container in value["docker"]["containers"]: @@ -238,3 +216,7 @@ async def async_update(self): # noqa: C901 self._state = round(mem_use / 1024 ** 2, 1) except KeyError: self._state = STATE_UNAVAILABLE + elif self.entity_description.type == "raid": + for raid_device, raid in value["raid"].items(): + if raid_device == self._sensor_name_prefix: + self._state = raid[self.entity_description.key] diff --git a/homeassistant/components/glances/translations/de.json b/homeassistant/components/glances/translations/de.json index e464bfdee3410..8c91e4fb2e35d 100644 --- a/homeassistant/components/glances/translations/de.json +++ b/homeassistant/components/glances/translations/de.json @@ -14,9 +14,9 @@ "name": "Name", "password": "Passwort", "port": "Port", - "ssl": "Verwende SSL / TLS, um eine Verbindung zum Glances-System herzustellen", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "\u00dcberpr\u00fcfe die Zertifizierung des Systems", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen", "version": "Glances API-Version (2 oder 3)" }, "title": "Glances einrichten" diff --git a/homeassistant/components/glances/translations/fr.json b/homeassistant/components/glances/translations/fr.json index cc9be2d6ce86d..6fafa8a3a519f 100644 --- a/homeassistant/components/glances/translations/fr.json +++ b/homeassistant/components/glances/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "cannot_connect": "\u00c9chec de connexion", "wrong_version": "Version non prise en charge (2 ou 3 uniquement)" }, "step": { @@ -14,9 +14,9 @@ "name": "Nom", "password": "Mot de passe", "port": "Port", - "ssl": "Utiliser SSL / TLS pour se connecter au syst\u00e8me Glances", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifier la certification du syst\u00e8me", + "verify_ssl": "V\u00e9rifier le certificat SSL", "version": "Glances API Version (2 ou 3)" }, "title": "Installation de Glances" diff --git a/homeassistant/components/glances/translations/he.json b/homeassistant/components/glances/translations/he.json index 6f4191da70d53..f5ba6464a4ee1 100644 --- a/homeassistant/components/glances/translations/he.json +++ b/homeassistant/components/glances/translations/he.json @@ -1,9 +1,21 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, "step": { "user": { "data": { - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "ssl": "\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05d9\u05e9\u05d5\u05e8 SSL", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", + "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" } } } diff --git a/homeassistant/components/glances/translations/hu.json b/homeassistant/components/glances/translations/hu.json index d85baecb5ca3a..d93fa4bb66e4c 100644 --- a/homeassistant/components/glances/translations/hu.json +++ b/homeassistant/components/glances/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/glances/translations/it.json b/homeassistant/components/glances/translations/it.json index f4806c5d95266..f7af778e17da5 100644 --- a/homeassistant/components/glances/translations/it.json +++ b/homeassistant/components/glances/translations/it.json @@ -16,10 +16,10 @@ "port": "Porta", "ssl": "Utilizza un certificato SSL", "username": "Nome utente", - "verify_ssl": "Verificare il certificato SSL", + "verify_ssl": "Verifica il certificato SSL", "version": "Glances API Version (2 o 3)" }, - "title": "Impostare Glances" + "title": "Configura Glances" } } }, diff --git a/homeassistant/components/glances/translations/ja.json b/homeassistant/components/glances/translations/ja.json new file mode 100644 index 0000000000000..0267110e0d0f2 --- /dev/null +++ b/homeassistant/components/glances/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "wrong_version": "\u5bfe\u5fdc\u3057\u3066\u3044\u306a\u3044\u30d0\u30fc\u30b8\u30e7\u30f3(2\u307e\u305f\u306f3\u306e\u307f)" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b", + "version": "Glances API\u30d0\u30fc\u30b8\u30e7\u30f3(2\u307e\u305f\u306f3)" + }, + "title": "Glances\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u5ea6" + }, + "description": "Glances\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/tr.json b/homeassistant/components/glances/translations/tr.json index 69f0cd7ceb123..50b2ef9cef199 100644 --- a/homeassistant/components/glances/translations/tr.json +++ b/homeassistant/components/glances/translations/tr.json @@ -4,16 +4,22 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "wrong_version": "S\u00fcr\u00fcm desteklenmiyor (yaln\u0131zca 2 veya 3)" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", + "name": "Ad", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n", + "version": "Glances API S\u00fcr\u00fcm\u00fc (2 veya 3)" + }, + "title": "Glances Kurulumu" } } }, @@ -22,7 +28,8 @@ "init": { "data": { "scan_interval": "G\u00fcncelleme s\u0131kl\u0131\u011f\u0131" - } + }, + "description": "Glances i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/glances/translations/zh-Hans.json b/homeassistant/components/glances/translations/zh-Hans.json index 22cb299567299..a62b5f8b32e22 100644 --- a/homeassistant/components/glances/translations/zh-Hans.json +++ b/homeassistant/components/glances/translations/zh-Hans.json @@ -1,15 +1,35 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u8fde\u63a5" + }, "error": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "wrong_version": "\u4e0d\u652f\u6301\u7684\u7248\u672c (\u4ec5\u96502\u62163)" }, "step": { "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", "name": "\u540d\u79f0", "password": "\u5bc6\u7801", - "username": "\u7528\u6237\u540d" - } + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u51ed\u8bc1", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66", + "version": "Glances API \u7248\u672c (2 \u6216 3)" + }, + "title": "\u8bbe\u7f6e Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9891\u7387" + }, + "description": "\u914d\u7f6e Glances \u9009\u9879" } } } diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index b0883d42a5f96..e014c4780ad67 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,48 +1,55 @@ """The Goal Zero Yeti integration.""" +from __future__ import annotations + import logging from goalzero import Yeti, exceptions -from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR -from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from .const import ( + ATTRIBUTION, + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN, + MANUFACTURER, + MIN_TIME_BETWEEN_UPDATES, +) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [DOMAIN_BINARY_SENSOR, DOMAIN_SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] - session = async_get_clientsession(hass) - api = Yeti(host, hass.loop, session) + api = Yeti(host, async_get_clientsession(hass)) try: await api.init_connect() except exceptions.ConnectError as ex: - _LOGGER.warning("Failed to connect: %s", ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex - async def async_update_data(): + async def async_update_data() -> None: """Fetch data from API endpoint.""" try: await api.get_state() except exceptions.ConnectError as err: - raise UpdateFailed(f"Failed to communicating with API: {err}") from err + raise UpdateFailed("Failed to communicate with device") from err coordinator = DataUpdateCoordinator( hass, @@ -51,6 +58,7 @@ async def async_update_data(): update_method=async_update_data, update_interval=MIN_TIME_BETWEEN_UPDATES, ) + await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA_KEY_API: api, @@ -62,7 +70,7 @@ async def async_update_data(): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -73,34 +81,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class YetiEntity(CoordinatorEntity): """Representation of a Goal Zero Yeti entity.""" - def __init__(self, api, coordinator, name, server_unique_id): + _attr_attribution = ATTRIBUTION + + def __init__( + self, + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti entity.""" super().__init__(coordinator) self.api = api self._name = name self._server_unique_id = server_unique_id - self._device_class = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - if self.api.data: - sw_version = self.api.data["firmwareVersion"] - else: - sw_version = None - if self.api.sysdata: - model = self.api.sysdata["model"] - else: - model = model or None - return { - "identifiers": {(DOMAIN, self._server_unique_id)}, - "manufacturer": "Goal Zero", - "model": model, - "name": self._name, - "sw_version": sw_version, - } - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self.api.sysdata["macAddress"])}, + identifiers={(DOMAIN, self._server_unique_id)}, + manufacturer=MANUFACTURER, + model=self.api.sysdata[ATTR_MODEL], + name=self._name, + sw_version=self.api.data["firmwareVersion"], + ) diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 59a8a6b3443fc..51883db6e604e 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -1,28 +1,66 @@ """Support for Goal Zero Yeti Sensors.""" -from homeassistant.components.binary_sensor import BinarySensorEntity +from __future__ import annotations + +from typing import cast + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import BINARY_SENSOR_DICT, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN PARALLEL_UPDATES = 0 +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="backlight", + name="Backlight", + icon="mdi:clock-digital", + ), + BinarySensorEntityDescription( + key="app_online", + name="App Online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="isCharging", + name="Charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + ), + BinarySensorEntityDescription( + key="inputDetected", + name="Input Detected", + device_class=BinarySensorDeviceClass.POWER, + ), +) + -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] - sensors = [ + async_add_entities( YetiBinarySensor( goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - sensor_name, + description, entry.entry_id, ) - for sensor_name in BINARY_SENSOR_DICT - ] - async_add_entities(sensors, True) + for description in BINARY_SENSOR_TYPES + ) class YetiBinarySensor(YetiEntity, BinarySensorEntity): @@ -30,40 +68,19 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): def __init__( self, - api, - coordinator, - name, - sensor_name, - server_unique_id, - ): + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: BinarySensorEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti sensor.""" super().__init__(api, coordinator, name, server_unique_id) - - self._condition = sensor_name - - variable_info = BINARY_SENSOR_DICT[sensor_name] - self._condition_name = variable_info[0] - self._icon = variable_info[2] - self._device_class = variable_info[1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"{self._server_unique_id}/{self._condition_name}" - - @property - def is_on(self): - """Return if the service is on.""" - if self.api.data: - return self.api.data[self._condition] == 1 - return False + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + def is_on(self) -> bool: + """Return True if the service is on.""" + return cast(bool, self.api.data[self.entity_description.key] == 1) diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index c570554d50e0e..2d8c0c848c979 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -1,52 +1,88 @@ """Config flow for Goal Zero Yeti integration.""" +from __future__ import annotations + import logging +from typing import Any from goalzero import Yeti, exceptions import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac -from .const import DEFAULT_NAME, DOMAIN +from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({"host": str, "name": str}) - class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Goal Zero Yeti.""" VERSION = 1 - async def async_step_user(self, user_input=None): + def __init__(self) -> None: + """Initialize a Goal Zero Yeti flow.""" + self.ip_address: str | None = None + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + self.ip_address = discovery_info.ip + + await self.async_set_unique_id(discovery_info.macaddress) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) + self._async_abort_entries_match({CONF_HOST: self.ip_address}) + + _, error = await self._async_try_connect(str(self.ip_address)) + if error is None: + return await self.async_step_confirm_discovery() + return self.async_abort(reason=error) + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + return self.async_create_entry( + title=MANUFACTURER, + data={ + CONF_HOST: self.ip_address, + CONF_NAME: DEFAULT_NAME, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + CONF_HOST: self.ip_address, + CONF_NAME: DEFAULT_NAME, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} - if user_input is not None: host = user_input[CONF_HOST] name = user_input[CONF_NAME] - if await self._async_endpoint_existed(host): - return self.async_abort(reason="already_configured") - - try: - await self._async_try_connect(host) - except exceptions.ConnectError: - errors["base"] = "cannot_connect" - _LOGGER.error("Error connecting to device at %s", host) - except exceptions.InvalidHost: - errors["base"] = "invalid_host" - _LOGGER.error("Invalid host at %s", host) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + self._async_abort_entries_match({CONF_HOST: host}) + + mac_address, error = await self._async_try_connect(host) + if error is None: + await self.async_set_unique_id(format_mac(str(mac_address))) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self.async_create_entry( title=name, data={CONF_HOST: host, CONF_NAME: name}, ) + errors["base"] = error user_input = user_input or {} return self.async_show_form( @@ -64,13 +100,16 @@ async def async_step_user(self, user_input=None): errors=errors, ) - async def _async_endpoint_existed(self, endpoint): - for entry in self._async_current_entries(): - if endpoint == entry.data.get(CONF_HOST): - return True - return False - - async def _async_try_connect(self, host): - session = async_get_clientsession(self.hass) - api = Yeti(host, self.hass.loop, session) - await api.get_state() + async def _async_try_connect(self, host: str) -> tuple[str | None, str | None]: + """Try connecting to Goal Zero Yeti.""" + try: + api = Yeti(host, async_get_clientsession(self.hass)) + await api.sysinfo() + except exceptions.ConnectError: + return None, "cannot_connect" + except exceptions.InvalidHost: + return None, "invalid_host" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return None, "unknown" + return str(api.sysdata["macAddress"]), None diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index 826c2621e237a..fef1636005d64 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -1,32 +1,12 @@ """Constants for the Goal Zero Yeti integration.""" from datetime import timedelta -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_POWER, -) +ATTRIBUTION = "Data provided by Goal Zero" +ATTR_DEFAULT_ENABLED = "default_enabled" DATA_KEY_COORDINATOR = "coordinator" DOMAIN = "goalzero" DEFAULT_NAME = "Yeti" DATA_KEY_API = "api" - +MANUFACTURER = "Goal Zero" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - -BINARY_SENSOR_DICT = { - "backlight": ["Backlight", None, "mdi:clock-digital"], - "app_online": [ - "App Online", - DEVICE_CLASS_CONNECTIVITY, - None, - ], - "isCharging": ["Charging", DEVICE_CLASS_BATTERY_CHARGING, None], - "inputDetected": ["Input Detected", DEVICE_CLASS_POWER, None], -} - -SWITCH_DICT = { - "v12PortStatus": "12V Port Status", - "usbPortStatus": "USB Port Status", - "acPortStatus": "AC Port Status", -} diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index 0a1bc4df70d2b..f46401d2a6be4 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -3,7 +3,11 @@ "name": "Goal Zero Yeti", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/goalzero", - "requirements": ["goalzero==0.1.7"], + "requirements": ["goalzero==0.2.1"], + "dhcp": [ + {"hostname": "yeti*"} + ], "codeowners": ["@tkdrob"], + "quality_scale": "silver", "iot_class": "local_polling" } diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py new file mode 100644 index 0000000000000..34af3a89ad615 --- /dev/null +++ b/homeassistant/components/goalzero/sensor.py @@ -0,0 +1,175 @@ +"""Support for Goal Zero Yeti Sensors.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="wattsIn", + name="Watts In", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="ampsIn", + name="Amps In", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="wattsOut", + name="Watts Out", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="ampsOut", + name="Amps Out", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="whOut", + name="WH Out", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="whStored", + name="WH Stored", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="volts", + name="Volts", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="socPercent", + name="State of Charge Percent", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="timeToEmptyFull", + name="Time to Empty/Full", + device_class=TIME_MINUTES, + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="wifiStrength", + name="Wifi Strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="timestamp", + name="Total Run Time", + native_unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="ssid", + name="Wi-Fi SSID", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="ipAddr", + name="IP Address", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Goal Zero Yeti sensor.""" + name = entry.data[CONF_NAME] + goalzero_data = hass.data[DOMAIN][entry.entry_id] + sensors = [ + YetiSensor( + goalzero_data[DATA_KEY_API], + goalzero_data[DATA_KEY_COORDINATOR], + name, + description, + entry.entry_id, + ) + for description in SENSOR_TYPES + ] + async_add_entities(sensors, True) + + +class YetiSensor(YetiEntity, SensorEntity): + """Representation of a Goal Zero Yeti sensor.""" + + def __init__( + self, + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: SensorEntityDescription, + server_unique_id: str, + ) -> None: + """Initialize a Goal Zero Yeti sensor.""" + super().__init__(api, coordinator, name, server_unique_id) + self._attr_name = f"{name} {description.name}" + self.entity_description = description + self._attr_unique_id = f"{server_unique_id}/{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state.""" + return cast(StateType, self.api.data[self.entity_description.key]) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index 92813337e779e..5147299b56493 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -3,11 +3,15 @@ "step": { "user": { "title": "Goal Zero Yeti", - "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. DHCP reservation must be set up in your router settings for the device to ensure the host IP does not change. Refer to your router's user manual.", + "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wi-fi network. DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual.", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" } + }, + "confirm_discovery": { + "title": "Goal Zero Yeti", + "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual." } }, "error": { @@ -16,7 +20,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index dd4c9deae3e98..6c80a773a741a 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -1,26 +1,50 @@ """Support for Goal Zero Yeti Switches.""" -from homeassistant.components.switch import SwitchEntity +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, SWITCH_DICT +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="v12PortStatus", + name="12V Port Status", + ), + SwitchEntityDescription( + key="usbPortStatus", + name="USB Port Status", + ), + SwitchEntityDescription( + key="acPortStatus", + name="AC Port Status", + ), +) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Goal Zero Yeti switch.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] - switches = [ + async_add_entities( YetiSwitch( goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - switch_name, + description, entry.entry_id, ) - for switch_name in SWITCH_DICT - ] - async_add_entities(switches) + for description in SWITCH_TYPES + ) class YetiSwitch(YetiEntity, SwitchEntity): @@ -28,44 +52,31 @@ class YetiSwitch(YetiEntity, SwitchEntity): def __init__( self, - api, - coordinator, - name, - switch_name, - server_unique_id, - ): + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: SwitchEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti switch.""" super().__init__(api, coordinator, name, server_unique_id) - - self._condition = switch_name - - self._condition_name = SWITCH_DICT[switch_name] - - @property - def name(self): - """Return the name of the switch.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self): - """Return the unique id of the switch.""" - return f"{self._server_unique_id}/{self._condition}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property - def is_on(self): + def is_on(self) -> bool: """Return state of the switch.""" - if self.api.data: - return self.api.data[self._condition] - return None + return cast(bool, self.api.data[self.entity_description.key] == 1) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - payload = {self._condition: 0} + payload = {self.entity_description.key: 0} await self.api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - payload = {self._condition: 1} + payload = {self.entity_description.key: 1} await self.api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) diff --git a/homeassistant/components/goalzero/translations/bg.json b/homeassistant/components/goalzero/translations/bg.json new file mode 100644 index 0000000000000..2461fa173aeee --- /dev/null +++ b/homeassistant/components/goalzero/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/ca.json b/homeassistant/components/goalzero/translations/ca.json index 5fb9643d09716..0f9979eb37844 100644 --- a/homeassistant/components/goalzero/translations/ca.json +++ b/homeassistant/components/goalzero/translations/ca.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "invalid_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids", + "unknown": "Error inesperat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -9,12 +11,16 @@ "unknown": "Error inesperat" }, "step": { + "confirm_discovery": { + "description": "Es recomana que la reserva DHCP del router estigui configurada. Si no ho est\u00e0, pot ser que el dispositiu no estigui disponible mentre Home Assistant no detecti la nova IP. Consulta el manual del router.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Amfitri\u00f3", "name": "Nom" }, - "description": "En primer lloc, has de baixar-te l'aplicaci\u00f3 Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSegueix les instruccions per connectar el Yeti a la xarxa Wifi. Cal que la reserva DHCP del router estigui configurada per al teu dispositiu per garantir que la IP no canvi\u00ef. Si cal, consulta el manual del router.", + "description": "En primer lloc, has de descarregar-te l'aplicaci\u00f3 Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSegueix les instruccions per connectar el Yeti al teu Wi-Fi. Es recomana que la reserva DHCP del router estigui configurada, si no ho est\u00e0, pot ser que el dispositiu no estigui disponible mentre Home Assistant no detecti la nova IP. Consulta el manual del router.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/de.json b/homeassistant/components/goalzero/translations/de.json index 3916b98798176..5133488c24770 100644 --- a/homeassistant/components/goalzero/translations/de.json +++ b/homeassistant/components/goalzero/translations/de.json @@ -1,20 +1,27 @@ { "config": { "abort": { - "already_configured": "Konto wurde bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", + "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "unknown": "Unerwarteter Fehler" }, "step": { + "confirm_discovery": { + "description": "Eine DHCP-Reservierung auf deinem Router wird empfohlen. Wenn sie nicht eingerichtet ist, ist das Ger\u00e4t m\u00f6glicherweise nicht mehr verf\u00fcgbar, bis Home Assistant die neue IP-Adresse erkennt. Schlage im Benutzerhandbuch deines Routers nach.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", "name": "Name" }, - "description": "Zun\u00e4chst musst du die Goal Zero App herunterladen: https://www.goalzero.com/product-features/yeti-app/\n\nFolge den Anweisungen, um deinen Yeti mit deinem Wifi-Netzwerk zu verbinden. Bekomme dann die Host-IP von deinem Router. DHCP muss in den Router-Einstellungen f\u00fcr das Ger\u00e4t richtig eingerichtet werden, um sicherzustellen, dass sich die Host-IP nicht \u00e4ndert. Schaue hierzu im Benutzerhandbuch deines Routers nach." + "description": "Zuerst musst du die Goal Zero App herunterladen: https://www.goalzero.com/product-features/yeti-app/ \n\nFolge den Anweisungen, um deinen Yeti mit deinem WLAN-Netzwerk zu verbinden. Eine DHCP-Reservierung auf deinem Router wird empfohlen. Wenn es nicht eingerichtet ist, ist das Ger\u00e4t m\u00f6glicherweise nicht verf\u00fcgbar, bis Home Assistant die neue IP-Adresse erkennt. Schlage dazu im Benutzerhandbuch deines Routers nach.", + "title": "Goal Zero Yeti" } } } diff --git a/homeassistant/components/goalzero/translations/en.json b/homeassistant/components/goalzero/translations/en.json index e6c6e4a72981b..26a92757a4b15 100644 --- a/homeassistant/components/goalzero/translations/en.json +++ b/homeassistant/components/goalzero/translations/en.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Device is already configured", + "invalid_host": "Invalid hostname or IP address", + "unknown": "Unexpected error" }, "error": { "cannot_connect": "Failed to connect", @@ -9,12 +11,16 @@ "unknown": "Unexpected error" }, "step": { + "confirm_discovery": { + "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", "name": "Name" }, - "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. DHCP reservation must be set up in your router settings for the device to ensure the host IP does not change. Refer to your router's user manual.", + "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wi-fi network. DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/es-419.json b/homeassistant/components/goalzero/translations/es-419.json new file mode 100644 index 0000000000000..9d46499634915 --- /dev/null +++ b/homeassistant/components/goalzero/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "Se recomienda reservar DHCP en su enrutador. Si no se configura, es posible que el dispositivo no est\u00e9 disponible hasta que Home Assistant detecte la nueva direcci\u00f3n IP. Consulte el manual de usuario de su enrutador." + }, + "user": { + "description": "Primero, debe descargar la aplicaci\u00f3n Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSiga las instrucciones para conectar su Yeti a su red Wi-Fi. Se recomienda reservar DHCP en su enrutador. Si no se configura, es posible que el dispositivo no est\u00e9 disponible hasta que Home Assistant detecte la nueva direcci\u00f3n IP. Consulte el manual de usuario de su enrutador." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/es.json b/homeassistant/components/goalzero/translations/es.json index 4a2c7eeca620e..fa54d6d6afc91 100644 --- a/homeassistant/components/goalzero/translations/es.json +++ b/homeassistant/components/goalzero/translations/es.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "La cuenta ya ha sido configurada" + "already_configured": "La cuenta ya ha sido configurada", + "invalid_host": "Nombre de host o direcci\u00f3n IP no v\u00e1lidos", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", @@ -9,6 +11,10 @@ "unknown": "Error inesperado" }, "step": { + "confirm_discovery": { + "description": "Se recomienda reservar el DHCP en el router. Si no se configura, el dispositivo puede dejar de estar disponible hasta que el Home Assistant detecte la nueva direcci\u00f3n ip. Consulte el manual de usuario de su router.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/goalzero/translations/et.json b/homeassistant/components/goalzero/translations/et.json index 5bc1af9729775..5d0111aa946d7 100644 --- a/homeassistant/components/goalzero/translations/et.json +++ b/homeassistant/components/goalzero/translations/et.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Konto on juba seadistatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "invalid_host": "Tundmatu host", + "unknown": "Tundmatu viga" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -9,6 +11,10 @@ "unknown": "Tundmatu viga" }, "step": { + "confirm_discovery": { + "description": "Soovitatav on DHCP aadressi reserveerimine ruuteris. Kui seda pole seadistatud, v\u00f5ib seade osutuda k\u00e4ttesaamatuks kuni Home Assistant tuvastab uue IP-aadressi. Vaata ruuteri kasutusjuhendit.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/goalzero/translations/fr.json b/homeassistant/components/goalzero/translations/fr.json index 7bd4929ad929c..7def0d064023c 100644 --- a/homeassistant/components/goalzero/translations/fr.json +++ b/homeassistant/components/goalzero/translations/fr.json @@ -1,14 +1,20 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", - "unknown": "Erreur inconnue" + "unknown": "Erreur inattendue" }, "step": { + "confirm_discovery": { + "description": "La r\u00e9servation DHCP sur votre routeur est recommand\u00e9e. S'il n'est pas configur\u00e9, l'appareil peut devenir indisponible jusqu'\u00e0 ce que Home Assistant d\u00e9tecte la nouvelle adresse IP. Reportez-vous au manuel d'utilisation de votre routeur.", + "title": "Objectif Z\u00e9ro Y\u00e9ti" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/goalzero/translations/he.json b/homeassistant/components/goalzero/translations/he.json new file mode 100644 index 0000000000000..f645dcab778cf --- /dev/null +++ b/homeassistant/components/goalzero/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/hu.json b/homeassistant/components/goalzero/translations/hu.json index c876a55301f16..62c0a1626f9c9 100644 --- a/homeassistant/components/goalzero/translations/hu.json +++ b/homeassistant/components/goalzero/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -9,11 +11,17 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "confirm_discovery": { + "description": "DHCP foglal\u00e1s aj\u00e1nlott az routeren. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az router felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", + "title": "Goal Zero Yeti" + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" - } + }, + "description": "El\u0151sz\u00f6r le kell t\u00f6ltenie a Goal Zero alkalmaz\u00e1st: https://www.goalzero.com/product-features/yeti-app/ \n\nK\u00f6vesse az utas\u00edt\u00e1sokat, hogy csatlakoztassa Yeti k\u00e9sz\u00fcl\u00e9k\u00e9t a Wi-Fi h\u00e1l\u00f3zathoz. DHCP foglal\u00e1s aj\u00e1nlott az routeren. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg a Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az router felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", + "title": "Goal Zero Yeti" } } } diff --git a/homeassistant/components/goalzero/translations/id.json b/homeassistant/components/goalzero/translations/id.json index 63fddf13a8e13..d5897a2d94480 100644 --- a/homeassistant/components/goalzero/translations/id.json +++ b/homeassistant/components/goalzero/translations/id.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "invalid_host": "Nama host atau alamat IP tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" }, "error": { "cannot_connect": "Gagal terhubung", @@ -9,6 +11,10 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "confirm_discovery": { + "description": "Dianjurkan untuk menggunakan reservasi DHCP pada router Anda. Jika tidak diatur, perangkat mungkin tidak tersedia hingga Home Assistant mendeteksi alamat IP baru. Lihat panduan pengguna router Anda.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/goalzero/translations/it.json b/homeassistant/components/goalzero/translations/it.json index 98b682dd76bd4..ad5042cc60264 100644 --- a/homeassistant/components/goalzero/translations/it.json +++ b/homeassistant/components/goalzero/translations/it.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "invalid_host": "Nome host o indirizzo IP non valido", + "unknown": "Errore imprevisto" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -9,12 +11,16 @@ "unknown": "Errore imprevisto" }, "step": { + "confirm_discovery": { + "description": "Si consiglia la prenotazione DHCP sul router. Se non configurato, il dispositivo potrebbe non essere disponibile fino a quando Home Assistant non rileva il nuovo indirizzo IP. Fai riferimento al manuale utente del router.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", "name": "Nome" }, - "description": "Innanzitutto, devi scaricare l'app Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSegui le istruzioni per connettere il tuo Yeti alla tua rete Wifi. La prenotazione DHCP per il dispositivo deve essere configurata nelle impostazioni del router per assicurarsi che l'IP host non cambi. Fare riferimento al manuale utente del router.", + "description": "Innanzitutto, devi scaricare l'app Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSegui le istruzioni per connettere il tuo Yeti alla tua rete Wi-Fi. Si consiglia la prenotazione DHCP sul router. Se non configurato, il dispositivo potrebbe non essere disponibile fino a quando Home Assistant non rileva il nuovo indirizzo IP. Fare riferimento al manuale utente del router.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/ja.json b/homeassistant/components/goalzero/translations/ja.json new file mode 100644 index 0000000000000..3e2e33bc302f4 --- /dev/null +++ b/homeassistant/components/goalzero/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "confirm_discovery": { + "description": "\u30eb\u30fc\u30bf\u30fc\u306eDHCP\u4e88\u7d04(DHCP reservation)\u3092\u304a\u52e7\u3081\u3057\u307e\u3059\u3002\u3053\u306e\u8a2d\u5b9a\u3092\u884c\u3063\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u306f\u3001Home Assistant\u304c\u65b0\u3057\u3044IP\u30a2\u30c9\u30ec\u30b9\u3092\u691c\u51fa\u3059\u308b\u307e\u3067\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002\u30eb\u30fc\u30bf\u30fc\u306e\u30e6\u30fc\u30b6\u30fc\u30de\u30cb\u30e5\u30a2\u30eb\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Goal Zero Yeti" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "\u307e\u305a\u3001Goal Zero app\u3092\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059: https://www.goalzero.com/product-features/yeti-app/\n\n\u30eb\u30fc\u30bf\u30fc\u306eDHCP\u4e88\u7d04(DHCP reservation)\u3092\u304a\u52e7\u3081\u3057\u307e\u3059\u3002\u3053\u306e\u8a2d\u5b9a\u3092\u884c\u3063\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u306f\u3001Home Assistant\u304c\u65b0\u3057\u3044IP\u30a2\u30c9\u30ec\u30b9\u3092\u691c\u51fa\u3059\u308b\u307e\u3067\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002\u30eb\u30fc\u30bf\u30fc\u306e\u30e6\u30fc\u30b6\u30fc\u30de\u30cb\u30e5\u30a2\u30eb\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Goal Zero Yeti" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/nl.json b/homeassistant/components/goalzero/translations/nl.json index c84ef7adb1f4e..d73c4d648ed7f 100644 --- a/homeassistant/components/goalzero/translations/nl.json +++ b/homeassistant/components/goalzero/translations/nl.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Account is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "invalid_host": "Ongeldige hostnaam of IP-adres", + "unknown": "Onverwachte fout" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -9,12 +11,16 @@ "unknown": "Onverwachte fout" }, "step": { + "confirm_discovery": { + "description": "DHCP-reservering op uw router wordt aanbevolen. Als dit niet het geval is, is het apparaat mogelijk niet meer beschikbaar totdat Home Assistant het nieuwe IP-adres detecteert. Raadpleeg de gebruikershandleiding van uw router.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", "name": "Naam" }, - "description": "Eerst moet u de Goal Zero-app downloaden: https://www.goalzero.com/product-features/yeti-app/ \n\n Volg de instructies om je Yeti te verbinden met je wifi-netwerk. Haal dan de host-ip van uw router. DHCP moet zijn ingesteld in uw routerinstellingen voor het apparaat om ervoor te zorgen dat het host-ip niet verandert. Raadpleeg de gebruikershandleiding van uw router.", + "description": "Eerst moet u de Goal Zero-app downloaden: https://www.goalzero.com/product-features/yeti-app/ \n\n Volg de instructies om Yeti te verbinden met uw wifi-netwerk. DHCP-reservering op uw router wordt aanbevolen. Als het niet is ingesteld, is het apparaat mogelijk niet meer beschikbaar totdat Home Assistant het nieuwe IP-adres detecteert. Raadpleeg de gebruikershandleiding van uw router.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/no.json b/homeassistant/components/goalzero/translations/no.json index 4dfeadfcf6d3b..1bc2d6feae182 100644 --- a/homeassistant/components/goalzero/translations/no.json +++ b/homeassistant/components/goalzero/translations/no.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Kontoen er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "invalid_host": "Ugyldig vertsnavn eller IP-adresse", + "unknown": "Uventet feil" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -9,12 +11,16 @@ "unknown": "Uventet feil" }, "step": { + "confirm_discovery": { + "description": "DHCP-reservasjon p\u00e5 ruteren din anbefales. Hvis den ikke er konfigurert, kan enheten bli utilgjengelig til Home Assistant oppdager den nye ip-adressen. Se i brukerh\u00e5ndboken til ruteren.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Vert", "name": "Navn" }, - "description": "F\u00f8rst m\u00e5 du laste ned appen Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\n F\u00f8lg instruksjonene for \u00e5 koble Yeti til Wifi-nettverket. DHCP-reservasjon m\u00e5 v\u00e6re satt opp i ruteren innstillinger for enheten for \u00e5 sikre at verts-IP ikke endres. Se i brukerh\u00e5ndboken til ruteren.", + "description": "F\u00f8rst m\u00e5 du laste ned Goal Zero-appen: https://www.goalzero.com/product-features/yeti-app/\n\nF\u00f8lg instruksjonene for \u00e5 koble Yeti til Wi-Fi-nettverket ditt. DHCP-reservasjon p\u00e5 ruteren anbefales. Hvis den ikke er konfigurert, kan enheten bli utilgjengelig til Home Assistant oppdager den nye IP-adressen. Se brukerh\u00e5ndboken for ruteren.", "title": "" } } diff --git a/homeassistant/components/goalzero/translations/pl.json b/homeassistant/components/goalzero/translations/pl.json index 4b06301953ec2..3aba221bc4a97 100644 --- a/homeassistant/components/goalzero/translations/pl.json +++ b/homeassistant/components/goalzero/translations/pl.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "invalid_host": "Nieprawid\u0142owa nazwa hosta lub adres IP", + "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", @@ -9,12 +11,16 @@ "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "confirm_discovery": { + "description": "Zaleca si\u0119 rezerwacj\u0119 DHCP w ustawieniach routera. Je\u015bli tego nie ustawisz, urz\u0105dzenie mo\u017ce sta\u0107 si\u0119 niedost\u0119pne, do czasu a\u017c Home Assistant wykryje nowy adres IP. Post\u0119puj wg instrukcji obs\u0142ugi routera.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", "name": "Nazwa" }, - "description": "Najpierw musisz pobra\u0107 aplikacj\u0119 Goal Zero: https://www.goalzero.com/product-features/yeti-app/\n\nPost\u0119puj zgodnie z instrukcjami, aby pod\u0142\u0105czy\u0107 Yeti do sieci Wi-Fi. W ustawieniach routera nale\u017cy skonfigurowa\u0107 rezerwacj\u0119 adres\u00f3w DHCP, aby upewni\u0107 si\u0119, \u017ce adres IP hosta nie ulegnie zmianie. Post\u0119puj wg instrukcji obs\u0142ugi routera.", + "description": "Najpierw musisz pobra\u0107 aplikacj\u0119 Goal Zero: https://www.goalzero.com/product-features/yeti-app/\n\nPost\u0119puj zgodnie z instrukcjami, aby pod\u0142\u0105czy\u0107 Yeti do sieci Wi-Fi. Zaleca si\u0119 rezerwacj\u0119 DHCP w ustawieniach routera. Je\u015bli tego nie ustawisz, urz\u0105dzenie mo\u017ce sta\u0107 si\u0119 niedost\u0119pne, do czasu a\u017c Home Assistant wykryje nowy adres IP. Post\u0119puj wg instrukcji obs\u0142ugi routera.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/ru.json b/homeassistant/components/goalzero/translations/ru.json index 066c93545d63a..52d8bbcbdc792 100644 --- a/homeassistant/components/goalzero/translations/ru.json +++ b/homeassistant/components/goalzero/translations/ru.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "invalid_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", @@ -9,12 +11,16 @@ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { + "confirm_discovery": { + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0442\u0430\u043a\u0438\u043c\u0438, \u0447\u0442\u043e\u0431\u044b IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437\u043c\u0435\u043d\u044f\u043b\u0441\u044f \u0441\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0435\u043c. \u0412 \u043f\u0440\u043e\u0442\u0438\u0432\u043d\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043c\u043e\u0436\u0435\u0442 \u0441\u0442\u0430\u0442\u044c \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c \u0434\u043e \u0442\u0435\u0445 \u043f\u043e\u0440, \u043f\u043e\u043a\u0430 Home Assistant \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442 \u043d\u043e\u0432\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441. \u041e\u0431\u0440\u0430\u0442\u0438\u0442\u0435\u0441\u044c \u043a \u0440\u0443\u043a\u043e\u0432\u043e\u0434\u0441\u0442\u0432\u0443 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0412\u0430\u0448\u0435\u0433\u043e \u0440\u043e\u0443\u0442\u0435\u0440\u0430.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u0441\u043a\u0430\u0447\u0430\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 Goal Zero: https://www.goalzero.com/product-features/yeti-app/.\n\n\u0421\u043b\u0435\u0434\u0443\u0439\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c \u043f\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044e Yeti \u043a \u0441\u0435\u0442\u0438 WiFi. \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0442\u0430\u043a\u0438\u043c\u0438, \u0447\u0442\u043e\u0431\u044b IP \u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437\u043c\u0435\u043d\u044f\u043b\u0441\u044f \u0441\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0435\u043c. \u041e \u0442\u043e\u043c, \u043a\u0430\u043a \u044d\u0442\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c, \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0443\u0437\u043d\u0430\u0442\u044c \u0432 \u0440\u0443\u043a\u043e\u0432\u043e\u0434\u0441\u0442\u0432\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0412\u0430\u0448\u0435\u0433\u043e \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430.", + "description": "\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u0441\u043a\u0430\u0447\u0430\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 Goal Zero: https://www.goalzero.com/product-features/yeti-app/.\n\n\u0421\u043b\u0435\u0434\u0443\u0439\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c \u043f\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044e Yeti \u043a \u0441\u0435\u0442\u0438 WiFi. \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0442\u0430\u043a\u0438\u043c\u0438, \u0447\u0442\u043e\u0431\u044b IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437\u043c\u0435\u043d\u044f\u043b\u0441\u044f \u0441\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0435\u043c. \u0412 \u043f\u0440\u043e\u0442\u0438\u0432\u043d\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043c\u043e\u0436\u0435\u0442 \u0441\u0442\u0430\u0442\u044c \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c \u0434\u043e \u0442\u0435\u0445 \u043f\u043e\u0440, \u043f\u043e\u043a\u0430 Home Assistant \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442 \u043d\u043e\u0432\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441. \u041e \u0442\u043e\u043c, \u043a\u0430\u043a \u044d\u0442\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c, \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0443\u0437\u043d\u0430\u0442\u044c \u0432 \u0440\u0443\u043a\u043e\u0432\u043e\u0434\u0441\u0442\u0432\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0412\u0430\u0448\u0435\u0433\u043e \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/goalzero/translations/tr.json b/homeassistant/components/goalzero/translations/tr.json index ae77262b2b3f9..a2c5b4f898655 100644 --- a/homeassistant/components/goalzero/translations/tr.json +++ b/homeassistant/components/goalzero/translations/tr.json @@ -1,17 +1,27 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", + "unknown": "Beklenmeyen hata" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unknown": "Beklenmeyen hata" }, "step": { + "confirm_discovery": { + "description": "Y\u00f6nlendiricinizde DHCP rezervasyonu yap\u0131lmas\u0131 \u00f6nerilir. Kurulmazsa, Home Assistant yeni ip adresini alg\u0131layana kadar cihaz kullan\u0131lamayabilir. Y\u00f6nlendiricinizin kullan\u0131m k\u0131lavuzuna bak\u0131n.", + "title": "Goal Zero Yeti" + }, "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Sunucu", + "name": "Ad" + }, + "description": "\u00d6ncelikle Goal Zero uygulamas\u0131n\u0131 indirmeniz gerekiyor: https://www.goalzero.com/product-features/yeti-app/ \n\n Yeti'nizi Wi-fi a\u011f\u0131n\u0131za ba\u011flamak i\u00e7in talimatlar\u0131 izleyin. Y\u00f6nlendiricinizde DHCP rezervasyonu yap\u0131lmas\u0131 \u00f6nerilir. Kurulmazsa, Home Assistant yeni ip adresini alg\u0131layana kadar cihaz kullan\u0131lamayabilir. Y\u00f6nlendiricinizin kullan\u0131m k\u0131lavuzuna bak\u0131n.", + "title": "Goal Zero Yeti" } } } diff --git a/homeassistant/components/goalzero/translations/zh-Hant.json b/homeassistant/components/goalzero/translations/zh-Hant.json index 5560def5eb192..13c49f8d2acf4 100644 --- a/homeassistant/components/goalzero/translations/zh-Hant.json +++ b/homeassistant/components/goalzero/translations/zh-Hant.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -9,12 +11,16 @@ "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "confirm_discovery": { + "description": "\u5efa\u8b70\u65bc\u8def\u7531\u5668\u7684 DHCP \u8a2d\u5b9a\u4e2d\u4fdd\u7559\u56fa\u5b9a IP\uff0c\u5047\u5982\u672a\u8a2d\u5b9a\u3001\u88dd\u7f6e\u53ef\u80fd\u6703\u5728 Home Assistant \u5075\u6e2c\u5230\u65b0 IP \u4e4b\u524d\u8b8a\u6210\u7121\u6cd5\u4f7f\u7528\u3002\u8acb\u53c3\u95b1\u8def\u7531\u5668\u624b\u518a\u4e86\u89e3\u5982\u4f55\u8a2d\u5b9a\u3002", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", "name": "\u540d\u7a31" }, - "description": "\u9996\u5148\u5fc5\u9808\u5148\u4e0b\u8f09 Goal Zero app\uff1ahttps://www.goalzero.com/product-features/yeti-app/\n\n\u8ddf\u96a8\u6307\u793a\u5c07 Yeti \u9023\u7dda\u81f3\u7121\u7dda\u7db2\u8def\u3002\u5fc5\u9808\u65bc\u8def\u7531\u5668\u5167\u8a2d\u5b9a\u88dd\u7f6e\u7684 DHCP \u56fa\u5b9a IP\u3001\u4ee5\u78ba\u4fdd\u4e3b\u6a5f\u7aef IP \u4e0d\u81f3\u65bc\u6539\u8b8a\u3002\u8acb\u53c3\u8003\u60a8\u7684\u8def\u7531\u5668\u624b\u518a\u4e86\u89e3\u5982\u4f55\u64cd\u4f5c\u3002", + "description": "\u9996\u5148\u5fc5\u9808\u5148\u4e0b\u8f09 Goal Zero app\uff1ahttps://www.goalzero.com/product-features/yeti-app/\n\n\u8ddf\u96a8\u6307\u793a\u5c07 Yeti \u9023\u7dda\u81f3\u7121\u7dda\u7db2\u8def\u3002\u5efa\u8b70\u65bc\u8def\u7531\u5668\u7684 DHCP \u8a2d\u5b9a\u4e2d\u4fdd\u7559\u56fa\u5b9a IP\uff0c\u5047\u5982\u672a\u8a2d\u5b9a\u3001\u88dd\u7f6e\u53ef\u80fd\u6703\u5728 Home Assistant \u5075\u6e2c\u5230\u65b0 IP \u4e4b\u524d\u8b8a\u6210\u7121\u6cd5\u4f7f\u7528\u3002\u8acb\u53c3\u95b1\u8def\u7531\u5668\u624b\u518a\u4e86\u89e3\u5982\u4f55\u8a2d\u5b9a\u3002", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py index d4271b3937a35..7dccd5551c7cc 100644 --- a/homeassistant/components/gogogate2/__init__.py +++ b/homeassistant/components/gogogate2/__init__.py @@ -1,15 +1,13 @@ """The gogogate2 component.""" -from homeassistant.components.cover import DOMAIN as COVER -from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE +from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant from .common import get_data_update_coordinator from .const import DEVICE_TYPE_GOGOGATE2 -PLATFORMS = [COVER, SENSOR] +PLATFORMS = [Platform.COVER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -18,13 +16,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Update the config entry. config_updates = {} if CONF_DEVICE not in entry.data: - config_updates["data"] = { + config_updates = { **entry.data, **{CONF_DEVICE: DEVICE_TYPE_GOGOGATE2}, } if config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) + hass.config_entries.async_update_entry(entry, data=config_updates) data_update_coordinator = get_data_update_coordinator(hass, entry) await data_update_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 9345f8d5fed37..5d0392e6db207 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -1,10 +1,10 @@ """Common code for GogoGate2 component.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable, Mapping from datetime import timedelta import logging -from typing import Callable, NamedTuple +from typing import Any, NamedTuple from ismartgate import AbstractGateApi, GogoGate2Api, ISmartGateApi from ismartgate.common import AbstractDoor, get_door_by_id @@ -18,6 +18,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -51,7 +52,7 @@ def __init__( update_interval: timedelta, update_method: Callable[[], Awaitable] | None = None, request_refresh_debouncer: Debouncer | None = None, - ): + ) -> None: """Initialize the data update coordinator.""" DataUpdateCoordinator.__init__( self, @@ -79,29 +80,44 @@ def __init__( super().__init__(data_update_coordinator) self._config_entry = config_entry self._door = door - self._unique_id = unique_id + self._door_id = door.door_id + self._api = data_update_coordinator.api + self._attr_unique_id = unique_id @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._unique_id - - def _get_door(self) -> AbstractDoor: + def door(self) -> AbstractDoor: + """Return the door object.""" door = get_door_by_id(self._door.door_id, self.coordinator.data) self._door = door or self._door return self._door @property - def device_info(self): + def door_status(self) -> AbstractDoor: + """Return the door with status.""" + data = self.coordinator.data + door_with_statuses = self._api.async_get_door_statuses_from_info(data) + return door_with_statuses[self._door_id] + + @property + def device_info(self) -> DeviceInfo: """Device info for the controller.""" data = self.coordinator.data - return { - "identifiers": {(DOMAIN, self._config_entry.unique_id)}, - "name": self._config_entry.title, - "manufacturer": MANUFACTURER, - "model": data.model, - "sw_version": data.firmwareversion, - } + configuration_url = ( + f"https://{data.remoteaccess}" if data.remoteaccess else None + ) + return DeviceInfo( + configuration_url=configuration_url, + identifiers={(DOMAIN, str(self._config_entry.unique_id))}, + name=self._config_entry.title, + manufacturer=MANUFACTURER, + model=data.model, + sw_version=data.firmwareversion, + ) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return {"door_id": self._door_id} def get_data_update_coordinator( @@ -149,7 +165,7 @@ def sensor_unique_id( return f"{config_entry.unique_id}_{door.door_id}_{sensor_type}" -def get_api(hass: HomeAssistant, config_data: dict) -> AbstractGateApi: +def get_api(hass: HomeAssistant, config_data: Mapping[str, Any]) -> AbstractGateApi: """Get an api object for config data.""" gate_class = GogoGate2Api diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index bfe740ecaa5ec..e97b62102c4f7 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -6,6 +6,8 @@ from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol +from homeassistant import data_entry_flow +from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_DEVICE, @@ -17,6 +19,11 @@ from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN +DEVICE_NAMES = { + DEVICE_TYPE_GOGOGATE2: "Gogogate2", + DEVICE_TYPE_ISMARTGATE: "ismartgate", +} + class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): """Gogogate2 config flow.""" @@ -28,18 +35,34 @@ def __init__(self): self._ip_address = None self._device_type = None - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> data_entry_flow.FlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id(discovery_info["properties"]["id"]) - self._abort_if_unique_id_configured({CONF_IP_ADDRESS: discovery_info["host"]}) + await self.async_set_unique_id( + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + ) + return await self._async_discovery_handler(discovery_info.host) - ip_address = discovery_info["host"] + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> data_entry_flow.FlowResult: + """Handle dhcp discovery.""" + await self.async_set_unique_id(discovery_info.macaddress) + return await self._async_discovery_handler(discovery_info.ip) - for entry in self._async_current_entries(): - if entry.data.get(CONF_IP_ADDRESS) == ip_address: - return self.async_abort(reason="already_configured") + async def _async_discovery_handler(self, ip_address): + """Start the user flow from any discovery.""" + self.context[CONF_IP_ADDRESS] = ip_address + self._abort_if_unique_id_configured({CONF_IP_ADDRESS: ip_address}) + + self._async_abort_entries_match({CONF_IP_ADDRESS: ip_address}) self._ip_address = ip_address + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_IP_ADDRESS) == self._ip_address: + raise data_entry_flow.AbortFlow("already_in_progress") + self._device_type = DEVICE_TYPE_ISMARTGATE return await self.async_step_user() @@ -85,6 +108,11 @@ async def async_step_user(self, user_input: dict = None): except Exception: # pylint: disable=broad-except errors["base"] = "cannot_connect" + if self._ip_address and self._device_type: + self.context["title_placeholders"] = { + CONF_DEVICE: DEVICE_NAMES[self._device_type], + CONF_IP_ADDRESS: self._ip_address, + } return self.async_show_form( step_id="user", data_schema=vol.Schema( diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 073c48e55b836..155c767eaea04 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,8 +1,6 @@ """Support for Gogogate2 garage Doors.""" from __future__ import annotations -import logging - from ismartgate.common import ( AbstractDoor, DoorStatus, @@ -11,10 +9,9 @@ ) from homeassistant.components.cover import ( - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_GATE, SUPPORT_CLOSE, SUPPORT_OPEN, + CoverDeviceClass, CoverEntity, ) from homeassistant.config_entries import ConfigEntry @@ -28,8 +25,6 @@ get_data_update_coordinator, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -59,64 +54,42 @@ def __init__( """Initialize the object.""" unique_id = cover_unique_id(config_entry, door) super().__init__(config_entry, data_update_coordinator, door, unique_id) - self._api = data_update_coordinator.api - self._is_available = True + self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + self._attr_device_class = ( + CoverDeviceClass.GATE if self.door.gate else CoverDeviceClass.GARAGE + ) @property def name(self): """Return the name of the door.""" - return self._get_door().name + return self.door.name @property def is_closed(self): """Return true if cover is closed, else False.""" - door_status = self._get_door_status() + door_status = self.door_status if door_status == DoorStatus.OPENED: return False if door_status == DoorStatus.CLOSED: return True - return None - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - if self._get_door().gate: - return DEVICE_CLASS_GATE - - return DEVICE_CLASS_GARAGE - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - @property def is_closing(self): """Return if the cover is closing or not.""" - return self._get_door_status() == TransitionDoorStatus.CLOSING + return self.door_status == TransitionDoorStatus.CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self._get_door_status() == TransitionDoorStatus.OPENING + return self.door_status == TransitionDoorStatus.OPENING async def async_open_cover(self, **kwargs): """Open the door.""" - await self._api.async_open_door(self._get_door().door_id) + await self._api.async_open_door(self._door_id) await self.coordinator.async_refresh() async def async_close_cover(self, **kwargs): """Close the door.""" - await self._api.async_close_door(self._get_door().door_id) + await self._api.async_close_door(self._door_id) await self.coordinator.async_refresh() - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"door_id": self._get_door().door_id} - - def _get_door_status(self) -> AbstractDoor: - return self._api.async_get_door_statuses_from_info(self.coordinator.data)[ - self._door.door_id - ] diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index a4c07fa1fb828..90d50bdda4335 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -1,12 +1,17 @@ { "domain": "gogogate2", - "name": "Gogogate2 and iSmartGate", + "name": "Gogogate2 and ismartgate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gogogate2", - "requirements": ["ismartgate==4.0.0"], + "requirements": ["ismartgate==4.0.4"], "codeowners": ["@vangorra", "@bdraco"], "homekit": { "models": ["iSmartGate"] }, + "dhcp": [ + { + "hostname": "ismartgate*" + } + ], "iot_class": "local_polling" } diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index 99edc8557337b..29c8f360963bc 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -5,14 +5,15 @@ from ismartgate.common import AbstractDoor, get_configured_doors -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import ( @@ -48,9 +49,24 @@ async def async_setup_entry( async_add_entities(sensors) -class DoorSensorBattery(GoGoGate2Entity, SensorEntity): +class DoorSensorEntity(GoGoGate2Entity, SensorEntity): + """Base class for door sensor entities.""" + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + attrs = super().extra_state_attributes + door = self.door + if door.sensorid is not None: + attrs["sensor_id"] = door.sensorid + return attrs + + +class DoorSensorBattery(DoorSensorEntity): """Battery sensor entity for gogogate2 door sensor.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__( self, config_entry: ConfigEntry, @@ -60,33 +76,22 @@ def __init__( """Initialize the object.""" unique_id = sensor_unique_id(config_entry, door, "battery") super().__init__(config_entry, data_update_coordinator, door, unique_id) + self._attr_device_class = SensorDeviceClass.BATTERY + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = PERCENTAGE @property def name(self): """Return the name of the door.""" - return f"{self._get_door().name} battery" + return f"{self.door.name} battery" @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_BATTERY - - @property - def state(self): + def native_value(self): """Return the state of the entity.""" - door = self._get_door() - return door.voltage # This is a percentage, not an absolute voltage + return self.door.voltage # This is a percentage, not an absolute voltage - @property - def extra_state_attributes(self): - """Return the state attributes.""" - door = self._get_door() - if door.sensorid is not None: - return {"door_id": door.door_id, "sensor_id": door.sensorid} - return None - -class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): +class DoorSensorTemperature(DoorSensorEntity): """Temperature sensor entity for gogogate2 door sensor.""" def __init__( @@ -98,32 +103,16 @@ def __init__( """Initialize the object.""" unique_id = sensor_unique_id(config_entry, door, "temperature") super().__init__(config_entry, data_update_coordinator, door, unique_id) + self._attr_device_class = SensorDeviceClass.TEMPERATURE + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = TEMP_CELSIUS @property def name(self): """Return the name of the door.""" - return f"{self._get_door().name} temperature" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_TEMPERATURE + return f"{self.door.name} temperature" @property - def state(self): + def native_value(self): """Return the state of the entity.""" - door = self._get_door() - return door.temperature - - @property - def unit_of_measurement(self): - """Return the unit_of_measurement.""" - return TEMP_CELSIUS - - @property - def device_state_attributes(self): - """Return the state attributes.""" - door = self._get_door() - if door.sensorid is not None: - return {"door_id": door.door_id, "sensor_id": door.sensorid} - return None + return self.door.temperature diff --git a/homeassistant/components/gogogate2/strings.json b/homeassistant/components/gogogate2/strings.json index f5385ff5d5424..7c165f90d0603 100644 --- a/homeassistant/components/gogogate2/strings.json +++ b/homeassistant/components/gogogate2/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{device} ({ip_address})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" @@ -9,7 +10,7 @@ }, "step": { "user": { - "title": "Setup GogoGate2 or iSmartGate", + "title": "Setup Gogogate2 or ismartgate", "description": "Provide requisite information below.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", diff --git a/homeassistant/components/gogogate2/translations/bg.json b/homeassistant/components/gogogate2/translations/bg.json new file mode 100644 index 0000000000000..48e0277066c84 --- /dev/null +++ b/homeassistant/components/gogogate2/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "flow_title": "{device} ({ip_address})", + "step": { + "user": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/ca.json b/homeassistant/components/gogogate2/translations/ca.json index a68c0e6384cbc..542a0cd2e0d0d 100644 --- a/homeassistant/components/gogogate2/translations/ca.json +++ b/homeassistant/components/gogogate2/translations/ca.json @@ -7,6 +7,7 @@ "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "Nom d'usuari" }, "description": "Proporciona, a continuaci\u00f3, la informaci\u00f3 necess\u00e0ria.", - "title": "Configuraci\u00f3 de GogoGate2 o iSmartGate" + "title": "Configuraci\u00f3 de Gogogate2 o ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/de.json b/homeassistant/components/gogogate2/translations/de.json index 5c0173a99cfe8..1ccb678e5c235 100644 --- a/homeassistant/components/gogogate2/translations/de.json +++ b/homeassistant/components/gogogate2/translations/de.json @@ -7,6 +7,7 @@ "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/en.json b/homeassistant/components/gogogate2/translations/en.json index b39bdfd7bb706..53e578526b229 100644 --- a/homeassistant/components/gogogate2/translations/en.json +++ b/homeassistant/components/gogogate2/translations/en.json @@ -7,6 +7,7 @@ "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "Username" }, "description": "Provide requisite information below.", - "title": "Setup GogoGate2 or iSmartGate" + "title": "Setup Gogogate2 or ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/es.json b/homeassistant/components/gogogate2/translations/es.json index 1498cc12368af..65d14508ce6cc 100644 --- a/homeassistant/components/gogogate2/translations/es.json +++ b/homeassistant/components/gogogate2/translations/es.json @@ -7,6 +7,7 @@ "cannot_connect": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/et.json b/homeassistant/components/gogogate2/translations/et.json index b3ab67388a1b4..966fca125c0e5 100644 --- a/homeassistant/components/gogogate2/translations/et.json +++ b/homeassistant/components/gogogate2/translations/et.json @@ -7,6 +7,7 @@ "cannot_connect": "\u00dchendamine nurjus", "invalid_auth": "Tuvastamine nurjus" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/fr.json b/homeassistant/components/gogogate2/translations/fr.json index 79f216738c4cc..94cee628a79df 100644 --- a/homeassistant/components/gogogate2/translations/fr.json +++ b/homeassistant/components/gogogate2/translations/fr.json @@ -7,6 +7,7 @@ "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/he.json b/homeassistant/components/gogogate2/translations/he.json new file mode 100644 index 0000000000000..53c141040223c --- /dev/null +++ b/homeassistant/components/gogogate2/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "flow_title": "{device} ({ip_address})", + "step": { + "user": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/hu.json b/homeassistant/components/gogogate2/translations/hu.json index cdc76a4145aa2..30d6ef5c016a4 100644 --- a/homeassistant/components/gogogate2/translations/hu.json +++ b/homeassistant/components/gogogate2/translations/hu.json @@ -7,6 +7,7 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -14,6 +15,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Adja meg a sz\u00fcks\u00e9ges inform\u00e1ci\u00f3kat al\u00e1bb.", "title": "A GogoGate2 vagy az iSmartGate be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/gogogate2/translations/id.json b/homeassistant/components/gogogate2/translations/id.json index 9de61641d41e2..04029205389bc 100644 --- a/homeassistant/components/gogogate2/translations/id.json +++ b/homeassistant/components/gogogate2/translations/id.json @@ -7,6 +7,7 @@ "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "Nama Pengguna" }, "description": "Berikan informasi yang diperlukan di bawah ini.", - "title": "Siapkan GogoGate2 atau iSmartGate" + "title": "Siapkan GogoGate2 atau ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/it.json b/homeassistant/components/gogogate2/translations/it.json index 7b1dbe4e3e4fa..71510b90040a0 100644 --- a/homeassistant/components/gogogate2/translations/it.json +++ b/homeassistant/components/gogogate2/translations/it.json @@ -7,6 +7,7 @@ "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "Nome utente" }, "description": "Fornire le informazioni richieste di seguito.", - "title": "Configurazione di GogoGate2 o iSmartGate" + "title": "Imposta Gogogate2 o ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/ja.json b/homeassistant/components/gogogate2/translations/ja.json new file mode 100644 index 0000000000000..d1c4eb62b9297 --- /dev/null +++ b/homeassistant/components/gogogate2/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{device} ({ip_address})", + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u4ee5\u4e0b\u306b\u5fc5\u8981\u306a\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Gogogate2\u307e\u305f\u306fismartgate\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/nl.json b/homeassistant/components/gogogate2/translations/nl.json index 5418735ec07a9..a32bb1af69bc2 100644 --- a/homeassistant/components/gogogate2/translations/nl.json +++ b/homeassistant/components/gogogate2/translations/nl.json @@ -7,6 +7,7 @@ "cannot_connect": "Kon niet verbinden", "invalid_auth": "Ongeldige authenticatie" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/no.json b/homeassistant/components/gogogate2/translations/no.json index 315a5fa9e2aa3..6640d35681a31 100644 --- a/homeassistant/components/gogogate2/translations/no.json +++ b/homeassistant/components/gogogate2/translations/no.json @@ -7,6 +7,7 @@ "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning" }, + "flow_title": "{device} ( {ip_address} )", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "Brukernavn" }, "description": "Gi n\u00f8dvendig informasjon nedenfor.", - "title": "Sett opp GogoGate2 eller iSmartGate" + "title": "Sett opp Gogogate2 eller ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/pl.json b/homeassistant/components/gogogate2/translations/pl.json index 6569c444c2036..fbd2c7af42e33 100644 --- a/homeassistant/components/gogogate2/translations/pl.json +++ b/homeassistant/components/gogogate2/translations/pl.json @@ -7,6 +7,7 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_auth": "Niepoprawne uwierzytelnienie" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "Nazwa u\u017cytkownika" }, "description": "Wprowad\u017a wymagane informacje poni\u017cej.", - "title": "Konfiguracja GogoGate2 lub iSmartGate" + "title": "Konfiguracja Gogogate2 lub ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/ru.json b/homeassistant/components/gogogate2/translations/ru.json index 4efa554fc913d..0ff1fbd766232 100644 --- a/homeassistant/components/gogogate2/translations/ru.json +++ b/homeassistant/components/gogogate2/translations/ru.json @@ -7,6 +7,7 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -14,8 +15,8 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 GogoGate2.", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 GogoGate2 \u0438\u043b\u0438 iSmartGate" + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 GogoGate2.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Gogogate2 \u0438\u043b\u0438 ismartgate" } } } diff --git a/homeassistant/components/gogogate2/translations/tr.json b/homeassistant/components/gogogate2/translations/tr.json index e912e7f8012f8..bd16dbe72f247 100644 --- a/homeassistant/components/gogogate2/translations/tr.json +++ b/homeassistant/components/gogogate2/translations/tr.json @@ -7,13 +7,16 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { - "ip_address": "\u0130p Adresi", + "ip_address": "IP Adresi", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "A\u015fa\u011f\u0131da gerekli bilgileri sa\u011flay\u0131n.", + "title": "Gogogate2 veya ismartgate'i kurun" } } } diff --git a/homeassistant/components/gogogate2/translations/zh-Hant.json b/homeassistant/components/gogogate2/translations/zh-Hant.json index 607794131ef25..0e11373b12903 100644 --- a/homeassistant/components/gogogate2/translations/zh-Hant.json +++ b/homeassistant/components/gogogate2/translations/zh-Hant.json @@ -7,6 +7,7 @@ "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { @@ -15,7 +16,7 @@ "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, "description": "\u8acb\u65bc\u4e0b\u65b9\u63d0\u4f9b\u6240\u9700\u8cc7\u8a0a\u3002", - "title": "\u8a2d\u5b9a GogoGate2 \u6216 iSmartGate" + "title": "\u8a2d\u5b9a Gogogate2 \u6216 ismartgate" } } } diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index b46d48848daae..08663a297d2da 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,5 +1,6 @@ """Support for Google - Calendar Event Devices.""" -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from enum import Enum import logging import os @@ -26,8 +27,8 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.event import track_time_change -from homeassistant.util import convert, dt +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import convert _LOGGER = logging.getLogger(__name__) @@ -41,6 +42,7 @@ CONF_SEARCH = "search" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_MAX_RESULTS = "max_results" +CONF_CALENDAR_ACCESS = "calendar_access" DEFAULT_CONF_TRACK_NEW = True DEFAULT_CONF_OFFSET = "!!" @@ -70,10 +72,26 @@ DATA_INDEX = "google_calendars" YAML_DEVICES = f"{DOMAIN}_calendars.yaml" -SCOPES = "https://www.googleapis.com/auth/calendar" TOKEN_FILE = f".{DOMAIN}.token" + +class FeatureAccess(Enum): + """Class to represent different access scopes.""" + + read_only = "https://www.googleapis.com/auth/calendar.readonly" + read_write = "https://www.googleapis.com/auth/calendar" + + def __init__(self, scope: str) -> None: + """Init instance.""" + self._scope = scope + + @property + def scope(self) -> str: + """Google calendar scope for the feature.""" + return self._scope + + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -81,22 +99,28 @@ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_TRACK_NEW): cv.boolean, + vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum( + FeatureAccess + ), } ) }, extra=vol.ALLOW_EXTRA, ) -_SINGLE_CALSEARCH_CONFIG = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, - vol.Optional(CONF_OFFSET): cv.string, - vol.Optional(CONF_SEARCH): cv.string, - vol.Optional(CONF_TRACK): cv.boolean, - vol.Optional(CONF_MAX_RESULTS): cv.positive_int, - } +_SINGLE_CALSEARCH_CONFIG = vol.All( + cv.deprecated(CONF_MAX_RESULTS), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, + vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_SEARCH): cv.string, + vol.Optional(CONF_TRACK): cv.boolean, + vol.Optional(CONF_MAX_RESULTS): cv.positive_int, # Now unused + } + ), ) DEVICE_SCHEMA = vol.Schema( @@ -139,7 +163,7 @@ def do_authentication(hass, hass_config, config): oauth = OAuth2WebServerFlow( client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_CLIENT_SECRET], - scope="https://www.googleapis.com/auth/calendar", + scope=config[CONF_CALENDAR_ACCESS].scope, redirect_uri="Home-Assistant.io", ) try: @@ -164,7 +188,12 @@ def do_authentication(hass, hass_config, config): def step2_exchange(now): """Keep trying to validate the user_code until it expires.""" - if now >= dt.as_local(dev_flow.user_code_expiry): + + # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime + # object without tzinfo. For the comparison below to work, it needs one. + user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc) + + if now >= user_code_expiry: hass.components.persistent_notification.create( "Authentication code expired, please restart " "Home-Assistant and try again", @@ -192,7 +221,7 @@ def step2_exchange(now): notification_id=NOTIFICATION_ID, ) - listener = track_time_change( + listener = track_utc_time_change( hass, step2_exchange, second=range(0, 60, dev_flow.interval) ) @@ -204,8 +233,7 @@ def setup(hass, config): if DATA_INDEX not in hass.data: hass.data[DATA_INDEX] = {} - conf = config.get(DOMAIN, {}) - if not conf: + if not (conf := config.get(DOMAIN, {})): # component is set up by tts platform return True @@ -213,7 +241,7 @@ def setup(hass, config): if not os.path.isfile(token_file): do_authentication(hass, config, conf) else: - if not check_correct_scopes(token_file): + if not check_correct_scopes(token_file, conf): do_authentication(hass, config, conf) else: do_setup(hass, config, conf) @@ -221,16 +249,22 @@ def setup(hass, config): return True -def check_correct_scopes(token_file): +def check_correct_scopes(token_file, config): """Check for the correct scopes in file.""" - tokenfile = open(token_file).read() - if "readonly" in tokenfile: - _LOGGER.warning("Please re-authenticate with Google") - return False + with open(token_file, encoding="utf8") as tokenfile: + contents = tokenfile.read() + + # Check for quoted scope as our scopes can be subsets of other scopes + target_scope = f'"{config.get(CONF_CALENDAR_ACCESS).scope}"' + if target_scope not in contents: + _LOGGER.warning("Please re-authenticate with Google") + return False return True -def setup_services(hass, hass_config, track_new_found_calendars, calendar_service): +def setup_services( + hass, hass_config, config, track_new_found_calendars, calendar_service +): """Set up the service listeners.""" def _found_calendar(call): @@ -312,9 +346,11 @@ def _add_event(call): service_data = {"calendarId": call.data[EVENT_CALENDAR_ID], "body": event} event = service.events().insert(**service_data).execute() - hass.services.register( - DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA - ) + # Only expose the add event service if we have the correct permissions + if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write: + hass.services.register( + DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA + ) return True @@ -327,7 +363,9 @@ def do_setup(hass, hass_config, config): track_new_found_calendars = convert( config.get(CONF_TRACK_NEW), bool, DEFAULT_CONF_TRACK_NEW ) - setup_services(hass, hass_config, track_new_found_calendars, calendar_service) + setup_services( + hass, hass_config, config, track_new_found_calendars, calendar_service + ) for calendar in hass.data[DATA_INDEX].values(): discovery.load_platform(hass, "calendar", DOMAIN, calendar, hass_config) @@ -377,7 +415,7 @@ def load_config(path): """Load the google_calendar_devices.yaml.""" calendars = {} try: - with open(path) as file: + with open(path, encoding="utf8") as file: data = yaml.safe_load(file) for calendar in data: try: @@ -394,6 +432,6 @@ def load_config(path): def update_config(path, calendar): """Write the google_calendar_devices.yaml.""" - with open(path, "a") as out: + with open(path, "a", encoding="utf8") as out: out.write("\n") yaml.dump([calendar], out, default_flow_style=False) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 2cc6612194800..7381e37d2aa90 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -18,7 +18,6 @@ from . import ( CONF_CAL_ID, CONF_IGNORE_AVAILABILITY, - CONF_MAX_RESULTS, CONF_SEARCH, CONF_TRACK, DEFAULT_CONF_OFFSET, @@ -30,7 +29,6 @@ DEFAULT_GOOGLE_SEARCH_PARAMS = { "orderBy": "startTime", - "maxResults": 5, "singleEvents": True, } @@ -71,7 +69,6 @@ def __init__(self, calendar_service, calendar, data, entity_id): calendar, data.get(CONF_SEARCH), data.get(CONF_IGNORE_AVAILABILITY), - data.get(CONF_MAX_RESULTS), ) self._event = None self._name = data[CONF_NAME] @@ -113,27 +110,24 @@ def update(self): class GoogleCalendarData: """Class to utilize calendar service object to get next event.""" - def __init__( - self, calendar_service, calendar_id, search, ignore_availability, max_results - ): + def __init__(self, calendar_service, calendar_id, search, ignore_availability): """Set up how we are going to search the google calendar.""" self.calendar_service = calendar_service self.calendar_id = calendar_id self.search = search self.ignore_availability = ignore_availability - self.max_results = max_results self.event = None def _prepare_query(self): try: service = self.calendar_service.get() - except ServerNotFoundError: - _LOGGER.error("Unable to connect to Google") + except ServerNotFoundError as err: + _LOGGER.error("Unable to connect to Google: %s", err) return None, None params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) params["calendarId"] = self.calendar_id - if self.max_results: - params["maxResults"] = self.max_results + params["maxResults"] = 100 # Page size + if self.search: params["q"] = self.search @@ -147,18 +141,30 @@ async def async_get_events(self, hass, start_date, end_date): params["timeMin"] = start_date.isoformat("T") params["timeMax"] = end_date.isoformat("T") + event_list = [] events = await hass.async_add_executor_job(service.events) + page_token = None + while True: + page_token = await self.async_get_events_page( + hass, events, params, page_token, event_list + ) + if not page_token: + break + return event_list + + async def async_get_events_page(self, hass, events, params, page_token, event_list): + """Get a page of events in a specific time frame.""" + params["pageToken"] = page_token result = await hass.async_add_executor_job(events.list(**params).execute) items = result.get("items", []) - event_list = [] for item in items: if not self.ignore_availability and "transparency" in item: if item["transparency"] == "opaque": event_list.append(item) else: event_list.append(item) - return event_list + return result.get("nextPageToken") @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 9b6f7d77f2698..e96cf4ec0c68c 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -1,7 +1,7 @@ { "domain": "google", "name": "Google Calendars", - "documentation": "https://www.home-assistant.io/integrations/google", + "documentation": "https://www.home-assistant.io/integrations/calendar.google/", "requirements": [ "google-api-python-client==1.6.4", "httplib2==0.19.0", diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 13516783233ad..1e0c0a0611470 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -2,14 +2,13 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol -# Typing imports from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ALIASES, @@ -91,7 +90,7 @@ def _check_report_state(data): ) -async def async_setup(hass: HomeAssistant, yaml_config: dict[str, Any]): +async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Actions component.""" if DOMAIN not in yaml_config: return True diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index d6badf2e7ba01..efeb62deb8efd 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -2,6 +2,7 @@ from homeassistant.components import ( alarm_control_panel, binary_sensor, + button, camera, climate, cover, @@ -9,12 +10,14 @@ group, humidifier, input_boolean, + input_button, input_select, light, lock, media_player, scene, script, + select, sensor, switch, vacuum, @@ -39,6 +42,8 @@ DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ + "alarm_control_panel", + "binary_sensor", "climate", "cover", "fan", @@ -47,15 +52,14 @@ "input_boolean", "input_select", "light", + "lock", "media_player", "scene", "script", + "select", + "sensor", "switch", "vacuum", - "lock", - "binary_sensor", - "sensor", - "alarm_control_panel", ] PREFIX_TYPES = "action.devices.types." @@ -99,6 +103,7 @@ ERR_UNKNOWN_ERROR = "unknownError" ERR_FUNCTION_NOT_SUPPORTED = "functionNotSupported" ERR_UNSUPPORTED_INPUT = "unsupportedInput" +ERR_NO_AVAILABLE_CHANNEL = "noAvailableChannel" ERR_ALREADY_DISARMED = "alreadyDisarmed" ERR_ALREADY_ARMED = "alreadyArmed" @@ -116,6 +121,8 @@ EVENT_SYNC_RECEIVED = "google_assistant_sync" DOMAIN_TO_GOOGLE_TYPES = { + alarm_control_panel.DOMAIN: TYPE_ALARM, + button.DOMAIN: TYPE_SCENE, camera.DOMAIN: TYPE_CAMERA, climate.DOMAIN: TYPE_THERMOSTAT, cover.DOMAIN: TYPE_BLINDS, @@ -123,37 +130,45 @@ group.DOMAIN: TYPE_SWITCH, humidifier.DOMAIN: TYPE_HUMIDIFIER, input_boolean.DOMAIN: TYPE_SWITCH, + input_button.DOMAIN: TYPE_SCENE, input_select.DOMAIN: TYPE_SENSOR, light.DOMAIN: TYPE_LIGHT, lock.DOMAIN: TYPE_LOCK, media_player.DOMAIN: TYPE_SETTOP, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, + sensor.DOMAIN: TYPE_SENSOR, + select.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, - alarm_control_panel.DOMAIN: TYPE_ALARM, } DEVICE_CLASS_TO_GOOGLE_TYPES = { - (cover.DOMAIN, cover.DEVICE_CLASS_GARAGE): TYPE_GARAGE, - (cover.DOMAIN, cover.DEVICE_CLASS_GATE): TYPE_GARAGE, - (cover.DOMAIN, cover.DEVICE_CLASS_DOOR): TYPE_DOOR, - (cover.DOMAIN, cover.DEVICE_CLASS_AWNING): TYPE_AWNING, - (cover.DOMAIN, cover.DEVICE_CLASS_SHUTTER): TYPE_SHUTTER, - (switch.DOMAIN, switch.DEVICE_CLASS_SWITCH): TYPE_SWITCH, - (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET, - (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_DOOR, - (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_GARAGE_DOOR): TYPE_GARAGE, - (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, - (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, - (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, - (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, - (media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER, - (media_player.DOMAIN, media_player.DEVICE_CLASS_RECEIVER): TYPE_RECEIVER, - (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, - (sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR, - (humidifier.DOMAIN, humidifier.DEVICE_CLASS_HUMIDIFIER): TYPE_HUMIDIFIER, - (humidifier.DOMAIN, humidifier.DEVICE_CLASS_DEHUMIDIFIER): TYPE_DEHUMIDIFIER, + (cover.DOMAIN, cover.CoverDeviceClass.GARAGE): TYPE_GARAGE, + (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, + (cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR, + (cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING, + (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, + (switch.DOMAIN, switch.SwitchDeviceClass.SWITCH): TYPE_SWITCH, + (switch.DOMAIN, switch.SwitchDeviceClass.OUTLET): TYPE_OUTLET, + (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.DOOR): TYPE_DOOR, + ( + binary_sensor.DOMAIN, + binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, + ): TYPE_GARAGE, + (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.LOCK): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.OPENING): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.WINDOW): TYPE_SENSOR, + (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV, + (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER, + (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER, + (sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR, + (sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR, + (humidifier.DOMAIN, humidifier.HumidifierDeviceClass.HUMIDIFIER): TYPE_HUMIDIFIER, + ( + humidifier.DOMAIN, + humidifier.HumidifierDeviceClass.DEHUMIDIFIER, + ): TYPE_DEHUMIDIFIER, } CHALLENGE_ACK_NEEDED = "ackNeeded" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 752f40a0ead60..238ee8d957648 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from asyncio import gather from collections.abc import Mapping +from http import HTTPStatus import logging import pprint @@ -15,10 +16,10 @@ ATTR_SUPPORTED_FEATURES, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, - EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, CoreState, HomeAssistant, State, callback +from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.helpers import start from homeassistant.helpers.area_registry import AreaEntry from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -53,8 +54,7 @@ async def _get_entity_and_device( hass.helpers.entity_registry.async_get_registry(), ) - entity_entry = ent_reg.async_get(entity_id) - if not entity_entry: + if not (entity_entry := ent_reg.async_get(entity_id)): return None, None device_entry = dev_reg.devices.get(entity_entry.device_id) return entity_entry, device_entry @@ -105,15 +105,14 @@ async def async_initialize(self): self._store = GoogleConfigStore(self.hass) await self._store.async_load() - if self.hass.state == CoreState.running: - await self.async_sync_entities_all() + if not self.enabled: return async def sync_google(_): """Sync entities to Google.""" await self.async_sync_entities_all() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, sync_google) + start.async_at_start(self.hass, sync_google) @property def enabled(self): @@ -205,17 +204,17 @@ async def async_sync_entities(self, agent_user_id: str): # Remove any pending sync self._google_sync_unsub.pop(agent_user_id, lambda: None)() status = await self._async_request_sync_devices(agent_user_id) - if status == 404: + if status == HTTPStatus.NOT_FOUND: await self.async_disconnect_agent_user(agent_user_id) return status async def async_sync_entities_all(self): """Sync all entities to Google for all registered agents.""" res = await gather( - *[ + *( self.async_sync_entities(agent_user_id) for agent_user_id in self._store.agent_user_ids - ] + ) ) return max(res, default=204) @@ -264,9 +263,7 @@ async def async_disconnect_agent_user(self, agent_user_id: str): @callback def async_enable_local_sdk(self): """Enable the local SDK.""" - webhook_id = self.local_sdk_webhook_id - - if webhook_id is None: + if (webhook_id := self.local_sdk_webhook_id) is None: return try: @@ -349,8 +346,7 @@ def pop_agent_user_id(self, agent_user_id): async def async_load(self): """Store current configuration to disk.""" - data = await self._store.async_load() - if data: + if data := await self._store.async_load(): self._data = data @@ -364,7 +360,7 @@ def __init__( source: str, request_id: str, devices: list[dict] | None, - ): + ) -> None: """Initialize the request data.""" self.config = config self.source = source @@ -388,7 +384,9 @@ def get_google_type(domain, device_class): class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - def __init__(self, hass: HomeAssistant, config: AbstractConfig, state: State): + def __init__( + self, hass: HomeAssistant, config: AbstractConfig, state: State + ) -> None: """Initialize a Google entity.""" self.hass = hass self.config = config @@ -499,8 +497,7 @@ async def sync_serialize(self, agent_user_id): } # use aliases - aliases = entity_config.get(CONF_ALIASES) - if aliases: + if aliases := entity_config.get(CONF_ALIASES): device["name"]["nicknames"] = [name] + aliases if self.config.is_local_sdk_active and self.should_expose_local(): @@ -517,16 +514,14 @@ async def sync_serialize(self, agent_user_id): for trt in traits: device["attributes"].update(trt.sync_attributes()) - room = entity_config.get(CONF_ROOM_HINT) - if room: + if room := entity_config.get(CONF_ROOM_HINT): device["roomHint"] = room else: area = await _get_area(self.hass, entity_entry, device_entry) if area and area.name: device["roomHint"] = area.name - device_info = await _get_device_info(device_entry) - if device_info: + if device_info := await _get_device_info(device_entry): device["deviceInfo"] = device_info return device diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 3787a63a514f5..e7a73351f6012 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,6 +1,7 @@ """Support for Google Actions Smart Home Control.""" import asyncio from datetime import timedelta +from http import HTTPStatus import logging from uuid import uuid4 @@ -10,11 +11,8 @@ # Typing imports from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, ENTITY_CATEGORIES +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util @@ -51,7 +49,7 @@ def _get_homegraph_jwt(time, iss, key): "iat": now, "exp": now + 3600, } - return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8") + return jwt.encode(jwt_raw, key, algorithm="RS256") async def _get_homegraph_token(hass, jwt_signed): @@ -112,16 +110,27 @@ def should_expose(self, state) -> bool: if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False + entity_registry = er.async_get(self.hass) + registry_entry = entity_registry.async_get(state.entity_id) + if registry_entry: + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES + else: + auxiliary_entity = False + explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = ( expose_by_default and state.domain in exposed_domains ) - # Expose an entity if the entity's domain is exposed by default and + # Expose an entity by default if the entity's domain is exposed by default + # and the entity is not a config or diagnostic entity + entity_exposed_by_default = domain_exposed_by_default and not auxiliary_entity + + # Expose an entity if the entity's is exposed by default and # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed - is_default_exposed = domain_exposed_by_default and explicit_expose is not False + is_default_exposed = entity_exposed_by_default and explicit_expose is not False return is_default_exposed or explicit_expose @@ -140,7 +149,7 @@ async def _async_request_sync_devices(self, agent_user_id: str): ) _LOGGER.error("No configuration for request_sync available") - return HTTP_INTERNAL_SERVER_ERROR + return HTTPStatus.INTERNAL_SERVER_ERROR async def _async_update_token(self, force=False): if CONF_SERVICE_ACCOUNT not in self._config: @@ -181,7 +190,7 @@ async def _call(): try: return await _call() except ClientResponseError as error: - if error.status == HTTP_UNAUTHORIZED: + if error.status == HTTPStatus.UNAUTHORIZED: _LOGGER.warning( "Request for %s unauthorized, renewing token and retrying", url ) @@ -193,7 +202,7 @@ async def _call(): return error.status except (asyncio.TimeoutError, ClientError): _LOGGER.error("Could not contact %s", url) - return HTTP_INTERNAL_SERVER_ERROR + return HTTPStatus.INTERNAL_SERVER_ERROR async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index f7c57732876b4..c3f8ba3bffda4 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -106,7 +106,7 @@ def extra_significant_check( """Check if the serialized data has changed.""" return old_extra_arg != new_extra_arg - async def inital_report(_now): + async def initial_report(_now): """Report initially all states.""" nonlocal unsub, checker entities = {} @@ -140,7 +140,7 @@ async def inital_report(_now): MATCH_ALL, async_entity_state_listener ) - unsub = async_call_later(hass, INITIAL_REPORT_DELAY, inital_report) + unsub = async_call_later(hass, INITIAL_REPORT_DELAY, initial_report) @callback def unsub_all(): diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 747dc234efe2c..eb7b5e9c9ebb2 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -17,6 +17,8 @@ from .error import SmartHomeError from .helpers import GoogleEntity, RequestData, async_get_entities +EXECUTE_LIMIT = 2 # Wait 2 seconds for execute to finish + HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -45,9 +47,7 @@ async def _process(hass, data, message): "payload": {"errorCode": ERR_PROTOCOL_ERROR}, } - handler = HANDLERS.get(inputs[0].get("intent")) - - if handler is None: + if (handler := HANDLERS.get(inputs[0].get("intent"))) is None: return { "requestId": data.request_id, "payload": {"errorCode": ERR_PROTOCOL_ERROR}, @@ -131,9 +131,8 @@ async def async_devices_query(hass, data, payload): devices = {} for device in payload_devices: devid = device["id"] - state = hass.states.get(devid) - if not state: + if not (state := hass.states.get(devid)): # If we can't find a state, the device is offline devices[devid] = {"online": False} continue @@ -199,9 +198,7 @@ async def handle_devices_execute(hass, data, payload): executions[entity_id].append(execution) continue - state = hass.states.get(entity_id) - - if state is None: + if (state := hass.states.get(entity_id)) is None: results[entity_id] = { "ids": [entity_id], "status": "ERROR", @@ -212,16 +209,23 @@ async def handle_devices_execute(hass, data, payload): entities[entity_id] = GoogleEntity(hass, data.config, state) executions[entity_id] = [execution] - execute_results = await asyncio.gather( - *[ - _entity_execute(entities[entity_id], data, executions[entity_id]) - for entity_id in executions - ] - ) - - for entity_id, result in zip(executions, execute_results): - if result is not None: - results[entity_id] = result + try: + execute_results = await asyncio.wait_for( + asyncio.shield( + asyncio.gather( + *( + _entity_execute(entities[entity_id], data, execution) + for entity_id, execution in executions.items() + ) + ) + ), + EXECUTE_LIMIT, + ) + for entity_id, result in zip(executions, execute_results): + if result is not None: + results[entity_id] = result + except asyncio.TimeoutError: + pass final_results = list(results.values()) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 64f803dab25b2..26cce0bd5a5f8 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -6,32 +6,38 @@ from homeassistant.components import ( alarm_control_panel, binary_sensor, + button, camera, cover, fan, group, input_boolean, + input_button, input_select, light, lock, media_player, scene, script, + select, sensor, switch, vacuum, ) from homeassistant.components.climate import const as climate from homeassistant.components.humidifier import const as humidifier +from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING +from homeassistant.components.media_player.const import MEDIA_TYPE_CHANNEL from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_BATTERY_LEVEL, ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_MODE, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - CAST_APP_ID_HOMEASSISTANT, + CAST_APP_ID_HOMEASSISTANT_MEDIA, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, @@ -71,6 +77,8 @@ ERR_ALREADY_DISARMED, ERR_ALREADY_STOPPED, ERR_CHALLENGE_NOT_SETUP, + ERR_FUNCTION_NOT_SUPPORTED, + ERR_NO_AVAILABLE_CHANNEL, ERR_NOT_SUPPORTED, ERR_UNSUPPORTED_INPUT, ERR_VALUE_OUT_OF_RANGE, @@ -99,6 +107,10 @@ TRAIT_HUMIDITY_SETTING = f"{PREFIX_TRAITS}HumiditySetting" TRAIT_TRANSPORT_CONTROL = f"{PREFIX_TRAITS}TransportControl" TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" +TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" +TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" +TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage" +TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -118,6 +130,7 @@ COMMAND_THERMOSTAT_SET_MODE = f"{PREFIX_COMMANDS}ThermostatSetMode" COMMAND_LOCKUNLOCK = f"{PREFIX_COMMANDS}LockUnlock" COMMAND_FANSPEED = f"{PREFIX_COMMANDS}SetFanSpeed" +COMMAND_FANSPEEDRELATIVE = f"{PREFIX_COMMANDS}SetFanSpeedRelative" COMMAND_MODES = f"{PREFIX_COMMANDS}SetModes" COMMAND_INPUT = f"{PREFIX_COMMANDS}SetInput" COMMAND_NEXT_INPUT = f"{PREFIX_COMMANDS}NextInput" @@ -136,8 +149,11 @@ COMMAND_MEDIA_SEEK_TO_POSITION = f"{PREFIX_COMMANDS}mediaSeekToPosition" COMMAND_MEDIA_SHUFFLE = f"{PREFIX_COMMANDS}mediaShuffle" COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop" +COMMAND_REVERSE = f"{PREFIX_COMMANDS}Reverse" COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" - +COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel" +COMMAND_LOCATE = f"{PREFIX_COMMANDS}Locate" +COMMAND_CHARGE = f"{PREFIX_COMMANDS}Charge" TRAITS = [] @@ -241,9 +257,7 @@ def query_attributes(self): async def execute(self, command, data, params, challenge): """Execute a brightness command.""" - domain = self.state.domain - - if domain == light.DOMAIN: + if self.state.domain == light.DOMAIN: await self.hass.services.async_call( light.DOMAIN, light.SERVICE_TURN_ON, @@ -251,7 +265,7 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params["brightness"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -295,7 +309,7 @@ async def execute(self, command, data, params, challenge): ) self.stream_info = { "cameraStreamAccessUrl": f"{get_url(self.hass)}{url}", - "cameraStreamReceiverAppId": CAST_APP_ID_HOMEASSISTANT, + "cameraStreamReceiverAppId": CAST_APP_ID_HOMEASSISTANT_MEDIA, } @@ -334,9 +348,7 @@ def query_attributes(self): async def execute(self, command, data, params, challenge): """Execute an OnOff command.""" - domain = self.state.domain - - if domain == group.DOMAIN: + if (domain := self.state.domain) == group.DOMAIN: service_domain = HA_DOMAIN service = SERVICE_TURN_ON if params["on"] else SERVICE_TURN_OFF @@ -348,7 +360,7 @@ async def execute(self, command, data, params, challenge): service_domain, service, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -399,10 +411,11 @@ def sync_attributes(self): def query_attributes(self): """Return color temperature query attributes.""" - color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) + color_mode = self.state.attributes.get(light.ATTR_COLOR_MODE) + color = {} - if light.color_supported(color_modes): + if light.color_supported([color_mode]): color_hs = self.state.attributes.get(light.ATTR_HS_COLOR) brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS, 1) if color_hs is not None: @@ -412,7 +425,7 @@ def query_attributes(self): "value": brightness / 255, } - if light.color_temp_supported(color_modes): + if light.color_temp_supported([color_mode]): temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) # Some faulty integrations might put 0 in here, raising exception. if temp == 0: @@ -452,7 +465,7 @@ async def execute(self, command, data, params, challenge): light.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -467,7 +480,7 @@ async def execute(self, command, data, params, challenge): light.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_HS_COLOR: color}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -484,7 +497,7 @@ async def execute(self, command, data, params, challenge): light.ATTR_HS_COLOR: [color["hue"], saturation], light.ATTR_BRIGHTNESS: brightness, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -502,11 +515,16 @@ class SceneTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain in (scene.DOMAIN, script.DOMAIN) + return domain in ( + button.DOMAIN, + input_button.DOMAIN, + scene.DOMAIN, + script.DOMAIN, + ) def sync_attributes(self): """Return scene attributes for a sync request.""" - # Neither supported domain can support sceneReversible + # None of the supported domains can support sceneReversible return {} def query_attributes(self): @@ -515,12 +533,20 @@ def query_attributes(self): async def execute(self, command, data, params, challenge): """Execute a scene command.""" - # Don't block for scripts as they can be slow. + service = SERVICE_TURN_ON + if self.state.domain == button.DOMAIN: + service = button.SERVICE_PRESS + elif self.state.domain == input_button.DOMAIN: + service = input_button.SERVICE_PRESS + + # Don't block for scripts or buttons, as they can be slow. await self.hass.services.async_call( self.state.domain, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=self.state.domain != script.DOMAIN, + blocking=(not self.config.should_report_state) + and self.state.domain + not in (button.DOMAIN, input_button.DOMAIN, script.DOMAIN), context=data.context, ) @@ -554,11 +580,104 @@ async def execute(self, command, data, params, challenge): self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) +@register_trait +class LocatorTrait(_Trait): + """Trait to offer locate functionality. + + https://developers.google.com/actions/smarthome/traits/locator + """ + + name = TRAIT_LOCATOR + commands = [COMMAND_LOCATE] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_LOCATE + + def sync_attributes(self): + """Return locator attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return locator query attributes.""" + return {} + + async def execute(self, command, data, params, challenge): + """Execute a locate command.""" + if params.get("silence", False): + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + "Silencing a Locate request is not yet supported", + ) + + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_LOCATE, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) + + +@register_trait +class EnergyStorageTrait(_Trait): + """Trait to offer EnergyStorage functionality. + + https://developers.google.com/actions/smarthome/traits/energystorage + """ + + name = TRAIT_ENERGYSTORAGE + commands = [COMMAND_CHARGE] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_BATTERY + + def sync_attributes(self): + """Return EnergyStorage attributes for a sync request.""" + return { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + def query_attributes(self): + """Return EnergyStorage query attributes.""" + battery_level = self.state.attributes.get(ATTR_BATTERY_LEVEL) + if battery_level == 100: + descriptive_capacity_remaining = "FULL" + elif 75 <= battery_level < 100: + descriptive_capacity_remaining = "HIGH" + elif 50 <= battery_level < 75: + descriptive_capacity_remaining = "MEDIUM" + elif 25 <= battery_level < 50: + descriptive_capacity_remaining = "LOW" + elif 0 <= battery_level < 25: + descriptive_capacity_remaining = "CRITICALLY_LOW" + return { + "descriptiveCapacityRemaining": descriptive_capacity_remaining, + "capacityRemaining": [{"rawValue": battery_level, "unit": "PERCENTAGE"}], + "capacityUntilFull": [ + {"rawValue": 100 - battery_level, "unit": "PERCENTAGE"} + ], + "isCharging": self.state.state == vacuum.STATE_DOCKED, + "isPluggedIn": self.state.state == vacuum.STATE_DOCKED, + } + + async def execute(self, command, data, params, challenge): + """Execute a dock command.""" + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + "Controlling charging of a vacuum is not yet supported", + ) + + @register_trait class StartStopTrait(_Trait): """Trait to offer StartStop functionality. @@ -622,7 +741,7 @@ async def _execute_vacuum(self, command, data, params, challenge): self.state.domain, vacuum.SERVICE_START, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -630,7 +749,7 @@ async def _execute_vacuum(self, command, data, params, challenge): self.state.domain, vacuum.SERVICE_STOP, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) elif command == COMMAND_PAUSEUNPAUSE: @@ -639,7 +758,7 @@ async def _execute_vacuum(self, command, data, params, challenge): self.state.domain, vacuum.SERVICE_PAUSE, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -647,7 +766,7 @@ async def _execute_vacuum(self, command, data, params, challenge): self.state.domain, vacuum.SERVICE_START, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -667,7 +786,7 @@ async def _execute_cover(self, command, data, params, challenge): self.state.domain, cover.SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -697,7 +816,8 @@ class TemperatureControlTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" return ( - domain == sensor.DOMAIN and device_class == sensor.DEVICE_CLASS_TEMPERATURE + domain == sensor.DOMAIN + and device_class == sensor.SensorDeviceClass.TEMPERATURE ) def sync_attributes(self): @@ -846,16 +966,14 @@ def query_attributes(self): 1, ) else: - target_temp = attrs.get(ATTR_TEMPERATURE) - if target_temp is not None: + if (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None: target_temp = round( temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1 ) response["thermostatTemperatureSetpointHigh"] = target_temp response["thermostatTemperatureSetpointLow"] = target_temp else: - target_temp = attrs.get(ATTR_TEMPERATURE) - if target_temp is not None: + if (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None: response["thermostatTemperatureSetpoint"] = round( temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1 ) @@ -886,7 +1004,7 @@ async def execute(self, command, data, params, challenge): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -934,7 +1052,7 @@ async def execute(self, command, data, params, challenge): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, svc_data, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -947,7 +1065,7 @@ async def execute(self, command, data, params, challenge): climate.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -957,7 +1075,7 @@ async def execute(self, command, data, params, challenge): climate.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -970,7 +1088,7 @@ async def execute(self, command, data, params, challenge): climate.ATTR_PRESET_MODE: self.google_to_preset[target_mode], ATTR_ENTITY_ID: self.state.entity_id, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -982,7 +1100,7 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_HVAC_MODE: self.google_to_hvac[target_mode], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1003,7 +1121,10 @@ def supported(domain, features, device_class, _): if domain == humidifier.DOMAIN: return True - return domain == sensor.DOMAIN and device_class == sensor.DEVICE_CLASS_HUMIDITY + return ( + domain == sensor.DOMAIN + and device_class == sensor.SensorDeviceClass.HUMIDITY + ) def sync_attributes(self): """Return humidity attributes for a sync request.""" @@ -1013,7 +1134,7 @@ def sync_attributes(self): if domain == sensor.DOMAIN: device_class = attrs.get(ATTR_DEVICE_CLASS) - if device_class == sensor.DEVICE_CLASS_HUMIDITY: + if device_class == sensor.SensorDeviceClass.HUMIDITY: response["queryOnlyHumiditySetting"] = True elif domain == humidifier.DOMAIN: @@ -1036,7 +1157,7 @@ def query_attributes(self): if domain == sensor.DOMAIN: device_class = attrs.get(ATTR_DEVICE_CLASS) - if device_class == sensor.DEVICE_CLASS_HUMIDITY: + if device_class == sensor.SensorDeviceClass.HUMIDITY: current_humidity = self.state.state if current_humidity not in (STATE_UNKNOWN, STATE_UNAVAILABLE): response["humidityAmbientPercent"] = round(float(current_humidity)) @@ -1050,9 +1171,7 @@ def query_attributes(self): async def execute(self, command, data, params, challenge): """Execute a humidity command.""" - domain = self.state.domain - - if domain == sensor.DOMAIN: + if self.state.domain == sensor.DOMAIN: raise SmartHomeError( ERR_NOT_SUPPORTED, "Execute is not supported by sensor" ) @@ -1065,7 +1184,7 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, humidifier.ATTR_HUMIDITY: params["humidity"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1096,7 +1215,11 @@ def sync_attributes(self): def query_attributes(self): """Return LockUnlock query attributes.""" - return {"isLocked": self.state.state == STATE_LOCKED} + if self.state.state == STATE_JAMMED: + return {"isJammed": True} + + # If its unlocking its not yet unlocked so we consider is locked + return {"isLocked": self.state.state in (STATE_UNLOCKING, STATE_LOCKED)} async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" @@ -1110,7 +1233,7 @@ async def execute(self, command, data, params, challenge): lock.DOMAIN, service, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1194,11 +1317,9 @@ def query_attributes(self): async def execute(self, command, data, params, challenge): """Execute an ArmDisarm command.""" if params["arm"] and not params.get("cancel"): - arm_level = params.get("armLevel") - # If no arm level given, we can only arm it if there is # only one supported arm type. We never default to triggered. - if not arm_level: + if not (arm_level := params.get("armLevel")): states = self._supported_states() if STATE_ALARM_TRIGGERED in states: @@ -1235,7 +1356,7 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, ATTR_CODE: data.config.secure_devices_pin, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1248,14 +1369,7 @@ class FanSpeedTrait(_Trait): """ name = TRAIT_FANSPEED - commands = [COMMAND_FANSPEED] - - speed_synonyms = { - fan.SPEED_OFF: ["stop", "off"], - fan.SPEED_LOW: ["slow", "low", "slowest", "lowest"], - fan.SPEED_MEDIUM: ["medium", "mid", "middle"], - fan.SPEED_HIGH: ["high", "max", "fast", "highest", "fastest", "maximum"], - } + commands = [COMMAND_FANSPEED, COMMAND_REVERSE] @staticmethod def supported(domain, features, device_class, _): @@ -1270,26 +1384,23 @@ def sync_attributes(self): """Return speed point and modes attributes for a sync request.""" domain = self.state.domain speeds = [] - reversible = False + result = {} if domain == fan.DOMAIN: - modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) - for mode in modes: - if mode not in self.speed_synonyms: - continue - speed = { - "speed_name": mode, - "speed_values": [ - {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} - ], - } - speeds.append(speed) reversible = bool( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & fan.SUPPORT_DIRECTION ) + + result.update( + { + "reversible": reversible, + "supportsFanSpeedPercent": True, + } + ) + elif domain == climate.DOMAIN: - modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) + modes = self.state.attributes.get(climate.ATTR_FAN_MODES) or [] for mode in modes: speed = { "speed_name": mode, @@ -1297,31 +1408,32 @@ def sync_attributes(self): } speeds.append(speed) - return { - "availableFanSpeeds": {"speeds": speeds, "ordered": True}, - "reversible": reversible, - "supportsFanSpeedPercent": True, - } + result.update( + { + "reversible": False, + "availableFanSpeeds": {"speeds": speeds, "ordered": True}, + } + ) + + return result def query_attributes(self): """Return speed point and modes query attributes.""" + attrs = self.state.attributes domain = self.state.domain response = {} if domain == climate.DOMAIN: - speed = attrs.get(climate.ATTR_FAN_MODE) - if speed is not None: - response["currentFanSpeedSetting"] = speed + speed = attrs.get(climate.ATTR_FAN_MODE) or "off" + response["currentFanSpeedSetting"] = speed + if domain == fan.DOMAIN: - speed = attrs.get(fan.ATTR_SPEED) percent = attrs.get(fan.ATTR_PERCENTAGE) or 0 - if speed is not None: - response["on"] = speed != fan.SPEED_OFF - response["currentFanSpeedSetting"] = speed - response["currentFanSpeedPercent"] = percent + response["currentFanSpeedPercent"] = percent + return response - async def execute(self, command, data, params, challenge): + async def execute_fanspeed(self, data, params): """Execute an SetFanSpeed command.""" domain = self.state.domain if domain == climate.DOMAIN: @@ -1332,28 +1444,45 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_FAN_MODE: params["fanSpeed"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) + if domain == fan.DOMAIN: - service_params = { - ATTR_ENTITY_ID: self.state.entity_id, - } - if "fanSpeedPercent" in params: - service = fan.SERVICE_SET_PERCENTAGE - service_params[fan.ATTR_PERCENTAGE] = params["fanSpeedPercent"] + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_PERCENTAGE: params["fanSpeedPercent"], + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + + async def execute_reverse(self, data, params): + """Execute a Reverse command.""" + if self.state.domain == fan.DOMAIN: + if self.state.attributes.get(fan.ATTR_DIRECTION) == fan.DIRECTION_FORWARD: + direction = fan.DIRECTION_REVERSE else: - service = fan.SERVICE_SET_SPEED - service_params[fan.ATTR_SPEED] = params["fanSpeed"] + direction = fan.DIRECTION_FORWARD await self.hass.services.async_call( fan.DOMAIN, - service, - service_params, - blocking=True, + fan.SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_DIRECTION: direction}, + blocking=not self.config.should_report_state, context=data.context, ) + async def execute(self, command, data, params, challenge): + """Execute a smart home command.""" + if command == COMMAND_FANSPEED: + await self.execute_fanspeed(data, params) + elif command == COMMAND_REVERSE: + await self.execute_reverse(data, params) + @register_trait class ModesTrait(_Trait): @@ -1366,6 +1495,7 @@ class ModesTrait(_Trait): commands = [COMMAND_MODES] SYNONYMS = { + "preset mode": ["preset mode", "mode", "preset"], "sound mode": ["sound mode", "effects"], "option": ["option", "setting", "mode", "value"], } @@ -1373,9 +1503,15 @@ class ModesTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" + if domain == fan.DOMAIN and features & fan.SUPPORT_PRESET_MODE: + return True + if domain == input_select.DOMAIN: return True + if domain == select.DOMAIN: + return True + if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES: return True @@ -1416,17 +1552,17 @@ def sync_attributes(self): modes = [] for domain, attr, name in ( + (fan.DOMAIN, fan.ATTR_PRESET_MODES, "preset mode"), (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"), (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"), + (select.DOMAIN, select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"), ): if self.state.domain != domain: continue - items = self.state.attributes.get(attr) - - if items is not None: + if (items := self.state.attributes.get(attr)) is not None: modes.append(self._generate(name, items)) # Shortcut since all domains are currently unique @@ -1442,11 +1578,16 @@ def query_attributes(self): response = {} mode_settings = {} - if self.state.domain == media_player.DOMAIN: + if self.state.domain == fan.DOMAIN: + if fan.ATTR_PRESET_MODES in attrs: + mode_settings["preset mode"] = attrs.get(fan.ATTR_PRESET_MODE) + elif self.state.domain == media_player.DOMAIN: if media_player.ATTR_SOUND_MODE_LIST in attrs: mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) elif self.state.domain == input_select.DOMAIN: mode_settings["option"] = self.state.state + elif self.state.domain == select.DOMAIN: + mode_settings["option"] = self.state.state elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) @@ -1463,8 +1604,22 @@ async def execute(self, command, data, params, challenge): """Execute a SetModes command.""" settings = params.get("updateModeSettings") + if self.state.domain == fan.DOMAIN: + preset_mode = settings["preset mode"] + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_PRESET_MODE: preset_mode, + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + if self.state.domain == input_select.DOMAIN: - option = params["updateModeSettings"]["option"] + option = settings["option"] await self.hass.services.async_call( input_select.DOMAIN, input_select.SERVICE_SELECT_OPTION, @@ -1472,7 +1627,21 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, input_select.ATTR_OPTION: option, }, - blocking=True, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + + if self.state.domain == select.DOMAIN: + option = settings["option"] + await self.hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: self.state.entity_id, + select.ATTR_OPTION: option, + }, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1486,7 +1655,7 @@ async def execute(self, command, data, params, challenge): ATTR_MODE: requested_mode, ATTR_ENTITY_ID: self.state.entity_id, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1500,21 +1669,14 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_EFFECT: requested_effect, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return - if self.state.domain != media_player.DOMAIN: - _LOGGER.info( - "Received an Options command for unrecognised domain %s", - self.state.domain, - ) - return - - sound_mode = settings.get("sound mode") - - if sound_mode: + if self.state.domain == media_player.DOMAIN and ( + sound_mode := settings.get("sound mode") + ): await self.hass.services.async_call( media_player.DOMAIN, media_player.SERVICE_SELECT_SOUND_MODE, @@ -1522,10 +1684,16 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_SOUND_MODE: sound_mode, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) + _LOGGER.info( + "Received an Options command for unrecognised domain %s", + self.state.domain, + ) + return + @register_trait class InputSelectorTrait(_Trait): @@ -1590,7 +1758,7 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_INPUT_SOURCE: requested_source, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1604,9 +1772,9 @@ class OpenCloseTrait(_Trait): # Cover device classes that require 2FA COVER_2FA = ( - cover.DEVICE_CLASS_DOOR, - cover.DEVICE_CLASS_GARAGE, - cover.DEVICE_CLASS_GATE, + cover.CoverDeviceClass.DOOR, + cover.CoverDeviceClass.GARAGE, + cover.CoverDeviceClass.GATE, ) name = TRAIT_OPENCLOSE @@ -1619,11 +1787,11 @@ def supported(domain, features, device_class, _): return True return domain == binary_sensor.DOMAIN and device_class in ( - binary_sensor.DEVICE_CLASS_DOOR, - binary_sensor.DEVICE_CLASS_GARAGE_DOOR, - binary_sensor.DEVICE_CLASS_LOCK, - binary_sensor.DEVICE_CLASS_OPENING, - binary_sensor.DEVICE_CLASS_WINDOW, + binary_sensor.BinarySensorDeviceClass.DOOR, + binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, + binary_sensor.BinarySensorDeviceClass.LOCK, + binary_sensor.BinarySensorDeviceClass.OPENING, + binary_sensor.BinarySensorDeviceClass.WINDOW, ) @staticmethod @@ -1734,7 +1902,11 @@ async def execute(self, command, data, params, challenge): _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( - cover.DOMAIN, service, svc_params, blocking=True, context=data.context + cover.DOMAIN, + service, + svc_params, + blocking=not self.config.should_report_state, + context=data.context, ) @@ -1796,7 +1968,7 @@ async def _set_volume_absolute(self, data, level): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: level, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1832,7 +2004,7 @@ async def _execute_volume_relative(self, data, params): media_player.DOMAIN, svc, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -1854,7 +2026,7 @@ async def _execute_mute(self, data, params): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_MUTED: mute, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1880,9 +2052,7 @@ def _verify_pin_challenge(data, state, challenge): if not challenge: raise ChallengeNeeded(CHALLENGE_PIN_NEEDED) - pin = challenge.get("pin") - - if pin != data.config.secure_devices_pin: + if challenge.get("pin") != data.config.secure_devices_pin: raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) @@ -2018,7 +2188,7 @@ async def execute(self, command, data, params, challenge): media_player.DOMAIN, service, service_attrs, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -2070,3 +2240,108 @@ def query_attributes(self): "activityState": self.activity_lookup.get(self.state.state, "INACTIVE"), "playbackState": self.playback_lookup.get(self.state.state, "STOPPED"), } + + +@register_trait +class ChannelTrait(_Trait): + """Trait to get media playback state. + + https://developers.google.com/actions/smarthome/traits/channel + """ + + name = TRAIT_CHANNEL + commands = [COMMAND_SELECT_CHANNEL] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + if ( + domain == media_player.DOMAIN + and (features & media_player.SUPPORT_PLAY_MEDIA) + and device_class == media_player.MediaPlayerDeviceClass.TV + ): + return True + + return False + + def sync_attributes(self): + """Return attributes for a sync request.""" + return {"availableChannels": [], "commandOnlyChannels": True} + + def query_attributes(self): + """Return channel query attributes.""" + return {} + + async def execute(self, command, data, params, challenge): + """Execute an setChannel command.""" + if command == COMMAND_SELECT_CHANNEL: + channel_number = params.get("channelNumber") + else: + raise SmartHomeError(ERR_NOT_SUPPORTED, "Unsupported command") + + if not channel_number: + raise SmartHomeError( + ERR_NO_AVAILABLE_CHANNEL, + "Channel is not available", + ) + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_CONTENT_ID: channel_number, + media_player.ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + + +@register_trait +class SensorStateTrait(_Trait): + """Trait to get sensor state. + + https://developers.google.com/actions/smarthome/traits/sensorstate + """ + + sensor_types = { + sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"), + sensor.SensorDeviceClass.CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.SensorDeviceClass.CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.SensorDeviceClass.PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.SensorDeviceClass.PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + name = TRAIT_SENSOR_STATE + commands = [] + + @classmethod + def supported(cls, domain, features, device_class, _): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class in cls.sensor_types + + def sync_attributes(self): + """Return attributes for a sync request.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + if (data := self.sensor_types.get(device_class)) is not None: + return { + "sensorStatesSupported": { + "name": data[0], + "numericCapabilities": {"rawValueUnit": data[1]}, + } + } + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + if (data := self.sensor_types.get(device_class)) is not None: + return { + "currentSensorStateData": [ + {"name": data[0], "rawValue": self.state.state} + ] + } diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index a1cbed2ee552a..3d65f4eb2972d 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -55,8 +55,11 @@ "ko-KR", "lv-LV", "ml-IN", + "ms-MY", "nb-NO", + "nl-BE", "nl-NL", + "pa-IN", "pl-PL", "pt-BR", "pt-PT", @@ -150,8 +153,7 @@ async def async_get_engine(hass, config, discovery_info=None): """Set up Google Cloud TTS component.""" - key_file = config.get(CONF_KEY_FILE) - if key_file: + if key_file := config.get(CONF_KEY_FILE): key_file = hass.config.path(key_file) if not os.path.isfile(key_file): _LOGGER.error("File %s doesn't exist", key_file) @@ -279,7 +281,7 @@ async def async_get_tts_audio(self, message, language, options=None): ) # pylint: enable=no-member - with async_timeout.timeout(10, loop=self.hass.loop): + async with async_timeout.timeout(10): response = await self.hass.async_add_executor_job( self._client.synthesize_speech, synthesis_input, voice, audio_config ) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index ae6cb5c70d5d0..59386eb378ac6 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -65,7 +65,7 @@ async def _update_google_domains(hass, session, domain, user, password, timeout) params = {"hostname": domain} try: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index b6bd6f71bf449..1a0396a69ac18 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -1,4 +1,6 @@ """Support for Google Maps location sharing.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,10 @@ from locationsharinglib.locationsharinglibexceptions import InvalidCookies import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PLATFORM_SCHEMA_BASE, + SOURCE_TYPE_GPS, +) from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -30,7 +35,9 @@ CREDENTIALS_FILE = ".google_maps_location_sharing.cookies" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +# the parent "device_tracker" have marked the schemas as legacy, so this +# need to be refactored as part of a bigger rewrite. +PLATFORM_SCHEMA = PLATFORM_SCHEMA_BASE.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), @@ -53,7 +60,7 @@ def __init__(self, hass, config: ConfigType, see) -> None: self.username = config[CONF_USERNAME] self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) - self._prev_seen = {} + self._prev_seen: dict[str, str] = {} credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}" try: diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index 365c118e99e7f..1de7e98d7761c 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -5,7 +5,6 @@ import json import logging import os -from typing import Any from google.cloud import pubsub_v1 import voluptuous as vol @@ -14,6 +13,7 @@ from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -39,15 +39,12 @@ ) -def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): +def setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Pub/Sub component.""" - config = yaml_config[DOMAIN] project_id = config[CONF_PROJECT_ID] topic_name = config[CONF_TOPIC_NAME] - service_principal_path = os.path.join( - hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL] - ) + service_principal_path = hass.config.path(config[CONF_SERVICE_PRINCIPAL]) if not os.path.isfile(service_principal_path): _LOGGER.error("Path to credentials file cannot be found") @@ -59,7 +56,9 @@ def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): service_principal_path ) - topic_path = publisher.topic_path(project_id, topic_name) + topic_path = publisher.topic_path( # pylint: disable=no-member + project_id, topic_name + ) encoder = DateTimeJSONEncoder() diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 890479f9ffdc1..b566f3447f470 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -2,7 +2,7 @@ "domain": "google_translate", "name": "Google Translate Text-to-Speech", "documentation": "https://www.home-assistant.io/integrations/google_translate", - "requirements": ["gTTS==2.2.2"], + "requirements": ["gTTS==2.2.3"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index c9a5eef2c834f..9f6ca3a88b8c6 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -12,6 +12,7 @@ SUPPORT_LANGUAGES = [ "af", "ar", + "bg", "bn", "bs", "ca", diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index 5d4b3d1b74a24..2012e38e0a2de 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -1,17 +1,28 @@ """The google_travel_time component.""" - from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get, +) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Maps Travel Time from a config entry.""" + if entry.unique_id is not None: + hass.config_entries.async_update_entry(entry, unique_id=None) + + ent_reg = async_get(hass) + for entity in async_entries_for_config_entry(ent_reg, entry.entry_id): + ent_reg.async_update_entity(entity.entity_id, new_unique_id=entry.entry_id) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 603a0ec12f040..39425f01d847e 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Google Maps Travel Time integration.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -7,7 +9,6 @@ from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify from .const import ( ALL_LANGUAGES, @@ -126,22 +127,13 @@ async def async_step_user(self, user_input=None): errors = {} user_input = user_input or {} if user_input: - await self.async_set_unique_id( - slugify( - f"{DOMAIN}_{user_input[CONF_ORIGIN]}_{user_input[CONF_DESTINATION]}" - ) - ) - self._abort_if_unique_id_configured() - if ( - self.source == config_entries.SOURCE_IMPORT - or await self.hass.async_add_executor_job( - is_valid_config_entry, - self.hass, - _LOGGER, - user_input[CONF_API_KEY], - user_input[CONF_ORIGIN], - user_input[CONF_DESTINATION], - ) + if await self.hass.async_add_executor_job( + is_valid_config_entry, + self.hass, + _LOGGER, + user_input[CONF_API_KEY], + user_input[CONF_ORIGIN], + user_input[CONF_DESTINATION], ): return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), @@ -165,5 +157,3 @@ async def async_step_user(self, user_input=None): ), errors=errors, ) - - async_step_import = async_step_user diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 425d21ee18154..f295bceb65d82 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -3,10 +3,11 @@ from googlemaps.distance_matrix import distance_matrix from googlemaps.exceptions import ApiError -from homeassistant.components.google_travel_time.const import TRACKABLE_DOMAINS from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.helpers import location +from .const import TRACKABLE_DOMAINS + def is_valid_config_entry(hass, logger, api_key, origin, destination): """Return whether the config entry data is valid.""" @@ -30,9 +31,7 @@ def resolve_location(hass, logger, loc): def get_location_from_entity(hass, logger, entity_id): """Get the location from the entity state or attributes.""" - entity = hass.states.get(entity_id) - - if entity is None: + if (entity := hass.states.get(entity_id)) is None: logger.error("Unable to find entity %s", entity_id) return None @@ -41,7 +40,7 @@ def get_location_from_entity(hass, logger, entity_id): return get_location_from_attributes(entity) # Check if device is in a zone - zone_entity = hass.states.get("zone.%s" % entity.state) + zone_entity = hass.states.get(f"zone.{entity.state}") if location.has_location(zone_entity): logger.debug( "%s is in %s, getting zone location", entity_id, zone_entity.entity_id diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index b6019ba2991c1..08104619bb939 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -6,49 +6,35 @@ from googlemaps import Client from googlemaps.distance_matrix import distance_matrix -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, - CONF_ENTITY_NAMESPACE, CONF_MODE, CONF_NAME, - CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED, TIME_MINUTES, ) from homeassistant.core import CoreState, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util from .const import ( - ALL_LANGUAGES, ATTRIBUTION, - AVOID, CONF_ARRIVAL_TIME, - CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_OPTIONS, CONF_ORIGIN, - CONF_TRAFFIC_MODEL, - CONF_TRANSIT_MODE, - CONF_TRANSIT_ROUTING_PREFERENCE, CONF_TRAVEL_MODE, CONF_UNITS, DEFAULT_NAME, DOMAIN, TRACKABLE_DOMAINS, - TRANSIT_PREFS, - TRANSPORT_TYPE, - TRAVEL_MODE, - TRAVEL_MODEL, - UNITS, ) from .helpers import get_location_from_entity, resolve_zone @@ -56,38 +42,6 @@ SCAN_INTERVAL = timedelta(minutes=5) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_DESTINATION): cv.string, - vol.Required(CONF_ORIGIN): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TRAVEL_MODE): vol.In(TRAVEL_MODE), - vol.Optional(CONF_OPTIONS, default={CONF_MODE: "driving"}): vol.All( - dict, - vol.Schema( - { - vol.Optional(CONF_MODE, default="driving"): vol.In(TRAVEL_MODE), - vol.Optional(CONF_LANGUAGE): vol.In(ALL_LANGUAGES), - vol.Optional(CONF_AVOID): vol.In(AVOID), - vol.Optional(CONF_UNITS): vol.In(UNITS), - vol.Exclusive(CONF_ARRIVAL_TIME, "time"): cv.string, - vol.Exclusive(CONF_DEPARTURE_TIME, "time"): cv.string, - vol.Optional(CONF_TRAFFIC_MODEL): vol.In(TRAVEL_MODEL), - vol.Optional(CONF_TRANSIT_MODE): vol.In(TRANSPORT_TYPE), - vol.Optional(CONF_TRANSIT_ROUTING_PREFERENCE): vol.In( - TRANSIT_PREFS - ), - } - ), - ), - # Remove options to exclude from import - vol.Remove(CONF_ENTITY_NAMESPACE): cv.string, - vol.Remove(CONF_SCAN_INTERVAL): cv.time_period, - }, - extra=vol.REMOVE_EXTRA, -) - def convert_time_to_utc(timestr): """Take a string like 08:00:00 and convert it to a unix timestamp.""" @@ -143,25 +97,6 @@ async def async_setup_entry( async_add_entities([sensor], False) -async def async_setup_platform( - hass: HomeAssistant, config, add_entities_callback, discovery_info=None -): - """Set up the Google travel time platform.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - _LOGGER.warning( - "Your Google travel time configuration has been imported into the UI; " - "please remove it from configuration.yaml as support for it will be " - "removed in a future release" - ) - - class GoogleTravelTimeSensor(SensorEntity): """Representation of a Google travel time sensor.""" @@ -172,7 +107,7 @@ def __init__(self, config_entry, name, api_key, origin, destination, client): self._unit_of_measurement = TIME_MINUTES self._matrix = None self._api_key = api_key - self._unique_id = config_entry.unique_id + self._unique_id = config_entry.entry_id self._client = client # Check if location is a trackable entity @@ -196,7 +131,7 @@ async def async_added_to_hass(self) -> None: await self.first_update() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._matrix is None: return None @@ -209,13 +144,13 @@ def state(self): return None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "name": DOMAIN, - "identifiers": {(DOMAIN, self._api_key)}, - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._api_key)}, + name=DOMAIN, + ) @property def unique_id(self) -> str: @@ -250,7 +185,7 @@ def extra_state_attributes(self): return res @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/google_travel_time/translations/bg.json b/homeassistant/components/google_travel_time/translations/bg.json new file mode 100644 index 0000000000000..d49807e49af41 --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/bg.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/de.json b/homeassistant/components/google_travel_time/translations/de.json index 4e89ad7da1abf..701935f53fee0 100644 --- a/homeassistant/components/google_travel_time/translations/de.json +++ b/homeassistant/components/google_travel_time/translations/de.json @@ -14,7 +14,7 @@ "name": "Name", "origin": "Startort" }, - "description": "Bei der Angabe von Ursprung und Ziel k\u00f6nnen Sie einen oder mehrere durch das Pipe-Zeichen getrennte Orte in Form einer Adresse, L\u00e4ngen- / Breitengradkoordinaten oder einer Google-Orts-ID angeben. Wenn Sie den Standort mithilfe einer Google-Orts-ID angeben, muss der ID \"place_id:\" vorangestellt werden." + "description": "Bei der Angabe von Ursprung und Ziel kannst du einen oder mehrere durch das Pipe-Zeichen getrennte Orte in Form einer Adresse, L\u00e4ngen- / Breitengradkoordinaten oder einer Google-Orts-ID angeben. Wenn du den Standort mithilfe einer Google-Orts-ID angibst, muss der ID \"place_id:\" vorangestellt werden." } } }, @@ -31,7 +31,7 @@ "transit_routing_preference": "Transit-Routing-Einstellungen", "units": "Einheiten" }, - "description": "Sie k\u00f6nnen optional entweder eine Abfahrtszeit oder eine Ankunftszeit angeben. Wenn Sie eine Abfahrtszeit angeben, k\u00f6nnen Sie \"Now\", einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" eingeben. Wenn Sie eine Ankunftszeit angeben, k\u00f6nnen Sie einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" verwenden." + "description": "Du kannst optional entweder eine Abfahrtszeit oder eine Ankunftszeit angeben. Wenn du eine Abfahrtszeit angibst, kannst du \"Now\", einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" eingeben. Wenn du eine Ankunftszeit angibst, kannst du einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" verwenden." } } }, diff --git a/homeassistant/components/google_travel_time/translations/fr.json b/homeassistant/components/google_travel_time/translations/fr.json index d9c19d0d79319..790fe9117bd69 100644 --- a/homeassistant/components/google_travel_time/translations/fr.json +++ b/homeassistant/components/google_travel_time/translations/fr.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "api_key": "common::config_flow::data::api_key", + "api_key": "Cl\u00e9 d'API", "destination": "Destination", "name": "Nom", "origin": "Origine" diff --git a/homeassistant/components/google_travel_time/translations/he.json b/homeassistant/components/google_travel_time/translations/he.json new file mode 100644 index 0000000000000..0db92654b417c --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/he.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "destination": "\u05d9\u05e2\u05d3", + "name": "\u05e9\u05dd", + "origin": "\u05de\u05e7\u05d5\u05e8" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "language": "\u05e9\u05e4\u05d4", + "time": "\u05d6\u05de\u05df", + "units": "\u05d9\u05d7\u05d9\u05d3\u05d5\u05ea" + } + } + } + }, + "title": "\u05d6\u05de\u05df \u05e0\u05e1\u05d9\u05e2\u05d4 \u05d1\u05d2\u05d5\u05d2\u05dc \u05de\u05e4\u05d5\u05ea" +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/hu.json b/homeassistant/components/google_travel_time/translations/hu.json index 5bee8045c4fe1..85a15a98e5891 100644 --- a/homeassistant/components/google_travel_time/translations/hu.json +++ b/homeassistant/components/google_travel_time/translations/hu.json @@ -11,6 +11,7 @@ "data": { "api_key": "Api kucs", "destination": "C\u00e9l", + "name": "N\u00e9v", "origin": "Eredet" }, "description": "Az eredet \u00e9s a c\u00e9l megad\u00e1sakor megadhat egy vagy t\u00f6bb helyet a pipa karakterrel elv\u00e1lasztva, c\u00edm, sz\u00e9less\u00e9gi / hossz\u00fas\u00e1gi koordin\u00e1t\u00e1k vagy Google helyazonos\u00edt\u00f3 form\u00e1j\u00e1ban. Amikor a helyet megadja egy Google helyazonos\u00edt\u00f3val, akkor az azonos\u00edt\u00f3t el\u0151taggal kell ell\u00e1tni a `hely_azonos\u00edt\u00f3:` sz\u00f3val." diff --git a/homeassistant/components/google_travel_time/translations/id.json b/homeassistant/components/google_travel_time/translations/id.json index 3973d673f8ea6..16b60148aa9bb 100644 --- a/homeassistant/components/google_travel_time/translations/id.json +++ b/homeassistant/components/google_travel_time/translations/id.json @@ -11,6 +11,7 @@ "data": { "api_key": "Kunci API", "destination": "Tujuan", + "name": "Nama", "origin": "Asal" }, "description": "Saat menentukan asal dan tujuan, Anda dapat menyediakan satu atau beberapa lokasi yang dipisahkan oleh karakter pipe, dalam bentuk alamat, koordinat lintang/bujur, atau ID tempat Google. Saat menentukan lokasi menggunakan ID tempat Google, ID harus diawali dengan \"place_id:'." diff --git a/homeassistant/components/google_travel_time/translations/it.json b/homeassistant/components/google_travel_time/translations/it.json index aa10970873899..484bed6099e53 100644 --- a/homeassistant/components/google_travel_time/translations/it.json +++ b/homeassistant/components/google_travel_time/translations/it.json @@ -22,7 +22,7 @@ "step": { "init": { "data": { - "avoid": "Evitare", + "avoid": "Evita", "language": "Lingua", "mode": "Modalit\u00e0 di viaggio", "time": "Ora", @@ -31,7 +31,7 @@ "transit_routing_preference": "Preferenza percorso di transito", "units": "Unit\u00e0" }, - "description": "Facoltativamente, \u00e8 possibile specificare un orario di partenza o un orario di arrivo. Se si specifica un orario di partenza, \u00e8 possibile inserire \"now\", un timestamp Unix o una stringa di 24 ore come \"08: 00: 00\". Se si specifica un'ora di arrivo, \u00e8 possibile utilizzare un timestamp Unix o una stringa di 24 ore come \"08: 00: 00\"" + "description": "Facoltativamente, puoi specificare un orario di partenza o un orario di arrivo. Se specifichi un orario di partenza, puoi inserire \"now\", un timestamp Unix o una stringa di 24 ore come \"08: 00: 00\". Se specifichi un'ora di arrivo, puoi utilizzare un timestamp Unix o una stringa di 24 ore come \"08: 00: 00\"" } } }, diff --git a/homeassistant/components/google_travel_time/translations/ja.json b/homeassistant/components/google_travel_time/translations/ja.json new file mode 100644 index 0000000000000..2fb8ae2883c96 --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/ja.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "destination": "\u76ee\u7684\u5730", + "name": "\u540d\u524d", + "origin": "\u30aa\u30ea\u30b8\u30f3" + }, + "description": "\u51fa\u767a\u5730\u3068\u76ee\u7684\u5730\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u3001\u4f4f\u6240\u3001\u7def\u5ea6/\u7d4c\u5ea6\u306e\u5ea7\u6a19\u3001\u307e\u305f\u306fGoogle place ID\u306e\u5f62\u5f0f\u3067\u3001\u30d1\u30a4\u30d7\u6587\u5b57\u3067\u533a\u5207\u3089\u308c\u305f1\u3064\u4ee5\u4e0a\u306e\u5834\u6240\u3092\u6307\u5b9a\u3067\u304d\u307e\u3059\u3002Google place ID\u3092\u4f7f\u7528\u3057\u3066\u5834\u6240\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u3001ID\u306e\u524d\u306b\u3001`place_id:` \u3092\u4ed8\u3051\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "avoid": "\u907f\u3051\u308b", + "language": "\u8a00\u8a9e", + "mode": "\u30c8\u30e9\u30d9\u30eb\u30e2\u30fc\u30c9", + "time": "\u6642\u9593", + "time_type": "\u6642\u9593\u30bf\u30a4\u30d7", + "transit_mode": "\u30c8\u30e9\u30f3\u30b8\u30c3\u30c8\u30e2\u30fc\u30c9", + "transit_routing_preference": "\u30c8\u30e9\u30f3\u30b8\u30c3\u30c8\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u306e\u8a2d\u5b9a", + "units": "\u5358\u4f4d" + }, + "description": "\u5fc5\u8981\u306b\u5fdc\u3058\u3066\u3001\u51fa\u767a\u6642\u523b\u307e\u305f\u306f\u5230\u7740\u6642\u523b\u306e\u3044\u305a\u308c\u304b\u3092\u6307\u5b9a\u3067\u304d\u307e\u3059\u3002\u51fa\u767a\u6642\u523b\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u306f\u3001Unix \u30bf\u30a4\u30e0\u30b9\u30bf\u30f3\u30d7\u306e'now' \u3001\u307e\u305f\u306f '08:00:00' \u306e\u3088\u3046\u306a24\u6642\u9593\u306e\u6642\u523b\u6587\u5b57\u5217\u3092\u5165\u529b\u3067\u304d\u307e\u3059\u3002\u5230\u7740\u6642\u523b\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u306f\u3001Unix\u30bf\u30a4\u30e0\u30b9\u30bf\u30f3\u30d7\u307e\u305f\u306f\u3001'08:00:00' \u306e\u3088\u3046\u306a24\u6642\u9593\u306e\u6642\u523b\u6587\u5b57\u5217\u3092\u4f7f\u7528\u3067\u304d\u307e\u3059\u3002" + } + } + }, + "title": "Google\u30de\u30c3\u30d7\u306e\u79fb\u52d5\u6642\u9593(Travel Time)" +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/tr.json b/homeassistant/components/google_travel_time/translations/tr.json new file mode 100644 index 0000000000000..421179ff1a0a4 --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/tr.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "destination": "Hedef", + "name": "Ad", + "origin": "Kalk\u0131\u015f" + }, + "description": "Ba\u015flang\u0131\u00e7 ve var\u0131\u015f yerini belirtirken, bir adres, enlem/boylam koordinatlar\u0131 veya bir Google yer kimli\u011fi bi\u00e7iminde dikey \u00e7izgi karakteriyle ayr\u0131lm\u0131\u015f bir veya daha fazla konum sa\u011flayabilirsiniz. Bir Google yer kimli\u011fi kullanarak konumu belirtirken, kimli\u011fin \u00f6n\u00fcne 'place_id:' eklenmelidir." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "avoid": "Ka\u00e7\u0131nmak", + "language": "Dil", + "mode": "Seyahat Modu", + "time": "Zaman", + "time_type": "Zaman T\u00fcr\u00fc", + "transit_mode": "Transit Modu", + "transit_routing_preference": "Toplu Ta\u015f\u0131ma Tercihi", + "units": "Birimler" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 olarak bir Kalk\u0131\u015f Saati veya Var\u0131\u015f Saati belirtebilirsiniz. Bir hareket saati belirtiyorsan\u0131z, \"\u015fimdi\", bir Unix zaman damgas\u0131 veya \"08:00:00\" gibi 24 saatlik bir zaman dizesi girebilirsiniz. Bir var\u0131\u015f saati belirtiyorsan\u0131z, bir Unix zaman damgas\u0131 veya \"08:00:00\" gibi 24 saatlik bir zaman dizesi kullanabilirsiniz." + } + } + }, + "title": "Google Haritalar Seyahat S\u00fcresi" +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/zh-Hant.json b/homeassistant/components/google_travel_time/translations/zh-Hant.json index cce3cb591312e..929810a85644c 100644 --- a/homeassistant/components/google_travel_time/translations/zh-Hant.json +++ b/homeassistant/components/google_travel_time/translations/zh-Hant.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "destination": "\u76ee\u7684\u5730", "name": "\u540d\u7a31", "origin": "\u51fa\u767c\u5730" diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 28ec5df7486fe..46cad2afe08cc 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -1,11 +1,18 @@ """Support for retrieving status info from Google Wifi/OnHub routers.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -32,25 +39,70 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -MONITORED_CONDITIONS = { - ATTR_CURRENT_VERSION: [ - ["software", "softwareVersion"], - None, - "mdi:checkbox-marked-circle-outline", - ], - ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], - ATTR_UPTIME: [["system", "uptime"], TIME_DAYS, "mdi:timelapse"], - ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"], - ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"], - ATTR_STATUS: [["wan", "online"], None, "mdi:google"], -} + +@dataclass +class GoogleWifiRequiredKeysMixin: + """Mixin for required keys.""" + + primary_key: str + sensor_key: str + + +@dataclass +class GoogleWifiSensorEntityDescription( + SensorEntityDescription, GoogleWifiRequiredKeysMixin +): + """Describes GoogleWifi sensor entity.""" + + +SENSOR_TYPES: tuple[GoogleWifiSensorEntityDescription, ...] = ( + GoogleWifiSensorEntityDescription( + key=ATTR_CURRENT_VERSION, + primary_key="software", + sensor_key="softwareVersion", + icon="mdi:checkbox-marked-circle-outline", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_NEW_VERSION, + primary_key="software", + sensor_key="updateNewVersion", + icon="mdi:update", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_UPTIME, + primary_key="system", + sensor_key="uptime", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:timelapse", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_LAST_RESTART, + primary_key="system", + sensor_key="uptime", + icon="mdi:restart", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_LOCAL_IP, + primary_key="wan", + sensor_key="localIpAddress", + icon="mdi:access-point-network", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_STATUS, + primary_key="wan", + sensor_key="online", + icon="mdi:google", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] + ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } ) @@ -58,64 +110,42 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Google Wifi sensor.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - conditions = config.get(CONF_MONITORED_CONDITIONS) - - api = GoogleWifiAPI(host, conditions) - dev = [] - for condition in conditions: - dev.append(GoogleWifiSensor(api, name, condition)) + name = config[CONF_NAME] + host = config[CONF_HOST] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - add_entities(dev, True) + api = GoogleWifiAPI(host, monitored_conditions) + entities = [ + GoogleWifiSensor(api, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + add_entities(entities, True) class GoogleWifiSensor(SensorEntity): """Representation of a Google Wifi sensor.""" - def __init__(self, api, name, variable): + entity_description: GoogleWifiSensorEntityDescription + + def __init__(self, api, name, description: GoogleWifiSensorEntityDescription): """Initialize a Google Wifi sensor.""" + self.entity_description = description self._api = api - self._name = name - self._state = None - - variable_info = MONITORED_CONDITIONS[variable] - self._var_name = variable - self._var_units = variable_info[1] - self._var_icon = variable_info[2] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name}_{self._var_name}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._var_icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._var_units + self._attr_name = f"{name}_{description.key}" @property def available(self): """Return availability of Google Wifi API.""" return self._api.available - @property - def state(self): - """Return the state of the device.""" - return self._state - def update(self): """Get the latest data from the Google Wifi API.""" self._api.update() if self.available: - self._state = self._api.data[self._var_name] + self._attr_native_value = self._api.data[self.entity_description.key] else: - self._state = None + self._attr_native_value = None class GoogleWifiAPI: @@ -155,13 +185,15 @@ def update(self): def data_format(self): """Format raw data into easily accessible dict.""" - for attr_key in self.conditions: - value = MONITORED_CONDITIONS[attr_key] + for description in SENSOR_TYPES: + if description.key not in self.conditions: + continue + attr_key = description.key try: - primary_key = value[0][0] - sensor_key = value[0][1] - if primary_key in self.raw_data: - sensor_value = self.raw_data[primary_key][sensor_key] + if description.primary_key in self.raw_data: + sensor_value = self.raw_data[description.primary_key][ + description.sensor_key + ] # Format sensor for better readability if attr_key == ATTR_NEW_VERSION and sensor_value == "0.0.0.0": sensor_value = "Latest" @@ -185,7 +217,7 @@ def data_format(self): _LOGGER.error( "Router does not support %s field. " "Please remove %s from monitored_conditions", - sensor_key, + description.sensor_key, attr_key, ) self.data[attr_key] = STATE_UNKNOWN diff --git a/homeassistant/components/gpmdp/manifest.json b/homeassistant/components/gpmdp/manifest.json index 2b65226b0c141..51fad8e9e7163 100644 --- a/homeassistant/components/gpmdp/manifest.json +++ b/homeassistant/components/gpmdp/manifest.json @@ -1,6 +1,7 @@ { "domain": "gpmdp", "name": "Google Play Music Desktop Player (GPMDP)", + "disabled": "Integration has incompatible requirements.", "documentation": "https://www.home-assistant.io/integrations/gpmdp", "requirements": ["websocket-client==0.54.0"], "dependencies": ["configurator"], diff --git a/homeassistant/components/gpmdp/media_player.py b/homeassistant/components/gpmdp/media_player.py index 5680eb755009c..c6dbe41c99651 100644 --- a/homeassistant/components/gpmdp/media_player.py +++ b/homeassistant/components/gpmdp/media_player.py @@ -1,8 +1,11 @@ """Support for Google Play Music Desktop Player.""" +from __future__ import annotations + import json import logging import socket import time +from typing import Any import voluptuous as vol from websocket import _exceptions, create_connection @@ -28,7 +31,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -_CONFIGURING = {} +_CONFIGURING: dict[str, Any] = {} _LOGGER = logging.getLogger(__name__) DEFAULT_HOST = "localhost" @@ -106,8 +109,7 @@ def gpmdp_configuration_callback(callback_data): "the desktop player and try again" ) break - code = tmpmsg["payload"] - if code == "CODE_REQUIRED": + if (code := tmpmsg["payload"]) == "CODE_REQUIRED": continue setup_gpmdp(hass, config, code, add_entities_callback) save_json(hass.config.path(GPMDP_CONFIG_FILE), {"CODE": code}) @@ -209,8 +211,7 @@ def send_gpmdp_msg(self, namespace, method, with_id=True): """Send ws messages to GPMDP and verify request id in response.""" try: - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: self._status = STATE_OFF return self._request_id += 1 @@ -340,8 +341,7 @@ def media_pause(self): def media_seek(self, position): """Send media_seek command to media player.""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send( json.dumps( @@ -356,24 +356,21 @@ def media_seek(self, position): def volume_up(self): """Send volume_up command to media player.""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send('{"namespace": "volume", "method": "increaseVolume"}') self.schedule_update_ha_state() def volume_down(self): """Send volume_down command to media player.""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send('{"namespace": "volume", "method": "decreaseVolume"}') self.schedule_update_ha_state() def set_volume_level(self, volume): """Set volume on media player, range(0..1).""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send( json.dumps( diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 2f97f62337c47..1b50282799644 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -84,7 +84,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 0ec8e6588673f..ebbac36659f1d 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -1,18 +1,11 @@ """Support for GPSLogger.""" +from http import HTTPStatus + from aiohttp import web import voluptuous as vol -from homeassistant.components.device_tracker import ( - ATTR_BATTERY, - DOMAIN as DEVICE_TRACKER, -) -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_WEBHOOK_ID, - HTTP_OK, - HTTP_UNPROCESSABLE_ENTITY, -) +from homeassistant.components.device_tracker import ATTR_BATTERY +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, Platform from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -28,7 +21,7 @@ DOMAIN, ) -PLATFORMS = [DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" @@ -69,7 +62,9 @@ async def handle_webhook(hass, webhook_id, request): try: data = WEBHOOK_SCHEMA(dict(await request.post())) except vol.MultipleInvalid as error: - return web.Response(text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY) + return web.Response( + text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + ) attrs = { ATTR_SPEED: data.get(ATTR_SPEED), @@ -91,7 +86,7 @@ async def handle_webhook(hass, webhook_id, request): attrs, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 5bce10ab088f6..18b5b7fa58556 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,6 +1,7 @@ """Support for the GPSLogger device tracking.""" from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_GPS_ACCURACY, @@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE @@ -22,7 +24,9 @@ ) -async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): """Configure a dispatcher connection based on a config entry.""" @callback @@ -108,9 +112,9 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - return {"name": self._name, "identifiers": {(GPL_DOMAIN, self._unique_id)}} + return DeviceInfo(identifiers={(GPL_DOMAIN, self._unique_id)}, name=self._name) @property def source_type(self): @@ -128,8 +132,7 @@ async def async_added_to_hass(self): if self._location is not None: return - state = await self.async_get_last_state() - if state is None: + if (state := await self.async_get_last_state()) is None: self._location = (None, None) self._accuracy = None self._attributes = { diff --git a/homeassistant/components/gpslogger/translations/bg.json b/homeassistant/components/gpslogger/translations/bg.json index 895cf27ee5b82..8e1049d859e12 100644 --- a/homeassistant/components/gpslogger/translations/bg.json +++ b/homeassistant/components/gpslogger/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, "create_entry": { "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 GPSLogger. \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." }, diff --git a/homeassistant/components/gpslogger/translations/he.json b/homeassistant/components/gpslogger/translations/he.json new file mode 100644 index 0000000000000..ebee9aee97649 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "webhook_not_internet_accessible": "\u05de\u05d5\u05e4\u05e2 \u05d4-Home Assistant \u05e9\u05dc\u05da \u05e6\u05e8\u05d9\u05da \u05dc\u05d4\u05d9\u05d5\u05ea \u05e0\u05d2\u05d9\u05e9 \u05de\u05d4\u05d0\u05d9\u05e0\u05d8\u05e8\u05e0\u05d8 \u05db\u05d3\u05d9 \u05dc\u05e7\u05d1\u05dc \u05d4\u05d5\u05d3\u05e2\u05d5\u05ea webhook." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/translations/hu.json b/homeassistant/components/gpslogger/translations/hu.json index fe459ca316495..d458e959d0a2b 100644 --- a/homeassistant/components/gpslogger/translations/hu.json +++ b/homeassistant/components/gpslogger/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a GPSLoggerben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." + "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtani a webhook funkci\u00f3t a GPSLoggerben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1lja: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a GPSLogger Webhookot?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a GPSLogger Webhookot?", "title": "GPSLogger Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/gpslogger/translations/it.json b/homeassistant/components/gpslogger/translations/it.json index 1ad8589583f55..7db05dfb8ee26 100644 --- a/homeassistant/components/gpslogger/translations/it.json +++ b/homeassistant/components/gpslogger/translations/it.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "L'istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi webhook." }, "create_entry": { - "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in GPSLogger.\n\n Compila le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli." + "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in GPSLogger.\n\n Compila le seguenti informazioni: \n\n - URL: `{webhook_url}` \n - Metodo: POST \n\n Vedi [la documentazione]({docs_url}) per ulteriori dettagli." }, "step": { "user": { diff --git a/homeassistant/components/gpslogger/translations/ja.json b/homeassistant/components/gpslogger/translations/ja.json new file mode 100644 index 0000000000000..c04d58020d60a --- /dev/null +++ b/homeassistant/components/gpslogger/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001GPSLogger\u3067webhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "GPSLogger Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "GPSLogger Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/translations/tr.json b/homeassistant/components/gpslogger/translations/tr.json index 84adcdf8225c4..ef10b98c5df0b 100644 --- a/homeassistant/components/gpslogger/translations/tr.json +++ b/homeassistant/components/gpslogger/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Olaylar\u0131 Home Assistant'a g\u00f6ndermek i\u00e7in GPSLogger'da webhook \u00f6zelli\u011fini ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "GPSLogger Webhook'u kurmak istedi\u011finizden emin misiniz?", + "title": "GPSLogger Webhook'u kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index 9405b576b4d6a..b63e461e76a98 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -149,8 +149,7 @@ def _report_attributes(self, entity_id, new_state): def run(self): """Run the process to export the data.""" while True: - event = self._queue.get() - if event == self._quit_object: + if (event := self._queue.get()) == self._quit_object: _LOGGER.debug("Event processing thread stopped") self._queue.task_done() return diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index b873d5ba4d374..fa51a48bb4f94 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -2,9 +2,8 @@ from datetime import timedelta import logging -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_interval @@ -20,10 +19,10 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [CLIMATE_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Gree Climate from a config entry.""" hass.data.setdefault(DOMAIN, {}) gree_discovery = DiscoveryService(hass) @@ -45,7 +44,7 @@ async def _async_scan_update(_=None): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if hass.data[DOMAIN].get(DISPATCHERS) is not None: for cleanup in hass.data[DOMAIN][DISPATCHERS]: diff --git a/homeassistant/components/gree/bridge.py b/homeassistant/components/gree/bridge.py index 87f02ab82c4d9..41ba4bd984215 100644 --- a/homeassistant/components/gree/bridge.py +++ b/homeassistant/components/gree/bridge.py @@ -26,7 +26,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator): """Manages polling for state changes from the device.""" - def __init__(self, hass: HomeAssistant, device: Device): + def __init__(self, hass: HomeAssistant, device: Device) -> None: """Initialize the data update coordinator.""" DataUpdateCoordinator.__init__( self, @@ -103,3 +103,10 @@ async def device_found(self, device_info: DeviceInfo) -> None: await coordo.async_refresh() async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) + + async def device_update(self, device_info: DeviceInfo) -> None: + """Handle updates in device information, update if ip has changed.""" + for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + if coordinator.device.device_info.mac == device_info.mac: + coordinator.device.device_info.ip = device_info.ip + await coordinator.async_refresh() diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index e468195ff9258..dbf8214e29a13 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -4,6 +4,10 @@ import logging from greeclimate.device import ( + TEMP_MAX, + TEMP_MAX_F, + TEMP_MIN, + TEMP_MIN_F, FanSpeed, HorizontalSwing, Mode, @@ -46,6 +50,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -55,8 +60,6 @@ DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, - MAX_TEMP, - MIN_TEMP, TARGET_TEMPERATURE_STEP, ) @@ -135,14 +138,14 @@ def unique_id(self) -> str: return self._mac @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "name": self._name, - "identifiers": {(DOMAIN, self._mac)}, - "manufacturer": "Gree", - "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, - } + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._mac)}, + identifiers={(DOMAIN, self._mac)}, + manufacturer="Gree", + name=self._name, + ) @property def temperature_unit(self) -> str: @@ -157,8 +160,8 @@ def precision(self) -> float: @property def current_temperature(self) -> float: - """Return the target temperature, gree devices don't provide internal temp.""" - return self.target_temperature + """Return the reported current temperature for the device.""" + return self.coordinator.device.current_temperature @property def target_temperature(self) -> float: @@ -184,12 +187,12 @@ async def async_set_temperature(self, **kwargs): @property def min_temp(self) -> float: """Return the minimum temperature supported by the device.""" - return MIN_TEMP + return TEMP_MIN if self.temperature_unit == TEMP_CELSIUS else TEMP_MIN_F @property def max_temp(self) -> float: """Return the maximum temperature supported by the device.""" - return MAX_TEMP + return TEMP_MAX if self.temperature_unit == TEMP_CELSIUS else TEMP_MAX_F @property def target_temperature_step(self) -> float: diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index 2d9a48496b2e6..b4df7a1acde5d 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -16,9 +16,6 @@ FAN_MEDIUM_LOW = "medium low" FAN_MEDIUM_HIGH = "medium high" -MIN_TEMP = 16 -MAX_TEMP = 30 - MAX_ERRORS = 2 TARGET_TEMPERATURE_STEP = 1 diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py new file mode 100644 index 0000000000000..7407a90b4d009 --- /dev/null +++ b/homeassistant/components/gree/entity.py @@ -0,0 +1,38 @@ +"""Entity object for shared properties of Gree entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .bridge import DeviceDataUpdateCoordinator +from .const import DOMAIN + + +class GreeEntity(CoordinatorEntity): + """Generic Gree entity (base class).""" + + def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._desc = desc + self._name = f"{coordinator.device.device_info.name}" + self._mac = coordinator.device.device_info.mac + + @property + def name(self): + """Return the name of the node.""" + return f"{self._name} {self._desc}" + + @property + def unique_id(self): + """Return the unique id based for the node.""" + return f"{self._mac}_{self._desc}" + + @property + def device_info(self) -> DeviceInfo: + """Return info about the device.""" + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._mac)}, + identifiers={(DOMAIN, self._mac)}, + manufacturer="Gree", + name=self._name, + ) diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json index 58ddb62216b5e..f4f8cf153a38f 100644 --- a/homeassistant/components/gree/manifest.json +++ b/homeassistant/components/gree/manifest.json @@ -3,7 +3,7 @@ "name": "Gree Climate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gree", - "requirements": ["greeclimate==0.11.4"], + "requirements": ["greeclimate==0.12.5"], "codeowners": ["@cmroche"], "iot_class": "local_polling" } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 7f659d7e64bf2..f05f751b8b664 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,13 +1,12 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations -from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN +from .entity import GreeEntity async def async_setup_entry(hass, config_entry, async_add_entities): @@ -16,7 +15,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def init_device(coordinator): """Register the device.""" - async_add_entities([GreeSwitchEntity(coordinator)]) + async_add_entities( + [ + GreePanelLightSwitchEntity(coordinator), + GreeQuietModeSwitchEntity(coordinator), + GreeFreshAirSwitchEntity(coordinator), + GreeXFanSwitchEntity(coordinator), + ] + ) for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) @@ -26,44 +32,22 @@ def init_device(coordinator): ) -class GreeSwitchEntity(CoordinatorEntity, SwitchEntity): - """Representation of a Gree HVAC device.""" +class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the front panel light on the device.""" def __init__(self, coordinator): """Initialize the Gree device.""" - super().__init__(coordinator) - self._name = coordinator.device.device_info.name + " Panel Light" - self._mac = coordinator.device.device_info.mac - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique id for the device.""" - return f"{self._mac}-panel-light" + super().__init__(coordinator, "Panel Light") @property def icon(self) -> str | None: """Return the icon for the device.""" return "mdi:lightbulb" - @property - def device_info(self): - """Return device specific attributes.""" - return { - "name": self._name, - "identifiers": {(DOMAIN, self._mac)}, - "manufacturer": "Gree", - "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, - } - @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_SWITCH + return SwitchDeviceClass.SWITCH @property def is_on(self) -> bool: @@ -81,3 +65,93 @@ async def async_turn_off(self, **kwargs): self.coordinator.device.light = False await self.coordinator.push_state_update() self.async_write_ha_state() + + +class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the quiet mode state of the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Quiet") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool: + """Return if the state is turned on.""" + return self.coordinator.device.quiet + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self.coordinator.device.quiet = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self.coordinator.device.quiet = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + +class GreeFreshAirSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the fresh air mode state of the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Fresh Air") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool: + """Return if the state is turned on.""" + return self.coordinator.device.fresh_air + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self.coordinator.device.fresh_air = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self.coordinator.device.fresh_air = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + +class GreeXFanSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the extra fan mode state of the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "XFan") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool: + """Return if the state is turned on.""" + return self.coordinator.device.xfan + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self.coordinator.device.xfan = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self.coordinator.device.xfan = False + await self.coordinator.push_state_update() + self.async_write_ha_state() diff --git a/homeassistant/components/gree/translations/ar.json b/homeassistant/components/gree/translations/ar.json new file mode 100644 index 0000000000000..205a46af4792a --- /dev/null +++ b/homeassistant/components/gree/translations/ar.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0643\u062a\u0634\u0627\u0641 \u0627\u062c\u0647\u0632\u0629 \u0641\u064a \u0645\u0646\u0632\u0644\u0643", + "single_instance_allowed": "\u0633\u0628\u0642 \u0648\u062a\u0645 \u062a\u0643\u0648\u064a\u0646\u0647. \u0641\u0642\u0637 \u062a\u0643\u0648\u064a\u0646 \u0648\u0627\u062d\u062f \u0645\u0645\u0643\u0646." + }, + "step": { + "confirm": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0628\u062f\u0621 \u0627\u0644\u0636\u0628\u0637\u061f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/bg.json b/homeassistant/components/gree/translations/bg.json new file mode 100644 index 0000000000000..e7ed81d36f5bc --- /dev/null +++ b/homeassistant/components/gree/translations/bg.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0437\u0430\u043f\u043e\u0447\u043d\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/de.json b/homeassistant/components/gree/translations/de.json index 86bc8e3673075..19cd4b8c70e03 100644 --- a/homeassistant/components/gree/translations/de.json +++ b/homeassistant/components/gree/translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } } diff --git a/homeassistant/components/gree/translations/he.json b/homeassistant/components/gree/translations/he.json new file mode 100644 index 0000000000000..d3d68dccc93cc --- /dev/null +++ b/homeassistant/components/gree/translations/he.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/hu.json b/homeassistant/components/gree/translations/hu.json index 6c61530acbebb..a56ebbfc90666 100644 --- a/homeassistant/components/gree/translations/hu.json +++ b/homeassistant/components/gree/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/gree/translations/ja.json b/homeassistant/components/gree/translations/ja.json new file mode 100644 index 0000000000000..d1234b69652eb --- /dev/null +++ b/homeassistant/components/gree/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/nl.json b/homeassistant/components/gree/translations/nl.json index d11896014fd2c..0671f0b3674c0 100644 --- a/homeassistant/components/gree/translations/nl.json +++ b/homeassistant/components/gree/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/gree/translations/tr.json b/homeassistant/components/gree/translations/tr.json index 8de4663957ea8..3df15466f030f 100644 --- a/homeassistant/components/gree/translations/tr.json +++ b/homeassistant/components/gree/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/gree/translations/zh-Hans.json b/homeassistant/components/gree/translations/zh-Hans.json new file mode 100644 index 0000000000000..808f01b57a8bd --- /dev/null +++ b/homeassistant/components/gree/translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6b64\u7f51\u7edc\u672a\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "single_instance_allowed": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e\u3002\u53ea\u5141\u8bb8\u5b58\u5728\u4e00\u4e2a\u914d\u7f6e\u6587\u6863" + }, + "step": { + "confirm": { + "description": "\u4f60\u60f3\u8981\u5f00\u59cb\u914d\u7f6e\u5417\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 51471739e98b6..d2b0e7c307ba6 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -1,7 +1,9 @@ """Support for monitoring a GreenEye Monitor energy monitor.""" +from __future__ import annotations + import logging -from greeneye import Monitors +import greeneye import voluptuous as vol from homeassistant.const import ( @@ -15,8 +17,10 @@ TIME_MINUTES, TIME_SECONDS, ) +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -117,15 +121,15 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: COMPONENT_SCHEMA}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GreenEye Monitor component.""" - monitors = Monitors() + monitors = greeneye.Monitors() hass.data[DATA_GREENEYE_MONITOR] = monitors server_config = config[DOMAIN] server = await monitors.start_server(server_config[CONF_PORT]) - async def close_server(*args): + async def close_server(event: Event) -> None: """Close the monitoring server.""" await server.close() @@ -157,8 +161,7 @@ async def close_server(*args): } ) - sensor_configs = monitor_config[CONF_TEMPERATURE_SENSORS] - if sensor_configs: + if sensor_configs := monitor_config[CONF_TEMPERATURE_SENSORS]: temperature_unit = { CONF_TEMPERATURE_UNIT: sensor_configs[CONF_TEMPERATURE_UNIT] } @@ -190,7 +193,7 @@ async def close_server(*args): return False hass.async_create_task( - async_load_platform(hass, "sensor", DOMAIN, all_sensors, config) + async_load_platform(hass, "sensor", DOMAIN, {CONF_SENSORS: all_sensors}, config) ) return True diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 4e792bf56e495..b0d1a246819e0 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,15 +1,25 @@ """Support for the sensors in a GreenEye Monitor.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from typing import Any, Optional, Union, cast + +import greeneye + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import ( CONF_NAME, CONF_SENSOR_TYPE, + CONF_SENSORS, CONF_TEMPERATURE_UNIT, + ELECTRIC_POTENTIAL_VOLT, POWER_WATT, TIME_HOURS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType from . import ( CONF_COUNTED_QUANTITY, @@ -31,18 +41,17 @@ UNIT_WATTS = POWER_WATT COUNTER_ICON = "mdi:counter" -CURRENT_SENSOR_ICON = "mdi:flash" -TEMPERATURE_ICON = "mdi:thermometer" -VOLTAGE_ICON = "mdi:current-ac" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType, +) -> None: """Set up a single GEM temperature sensor.""" - if not discovery_info: - return - - entities = [] - for sensor in discovery_info: + entities: list[GEMSensor] = [] + for sensor in discovery_info[CONF_SENSORS]: sensor_type = sensor[CONF_SENSOR_TYPE] if sensor_type == SENSOR_TYPE_CURRENT: entities.append( @@ -85,45 +94,45 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(entities) +UnderlyingSensorType = Union[ + greeneye.monitor.Channel, + greeneye.monitor.Monitor, + greeneye.monitor.PulseCounter, + greeneye.monitor.TemperatureSensor, +] + + class GEMSensor(SensorEntity): """Base class for GreenEye Monitor sensors.""" - def __init__(self, monitor_serial_number, name, sensor_type, number): + _attr_should_poll = False + + def __init__( + self, monitor_serial_number: int, name: str, sensor_type: str, number: int + ) -> None: """Construct the entity.""" self._monitor_serial_number = monitor_serial_number - self._name = name - self._sensor = None + self._attr_name = name + self._monitor: greeneye.monitor.Monitor | None = None self._sensor_type = sensor_type self._number = number + self._attr_unique_id = ( + f"{self._monitor_serial_number}-{self._sensor_type}-{self._number}" + ) - @property - def should_poll(self): - """GEM pushes changes, so this returns False.""" - return False - - @property - def unique_id(self): - """Return a unique ID for this sensor.""" - return f"{self._monitor_serial_number}-{self._sensor_type}-{self._number}" - - @property - def name(self): - """Return the name of the channel.""" - return self._name - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Wait for and connect to the sensor.""" monitors = self.hass.data[DATA_GREENEYE_MONITOR] if not self._try_connect_to_monitor(monitors): monitors.add_listener(self._on_new_monitor) - def _on_new_monitor(self, *args): + def _on_new_monitor(self, monitor: greeneye.monitor.Monitor) -> None: monitors = self.hass.data[DATA_GREENEYE_MONITOR] if self._try_connect_to_monitor(monitors): monitors.remove_listener(self._on_new_monitor) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Remove listener from the sensor.""" if self._sensor: self._sensor.remove_listener(self.async_write_ha_state) @@ -131,51 +140,48 @@ async def async_will_remove_from_hass(self): monitors = self.hass.data[DATA_GREENEYE_MONITOR] monitors.remove_listener(self._on_new_monitor) - def _try_connect_to_monitor(self, monitors): - monitor = monitors.monitors.get(self._monitor_serial_number) - if not monitor: + def _try_connect_to_monitor(self, monitors: greeneye.Monitors) -> bool: + self._monitor = monitors.monitors.get(self._monitor_serial_number) + if not self._sensor: return False - self._sensor = self._get_sensor(monitor) self._sensor.add_listener(self.async_write_ha_state) + self.async_write_ha_state() return True - def _get_sensor(self, monitor): + @property + def _sensor(self) -> UnderlyingSensorType | None: raise NotImplementedError() class CurrentSensor(GEMSensor): """Entity showing power usage on one channel of the monitor.""" - def __init__(self, monitor_serial_number, number, name, net_metering): + _attr_native_unit_of_measurement = UNIT_WATTS + _attr_device_class = SensorDeviceClass.POWER + + def __init__( + self, monitor_serial_number: int, number: int, name: str, net_metering: bool + ) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "current", number) self._net_metering = net_metering - def _get_sensor(self, monitor): - return monitor.channels[self._number - 1] - @property - def icon(self): - """Return the icon that should represent this sensor in the UI.""" - return CURRENT_SENSOR_ICON + def _sensor(self) -> greeneye.monitor.Channel | None: + return self._monitor.channels[self._number - 1] if self._monitor else None @property - def unit_of_measurement(self): - """Return the unit of measurement used by this sensor.""" - return UNIT_WATTS - - @property - def state(self): + def native_value(self) -> float | None: """Return the current number of watts being used by the channel.""" if not self._sensor: return None - return self._sensor.watts + return cast(Optional[float], self._sensor.watts) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return total wattseconds in the state dictionary.""" if not self._sensor: return None @@ -191,43 +197,42 @@ def extra_state_attributes(self): class PulseCounter(GEMSensor): """Entity showing rate of change in one pulse counter of the monitor.""" + _attr_icon = COUNTER_ICON + def __init__( self, - monitor_serial_number, - number, - name, - counted_quantity, - time_unit, - counted_quantity_per_pulse, - ): + monitor_serial_number: int, + number: int, + name: str, + counted_quantity: str, + time_unit: str, + counted_quantity_per_pulse: float, + ) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "pulse", number) - self._counted_quantity = counted_quantity self._counted_quantity_per_pulse = counted_quantity_per_pulse self._time_unit = time_unit - - def _get_sensor(self, monitor): - return monitor.pulse_counters[self._number - 1] + self._attr_native_unit_of_measurement = f"{counted_quantity}/{self._time_unit}" @property - def icon(self): - """Return the icon that should represent this sensor in the UI.""" - return COUNTER_ICON + def _sensor(self) -> greeneye.monitor.PulseCounter | None: + return self._monitor.pulse_counters[self._number - 1] if self._monitor else None @property - def state(self): + def native_value(self) -> float | None: """Return the current rate of change for the given pulse counter.""" if not self._sensor or self._sensor.pulses_per_second is None: return None - return ( + result = ( self._sensor.pulses_per_second * self._counted_quantity_per_pulse * self._seconds_per_time_unit ) + return cast(float, result) @property - def _seconds_per_time_unit(self): + def _seconds_per_time_unit(self) -> int: """Return the number of seconds in the given display time unit.""" if self._time_unit == TIME_SECONDS: return 1 @@ -236,13 +241,13 @@ def _seconds_per_time_unit(self): if self._time_unit == TIME_HOURS: return 3600 - @property - def unit_of_measurement(self): - """Return the unit of measurement for this pulse counter.""" - return f"{self._counted_quantity}/{self._time_unit}" + # Config schema should have ensured it is one of the above values + raise Exception( + f"Invalid value for time unit: {self._time_unit}. Expected one of {TIME_SECONDS}, {TIME_MINUTES}, or {TIME_HOURS}" + ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return total pulses in the data dictionary.""" if not self._sensor: return None @@ -253,58 +258,51 @@ def extra_state_attributes(self): class TemperatureSensor(GEMSensor): """Entity showing temperature from one temperature sensor.""" - def __init__(self, monitor_serial_number, number, name, unit): + _attr_device_class = SensorDeviceClass.TEMPERATURE + + def __init__( + self, monitor_serial_number: int, number: int, name: str, unit: str + ) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "temp", number) - self._unit = unit - - def _get_sensor(self, monitor): - return monitor.temperature_sensors[self._number - 1] + self._attr_native_unit_of_measurement = unit @property - def icon(self): - """Return the icon that should represent this sensor in the UI.""" - return TEMPERATURE_ICON + def _sensor(self) -> greeneye.monitor.TemperatureSensor | None: + return ( + self._monitor.temperature_sensors[self._number - 1] + if self._monitor + else None + ) @property - def state(self): + def native_value(self) -> float | None: """Return the current temperature being reported by this sensor.""" if not self._sensor: return None - return self._sensor.temperature - - @property - def unit_of_measurement(self): - """Return the unit of measurement for this sensor (user specified).""" - return self._unit + return cast(Optional[float], self._sensor.temperature) class VoltageSensor(GEMSensor): """Entity showing voltage.""" - def __init__(self, monitor_serial_number, number, name): + _attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT + _attr_device_class = SensorDeviceClass.VOLTAGE + + def __init__(self, monitor_serial_number: int, number: int, name: str) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "volts", number) - def _get_sensor(self, monitor): - """Wire the updates to the monitor itself, since there is no voltage element in the API.""" - return monitor - @property - def icon(self): - """Return the icon that should represent this sensor in the UI.""" - return VOLTAGE_ICON + def _sensor(self) -> greeneye.monitor.Monitor | None: + """Wire the updates to the monitor itself, since there is no voltage element in the API.""" + return self._monitor @property - def state(self): + def native_value(self) -> float | None: """Return the current voltage being reported by this sensor.""" if not self._sensor: return None - return self._sensor.voltage - - @property - def unit_of_measurement(self): - """Return the unit of measurement for this sensor.""" - return VOLT + return cast(Optional[float], self._sensor.voltage) diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 41e4b99b6c61f..b3d6898d98459 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -35,7 +35,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): tokenfile = hass.config.path(".greenwave") if config.get(CONF_VERSION) == 3: if os.path.exists(tokenfile): - with open(tokenfile) as tokenfile: + with open(tokenfile, encoding="utf8") as tokenfile: token = tokenfile.read() else: try: @@ -43,7 +43,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except PermissionError: _LOGGER.error("The Gateway Is Not In Sync Mode") raise - with open(tokenfile, "w+") as tokenfile: + with open(tokenfile, "w+", encoding="utf8") as tokenfile: tokenfile.write(token) else: token = None diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 096108b460e6b..e3816d52d609b 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -56,7 +56,7 @@ SERVICE_SET = "set" SERVICE_REMOVE = "remove" -PLATFORMS = ["light", "cover", "notify"] +PLATFORMS = ["light", "cover", "notify", "fan", "binary_sensor"] REG_KEY = f"{DOMAIN}_registry" @@ -121,9 +121,7 @@ def is_on(hass, entity_id): # Integration not setup yet, it cannot be on return False - state = hass.states.get(entity_id) - - if state is not None: + if (state := hass.states.get(entity_id)) is not None: return state.state in hass.data[REG_KEY].on_off_mapping return False @@ -213,9 +211,7 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: async def async_setup(hass, config): """Set up all groups found defined in the configuration.""" - component = hass.data.get(DOMAIN) - - if component is None: + if (component := hass.data.get(DOMAIN)) is None: component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) hass.data[REG_KEY] = GroupIntegrationRegistry() @@ -228,8 +224,7 @@ async def reload_service_handler(service): """Remove all user-defined groups and load new ones from config.""" auto = list(filter(lambda e: not e.user_defined, component.entities)) - conf = await component.async_prepare_reload() - if conf is None: + if (conf := await component.async_prepare_reload()) is None: return await _async_process_config(hass, conf, component) @@ -507,9 +502,7 @@ async def async_create_group( ) # If called before the platform async_setup is called (test cases) - component = hass.data.get(DOMAIN) - - if component is None: + if (component := hass.data.get(DOMAIN)) is None: component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities([group]) @@ -661,9 +654,8 @@ async def _async_state_changed_listener(self, event): return self.async_set_context(event.context) - new_state = event.data.get("new_state") - if new_state is None: + if (new_state := event.data.get("new_state")) is None: # The state was removed from the state machine self._reset_tracked_state() @@ -677,9 +669,7 @@ def _reset_tracked_state(self): self._on_states = set() for entity_id in self.trackable: - state = self.hass.states.get(entity_id) - - if state is not None: + if (state := self.hass.states.get(entity_id)) is not None: self._see_state(state) def _see_state(self, new_state): diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py new file mode 100644 index 0000000000000..613b21571de6c --- /dev/null +++ b/homeassistant/components/group/binary_sensor.py @@ -0,0 +1,128 @@ +"""This platform allows several binary sensor to be grouped into one binary sensor.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, + PLATFORM_SCHEMA, + BinarySensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import CoreState, Event, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType + +from . import GroupEntity + +DEFAULT_NAME = "Binary Sensor Group" + +CONF_ALL = "all" +REG_KEY = f"{BINARY_SENSOR_DOMAIN}_registry" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(BINARY_SENSOR_DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ALL): cv.boolean, + } +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the Group Binary Sensor platform.""" + async_add_entities( + [ + BinarySensorGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config.get(CONF_DEVICE_CLASS), + config[CONF_ENTITIES], + config.get(CONF_ALL), + ) + ] + ) + + +class BinarySensorGroup(GroupEntity, BinarySensorEntity): + """Representation of a BinarySensorGroup.""" + + def __init__( + self, + unique_id: str | None, + name: str, + device_class: str | None, + entity_ids: list[str], + mode: str | None, + ) -> None: + """Initialize a BinarySensorGroup entity.""" + super().__init__() + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + self._device_class = device_class + self._state: str | None = None + self.mode = any + if mode: + self.mode = all + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + async def async_state_changed_listener(event: Event) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + await self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + if self.hass.state == CoreState.running: + await self.async_update() + return + + await super().async_added_to_hass() + + async def async_update(self) -> None: + """Query all members and determine the binary sensor group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + filtered_states: list[str] = [x.state for x in all_states if x is not None] + self._attr_available = any( + state != STATE_UNAVAILABLE for state in filtered_states + ) + if STATE_UNAVAILABLE in filtered_states: + self._attr_is_on = None + else: + states = list(map(lambda x: x == STATE_ON, filtered_states)) + state = self.mode(states) + self._attr_is_on = state + self.async_write_ha_state() + + @property + def device_class(self) -> str | None: + """Return the sensor class of the binary sensor.""" + return self._device_class diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 5e8d18b28e237..8c4e260b8c12b 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -1,6 +1,8 @@ """This platform allows several cover to be grouped into one cover.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.cover import ( @@ -34,18 +36,20 @@ ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, + CONF_UNIQUE_ID, + STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, ) -from homeassistant.core import CoreState, State +from homeassistant.core import CoreState, Event, HomeAssistant, State import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType from . import GroupEntity - -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs -# mypy: no-check-untyped-defs +from .util import attribute_equal, reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -56,31 +60,40 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: """Set up the Group Cover platform.""" - async_add_entities([CoverGroup(config[CONF_NAME], config[CONF_ENTITIES])]) + async_add_entities( + [ + CoverGroup( + config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + ) + ] + ) class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" - def __init__(self, name, entities): - """Initialize a CoverGroup entity.""" - self._name = name - self._is_closed = False - self._is_closing = False - self._is_opening = False - self._cover_position: int | None = 100 - self._tilt_position = None - self._supported_features = 0 - self._assumed_state = True + _attr_is_closed: bool | None = None + _attr_is_opening: bool | None = False + _attr_is_closing: bool | None = False + _attr_current_cover_position: int | None = 100 + _attr_assumed_state: bool = True + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: + """Initialize a CoverGroup entity.""" self._entities = entities self._covers: dict[str, set[str]] = { KEY_OPEN_CLOSE: set(), @@ -93,11 +106,16 @@ def __init__(self, name, entities): KEY_POSITION: set(), } - async def _update_supported_features_event(self, event): + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} + self._attr_unique_id = unique_id + + async def _update_supported_features_event(self, event: Event) -> None: self.async_set_context(event.context) - await self.async_update_supported_features( - event.data.get("entity_id"), event.data.get("new_state") - ) + if (entity := event.data.get("entity_id")) is not None: + await self.async_update_supported_features( + entity, event.data.get("new_state") + ) async def async_update_supported_features( self, @@ -146,11 +164,10 @@ async def async_update_supported_features( if update_state: await self.async_defer_or_update_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register listeners.""" for entity_id in self._entities: - new_state = self.hass.states.get(entity_id) - if new_state is None: + if (new_state := self.hass.states.get(entity_id)) is None: continue await self.async_update_supported_features( entity_id, new_state, update_state=False @@ -166,73 +183,28 @@ async def async_added_to_hass(self): return await super().async_added_to_hass() - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def assumed_state(self): - """Enable buttons even if at end position.""" - return self._assumed_state - - @property - def supported_features(self): - """Flag supported features for the cover.""" - return self._supported_features - - @property - def is_closed(self): - """Return if all covers in group are closed.""" - return self._is_closed - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def current_cover_position(self) -> int | None: - """Return current position for all covers.""" - return self._cover_position - - @property - def current_cover_tilt_position(self): - """Return current tilt position for all covers.""" - return self._tilt_position - - @property - def extra_state_attributes(self): - """Return the state attributes for the cover group.""" - return {ATTR_ENTITY_ID: self._entities} - - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the covers up.""" data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} await self.hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, data, blocking=True, context=self._context ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the covers down.""" data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} await self.hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER, data, blocking=True, context=self._context ) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" data = {ATTR_ENTITY_ID: self._covers[KEY_STOP]} await self.hass.services.async_call( DOMAIN, SERVICE_STOP_COVER, data, blocking=True, context=self._context ) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set covers position.""" data = { ATTR_ENTITY_ID: self._covers[KEY_POSITION], @@ -246,28 +218,28 @@ async def async_set_cover_position(self, **kwargs): context=self._context, ) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt covers open.""" data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} await self.hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, data, blocking=True, context=self._context ) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt covers closed.""" data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} await self.hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, data, blocking=True, context=self._context ) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" data = {ATTR_ENTITY_ID: self._tilts[KEY_STOP]} await self.hass.services.async_call( DOMAIN, SERVICE_STOP_COVER_TILT, data, blocking=True, context=self._context ) - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Set tilt position.""" data = { ATTR_ENTITY_ID: self._tilts[KEY_POSITION], @@ -281,62 +253,54 @@ async def async_set_cover_tilt_position(self, **kwargs): context=self._context, ) - async def async_update(self): + async def async_update(self) -> None: """Update state and attributes.""" - self._assumed_state = False + self._attr_assumed_state = False - self._is_closed = True - self._is_closing = False - self._is_opening = False + self._attr_is_closed = True + self._attr_is_closing = False + self._attr_is_opening = False + has_valid_state = False for entity_id in self._entities: - state = self.hass.states.get(entity_id) - if not state: + if not (state := self.hass.states.get(entity_id)): continue if state.state == STATE_OPEN: - self._is_closed = False - break + self._attr_is_closed = False + has_valid_state = True + continue + if state.state == STATE_CLOSED: + has_valid_state = True + continue if state.state == STATE_CLOSING: - self._is_closing = True - break + self._attr_is_closing = True + has_valid_state = True + continue if state.state == STATE_OPENING: - self._is_opening = True - break - - self._cover_position = None - if self._covers[KEY_POSITION]: - position = -1 - self._cover_position = 0 if self.is_closed else 100 - for entity_id in self._covers[KEY_POSITION]: - state = self.hass.states.get(entity_id) - if state is None: - continue - pos = state.attributes.get(ATTR_CURRENT_POSITION) - if position == -1: - position = pos - elif position != pos: - self._assumed_state = True - break - else: - if position != -1: - self._cover_position = position - - self._tilt_position = None - if self._tilts[KEY_POSITION]: - position = -1 - self._tilt_position = 100 - for entity_id in self._tilts[KEY_POSITION]: - state = self.hass.states.get(entity_id) - if state is None: - continue - pos = state.attributes.get(ATTR_CURRENT_TILT_POSITION) - if position == -1: - position = pos - elif position != pos: - self._assumed_state = True - break - else: - if position != -1: - self._tilt_position = position + self._attr_is_opening = True + has_valid_state = True + continue + if not has_valid_state: + self._attr_is_closed = None + + position_covers = self._covers[KEY_POSITION] + all_position_states = [self.hass.states.get(x) for x in position_covers] + position_states: list[State] = list(filter(None, all_position_states)) + self._attr_current_cover_position = reduce_attribute( + position_states, ATTR_CURRENT_POSITION + ) + self._attr_assumed_state |= not attribute_equal( + position_states, ATTR_CURRENT_POSITION + ) + + tilt_covers = self._tilts[KEY_POSITION] + all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] + tilt_states: list[State] = list(filter(None, all_tilt_states)) + self._attr_current_cover_tilt_position = reduce_attribute( + tilt_states, ATTR_CURRENT_TILT_POSITION + ) + self._attr_assumed_state |= not attribute_equal( + tilt_states, ATTR_CURRENT_TILT_POSITION + ) supported_features = 0 supported_features |= ( @@ -351,13 +315,12 @@ async def async_update(self): supported_features |= ( SUPPORT_SET_TILT_POSITION if self._tilts[KEY_POSITION] else 0 ) - self._supported_features = supported_features + self._attr_supported_features = supported_features - if not self._assumed_state: + if not self._attr_assumed_state: for entity_id in self._entities: - state = self.hass.states.get(entity_id) - if state is None: + if (state := self.hass.states.get(entity_id)) is None: continue if state and state.attributes.get(ATTR_ASSUMED_STATE): - self._assumed_state = True + self._attr_assumed_state = True break diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py new file mode 100644 index 0000000000000..d36fcc39f4326 --- /dev/null +++ b/homeassistant/components/group/fan.py @@ -0,0 +1,284 @@ +"""This platform allows several fans to be grouped into one fan.""" +from __future__ import annotations + +from functools import reduce +import logging +from operator import ior +from typing import Any + +import voluptuous as vol + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + DOMAIN, + PLATFORM_SCHEMA, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_ON, +) +from homeassistant.core import CoreState, Event, HomeAssistant, State +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType + +from . import GroupEntity +from .util import ( + attribute_equal, + most_frequent_attribute, + reduce_attribute, + states_equal, +) + +SUPPORTED_FLAGS = {SUPPORT_SET_SPEED, SUPPORT_DIRECTION, SUPPORT_OSCILLATE} + +DEFAULT_NAME = "Fan Group" + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the Group Cover platform.""" + async_add_entities( + [FanGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES])] + ) + + +class FanGroup(GroupEntity, FanEntity): + """Representation of a FanGroup.""" + + _attr_assumed_state: bool = True + + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: + """Initialize a FanGroup entity.""" + self._entities = entities + self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS} + self._percentage = None + self._oscillating = None + self._direction = None + self._supported_features = 0 + self._speed_count = 100 + self._is_on = False + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} + self._attr_unique_id = unique_id + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return self._speed_count + + @property + def is_on(self) -> bool: + """Return true if the entity is on.""" + return self._is_on + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage.""" + return self._percentage + + @property + def current_direction(self) -> str | None: + """Return the current direction of the fan.""" + return self._direction + + @property + def oscillating(self) -> bool | None: + """Return whether or not the fan is currently oscillating.""" + return self._oscillating + + async def _update_supported_features_event(self, event: Event) -> None: + self.async_set_context(event.context) + if (entity := event.data.get("entity_id")) is not None: + await self.async_update_supported_features( + entity, event.data.get("new_state") + ) + + async def async_update_supported_features( + self, + entity_id: str, + new_state: State | None, + update_state: bool = True, + ) -> None: + """Update dictionaries with supported features.""" + if not new_state: + for values in self._fans.values(): + values.discard(entity_id) + else: + features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature in SUPPORTED_FLAGS: + if features & feature: + self._fans[feature].add(entity_id) + else: + self._fans[feature].discard(entity_id) + + if update_state: + await self.async_defer_or_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Register listeners.""" + for entity_id in self._entities: + if (new_state := self.hass.states.get(entity_id)) is None: + continue + await self.async_update_supported_features( + entity_id, new_state, update_state=False + ) + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entities, self._update_supported_features_event + ) + ) + + if self.hass.state == CoreState.running: + await self.async_update() + return + await super().async_added_to_hass() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage == 0: + await self.async_turn_off() + await self._async_call_supported_entities( + SERVICE_SET_PERCENTAGE, SUPPORT_SET_SPEED, {ATTR_PERCENTAGE: percentage} + ) + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self._async_call_supported_entities( + SERVICE_OSCILLATE, SUPPORT_OSCILLATE, {ATTR_OSCILLATING: oscillating} + ) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + await self._async_call_supported_entities( + SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, {ATTR_DIRECTION: direction} + ) + + async def async_turn_on( + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + await self.async_set_percentage(percentage) + return + await self._async_call_all_entities(SERVICE_TURN_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fans off.""" + await self._async_call_all_entities(SERVICE_TURN_OFF) + + async def _async_call_supported_entities( + self, service: str, support_flag: int, data: dict[str, Any] + ) -> None: + """Call a service with all entities.""" + await self.hass.services.async_call( + DOMAIN, + service, + {**data, ATTR_ENTITY_ID: self._fans[support_flag]}, + blocking=True, + context=self._context, + ) + + async def _async_call_all_entities(self, service: str) -> None: + """Call a service with all entities.""" + await self.hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: self._entities}, + blocking=True, + context=self._context, + ) + + def _async_states_by_support_flag(self, flag: int) -> list[State]: + """Return all the entity states for a supported flag.""" + states: list[State] = list( + filter(None, [self.hass.states.get(x) for x in self._fans[flag]]) + ) + return states + + def _set_attr_most_frequent(self, attr: str, flag: int, entity_attr: str) -> None: + """Set an attribute based on most frequent supported entities attributes.""" + states = self._async_states_by_support_flag(flag) + setattr(self, attr, most_frequent_attribute(states, entity_attr)) + self._attr_assumed_state |= not attribute_equal(states, entity_attr) + + async def async_update(self) -> None: + """Update state and attributes.""" + self._attr_assumed_state = False + + on_states: list[State] = list( + filter(None, [self.hass.states.get(x) for x in self._entities]) + ) + self._is_on = any(state.state == STATE_ON for state in on_states) + self._attr_assumed_state |= not states_equal(on_states) + + percentage_states = self._async_states_by_support_flag(SUPPORT_SET_SPEED) + self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) + self._attr_assumed_state |= not attribute_equal( + percentage_states, ATTR_PERCENTAGE + ) + if ( + percentage_states + and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) + and attribute_equal(percentage_states, ATTR_PERCENTAGE_STEP) + ): + self._speed_count = ( + round(100 / percentage_states[0].attributes[ATTR_PERCENTAGE_STEP]) + or 100 + ) + else: + self._speed_count = 100 + + self._set_attr_most_frequent( + "_oscillating", SUPPORT_OSCILLATE, ATTR_OSCILLATING + ) + self._set_attr_most_frequent("_direction", SUPPORT_DIRECTION, ATTR_DIRECTION) + + self._supported_features = reduce( + ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 + ) + self._attr_assumed_state |= any( + state.attributes.get(ATTR_ASSUMED_STATE) for state in on_states + ) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 84c218b5d7218..4a14bc5dcf3a0 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,11 +1,10 @@ """This platform allows several lights to be grouped into one light.""" from __future__ import annotations -import asyncio from collections import Counter -from collections.abc import Iterator import itertools -from typing import Any, Callable, cast +import logging +from typing import Any, Set, cast import voluptuous as vol @@ -25,40 +24,41 @@ ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_ONOFF, PLATFORM_SCHEMA, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, - color_supported, - color_temp_supported, ) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, + CONF_UNIQUE_ID, STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.core import CoreState, Event, HomeAssistant, State import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType -from homeassistant.util import color as color_util from . import GroupEntity - -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs -# mypy: no-check-untyped-defs +from .util import find_state_attributes, mean_tuple, reduce_attribute DEFAULT_NAME = "Light Group" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN), } ) @@ -67,46 +67,66 @@ SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_TRANSITION | SUPPORT_WHITE_VALUE ) +_LOGGER = logging.getLogger(__name__) + async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, ) -> None: """Initialize light.group platform.""" async_add_entities( - [LightGroup(cast(str, config.get(CONF_NAME)), config[CONF_ENTITIES])] + [ + LightGroup( + config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + ) + ] ) +FORWARDED_ATTRIBUTES = frozenset( + { + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_TRANSITION, + ATTR_WHITE, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + } +) + + class LightGroup(GroupEntity, light.LightEntity): """Representation of a light group.""" - def __init__(self, name: str, entity_ids: list[str]) -> None: + _attr_available = False + _attr_icon = "mdi:lightbulb-group" + _attr_is_on = False + _attr_max_mireds = 500 + _attr_min_mireds = 154 + _attr_should_poll = False + + def __init__(self, unique_id: str | None, name: str, entity_ids: list[str]) -> None: """Initialize a light group.""" - self._name = name self._entity_ids = entity_ids - self._is_on = False - self._available = False - self._icon = "mdi:lightbulb-group" - self._brightness: int | None = None - self._color_mode: str | None = None - self._hs_color: tuple[float, float] | None = None - self._rgb_color: tuple[int, int, int] | None = None - self._rgbw_color: tuple[int, int, int, int] | None = None - self._rgbww_color: tuple[int, int, int, int, int] | None = None - self._xy_color: tuple[float, float] | None = None - self._color_temp: int | None = None - self._min_mireds: int = 154 - self._max_mireds: int = 500 self._white_value: int | None = None - self._effect_list: list[str] | None = None - self._effect: str | None = None - self._supported_color_modes: set[str] | None = None - self._supported_features: int = 0 + + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id async def async_added_to_hass(self) -> None: """Register callbacks.""" - async def async_state_changed_listener(event): + async def async_state_changed_listener(event: Event) -> None: """Handle child updates.""" self.async_set_context(event.context) await self.async_defer_or_update_ha_state() @@ -123,202 +143,29 @@ async def async_state_changed_listener(event): await super().async_added_to_hass() - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def is_on(self) -> bool: - """Return the on/off state of the light group.""" - return self._is_on - - @property - def available(self) -> bool: - """Return whether the light group is available.""" - return self._available - - @property - def icon(self): - """Return the light group icon.""" - return self._icon - - @property - def brightness(self) -> int | None: - """Return the brightness of this light group between 0..255.""" - return self._brightness - - @property - def color_mode(self) -> str | None: - """Return the color mode of the light.""" - return self._color_mode - - @property - def hs_color(self) -> tuple[float, float] | None: - """Return the HS color value [float, float].""" - return self._hs_color - - @property - def rgb_color(self) -> tuple[int, int, int] | None: - """Return the rgb color value [int, int, int].""" - return self._rgb_color - - @property - def rgbw_color(self) -> tuple[int, int, int, int] | None: - """Return the rgbw color value [int, int, int, int].""" - return self._rgbw_color - - @property - def rgbww_color(self) -> tuple[int, int, int, int, int] | None: - """Return the rgbww color value [int, int, int, int, int].""" - return self._rgbww_color - - @property - def xy_color(self) -> tuple[float, float] | None: - """Return the xy color value [float, float].""" - return self._xy_color - - @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self._color_temp - - @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light group supports.""" - return self._min_mireds - - @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light group supports.""" - return self._max_mireds - @property def white_value(self) -> int | None: """Return the white value of this light group between 0..255.""" return self._white_value - @property - def effect_list(self) -> list[str] | None: - """Return the list of supported effects.""" - return self._effect_list - - @property - def effect(self) -> str | None: - """Return the current effect.""" - return self._effect - - @property - def supported_color_modes(self) -> set | None: - """Flag supported color modes.""" - return self._supported_color_modes - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return self._supported_features - - @property - def should_poll(self) -> bool: - """No polling needed for a light group.""" - return False - - @property - def extra_state_attributes(self): - """Return the state attributes for the light group.""" - return {ATTR_ENTITY_ID: self._entity_ids} - - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all lights in the light group.""" - data = {ATTR_ENTITY_ID: self._entity_ids} - emulate_color_temp_entity_ids = [] - - if ATTR_BRIGHTNESS in kwargs: - data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] - - if ATTR_HS_COLOR in kwargs: - data[ATTR_HS_COLOR] = kwargs[ATTR_HS_COLOR] - - if ATTR_RGB_COLOR in kwargs: - data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR] - - if ATTR_RGBW_COLOR in kwargs: - data[ATTR_RGBW_COLOR] = kwargs[ATTR_RGBW_COLOR] - - if ATTR_RGBWW_COLOR in kwargs: - data[ATTR_RGBWW_COLOR] = kwargs[ATTR_RGBWW_COLOR] - - if ATTR_XY_COLOR in kwargs: - data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR] - - if ATTR_COLOR_TEMP in kwargs: - data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] + data = { + key: value for key, value in kwargs.items() if key in FORWARDED_ATTRIBUTES + } + data[ATTR_ENTITY_ID] = self._entity_ids - # Create a new entity list to mutate - updated_entities = list(self._entity_ids) + _LOGGER.debug("Forwarded turn_on command: %s", data) - # Walk through initial entity ids, split entity lists by support - for entity_id in self._entity_ids: - state = self.hass.states.get(entity_id) - if not state: - continue - support = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) - # Only pass color temperature to supported entity_ids - if color_supported(support) and not color_temp_supported(support): - emulate_color_temp_entity_ids.append(entity_id) - updated_entities.remove(entity_id) - data[ATTR_ENTITY_ID] = updated_entities - - if ATTR_WHITE_VALUE in kwargs: - data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] - - if ATTR_EFFECT in kwargs: - data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] - - if ATTR_TRANSITION in kwargs: - data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] - - if ATTR_FLASH in kwargs: - data[ATTR_FLASH] = kwargs[ATTR_FLASH] - - if not emulate_color_temp_entity_ids: - await self.hass.services.async_call( - light.DOMAIN, - light.SERVICE_TURN_ON, - data, - blocking=True, - context=self._context, - ) - return - - emulate_color_temp_data = data.copy() - temp_k = color_util.color_temperature_mired_to_kelvin( - emulate_color_temp_data[ATTR_COLOR_TEMP] - ) - hs_color = color_util.color_temperature_to_hs(temp_k) - emulate_color_temp_data[ATTR_HS_COLOR] = hs_color - del emulate_color_temp_data[ATTR_COLOR_TEMP] - - emulate_color_temp_data[ATTR_ENTITY_ID] = emulate_color_temp_entity_ids - - await asyncio.gather( - self.hass.services.async_call( - light.DOMAIN, - light.SERVICE_TURN_ON, - data, - blocking=True, - context=self._context, - ), - self.hass.services.async_call( - light.DOMAIN, - light.SERVICE_TURN_ON, - emulate_color_temp_data, - blocking=True, - context=self._context, - ), + await self.hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_ON, + data, + blocking=True, + context=self._context, ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Forward the turn_off command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} @@ -333,111 +180,85 @@ async def async_turn_off(self, **kwargs): context=self._context, ) - async def async_update(self): + async def async_update(self) -> None: """Query all members and determine the light group state.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] states: list[State] = list(filter(None, all_states)) on_states = [state for state in states if state.state == STATE_ON] - self._is_on = len(on_states) > 0 - self._available = any(state.state != STATE_UNAVAILABLE for state in states) - - self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) + self._attr_is_on = len(on_states) > 0 + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) + self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) - self._hs_color = _reduce_attribute(on_states, ATTR_HS_COLOR, reduce=_mean_tuple) - self._rgb_color = _reduce_attribute( - on_states, ATTR_RGB_COLOR, reduce=_mean_tuple + self._attr_hs_color = reduce_attribute( + on_states, ATTR_HS_COLOR, reduce=mean_tuple ) - self._rgbw_color = _reduce_attribute( - on_states, ATTR_RGBW_COLOR, reduce=_mean_tuple + self._attr_rgb_color = reduce_attribute( + on_states, ATTR_RGB_COLOR, reduce=mean_tuple ) - self._rgbww_color = _reduce_attribute( - on_states, ATTR_RGBWW_COLOR, reduce=_mean_tuple + self._attr_rgbw_color = reduce_attribute( + on_states, ATTR_RGBW_COLOR, reduce=mean_tuple + ) + self._attr_rgbww_color = reduce_attribute( + on_states, ATTR_RGBWW_COLOR, reduce=mean_tuple + ) + self._attr_xy_color = reduce_attribute( + on_states, ATTR_XY_COLOR, reduce=mean_tuple ) - self._xy_color = _reduce_attribute(on_states, ATTR_XY_COLOR, reduce=_mean_tuple) - self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) + self._white_value = reduce_attribute(on_states, ATTR_WHITE_VALUE) - self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) - self._min_mireds = _reduce_attribute( + self._attr_color_temp = reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._attr_min_mireds = reduce_attribute( states, ATTR_MIN_MIREDS, default=154, reduce=min ) - self._max_mireds = _reduce_attribute( + self._attr_max_mireds = reduce_attribute( states, ATTR_MAX_MIREDS, default=500, reduce=max ) - self._effect_list = None - all_effect_lists = list(_find_state_attributes(states, ATTR_EFFECT_LIST)) + self._attr_effect_list = None + all_effect_lists = list(find_state_attributes(states, ATTR_EFFECT_LIST)) if all_effect_lists: # Merge all effects from all effect_lists with a union merge. - self._effect_list = list(set().union(*all_effect_lists)) - - self._effect = None - all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) + self._attr_effect_list = list(set().union(*all_effect_lists)) + self._attr_effect_list.sort() + if "None" in self._attr_effect_list: + self._attr_effect_list.remove("None") + self._attr_effect_list.insert(0, "None") + + self._attr_effect = None + all_effects = list(find_state_attributes(on_states, ATTR_EFFECT)) if all_effects: # Report the most common effect. effects_count = Counter(itertools.chain(all_effects)) - self._effect = effects_count.most_common(1)[0][0] + self._attr_effect = effects_count.most_common(1)[0][0] - self._color_mode = None - all_color_modes = list(_find_state_attributes(on_states, ATTR_COLOR_MODE)) + self._attr_color_mode = None + all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) if all_color_modes: - # Report the most common color mode. + # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) - self._color_mode = color_mode_count.most_common(1)[0][0] + if COLOR_MODE_ONOFF in color_mode_count: + color_mode_count[COLOR_MODE_ONOFF] = -1 + if COLOR_MODE_BRIGHTNESS in color_mode_count: + color_mode_count[COLOR_MODE_BRIGHTNESS] = 0 + self._attr_color_mode = color_mode_count.most_common(1)[0][0] - self._supported_color_modes = None + self._attr_supported_color_modes = None all_supported_color_modes = list( - _find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. - self._supported_color_modes = set().union(*all_supported_color_modes) + self._attr_supported_color_modes = cast( + Set[str], set().union(*all_supported_color_modes) + ) - self._supported_features = 0 - for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + self._attr_supported_features = 0 + for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature # we find. - self._supported_features |= support + self._attr_supported_features |= support # Bitwise-and the supported features with the GroupedLight's features # so that we don't break in the future when a new feature is added. - self._supported_features &= SUPPORT_GROUP_LIGHT - - -def _find_state_attributes(states: list[State], key: str) -> Iterator[Any]: - """Find attributes with matching key from states.""" - for state in states: - value = state.attributes.get(key) - if value is not None: - yield value - - -def _mean_int(*args): - """Return the mean of the supplied values.""" - return int(sum(args) / len(args)) - - -def _mean_tuple(*args): - """Return the mean values along the columns of the supplied values.""" - return tuple(sum(x) / len(x) for x in zip(*args)) - - -def _reduce_attribute( - states: list[State], - key: str, - default: Any | None = None, - reduce: Callable[..., Any] = _mean_int, -) -> Any: - """Find the first attribute matching key from states. - - If none are found, return default. - """ - attrs = list(_find_state_attributes(states, key)) - - if not attrs: - return default - - if len(attrs) == 1: - return attrs[0] - - return reduce(*attrs) + self._attr_supported_features &= SUPPORT_GROUP_LIGHT diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py new file mode 100644 index 0000000000000..844e6e3799f8f --- /dev/null +++ b/homeassistant/components/group/media_player.py @@ -0,0 +1,421 @@ +"""This platform allows several media players to be grouped into one media player.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import voluptuous as vol + +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN, + PLATFORM_SCHEMA, + SERVICE_CLEAR_PLAYLIST, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_PLAY_MEDIA, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_SHUFFLE_SET, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, + MediaPlayerEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, State, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType + +KEY_CLEAR_PLAYLIST = "clear_playlist" +KEY_ON_OFF = "on_off" +KEY_PAUSE_PLAY_STOP = "play" +KEY_PLAY_MEDIA = "play_media" +KEY_SHUFFLE = "shuffle" +KEY_SEEK = "seek" +KEY_TRACKS = "tracks" +KEY_VOLUME = "volume" + +DEFAULT_NAME = "Media Group" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: Callable, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Media Group platform.""" + async_add_entities( + [ + MediaGroup( + config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + ) + ] + ) + + +class MediaGroup(MediaPlayerEntity): + """Representation of a Media Group.""" + + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: + """Initialize a Media Group entity.""" + self._name = name + self._state: str | None = None + self._supported_features: int = 0 + self._attr_unique_id = unique_id + + self._entities = entities + self._features: dict[str, set[str]] = { + KEY_CLEAR_PLAYLIST: set(), + KEY_ON_OFF: set(), + KEY_PAUSE_PLAY_STOP: set(), + KEY_PLAY_MEDIA: set(), + KEY_SHUFFLE: set(), + KEY_SEEK: set(), + KEY_TRACKS: set(), + KEY_VOLUME: set(), + } + + @callback + def async_on_state_change(self, event: EventType) -> None: + """Update supported features and state when a new state is received.""" + self.async_set_context(event.context) + self.async_update_supported_features( + event.data.get("entity_id"), event.data.get("new_state") # type: ignore + ) + self.async_update_state() + + @callback + def async_update_supported_features( + self, + entity_id: str, + new_state: State | None, + ) -> None: + """Update dictionaries with supported features.""" + if not new_state: + for players in self._features.values(): + players.discard(entity_id) + return + + new_features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if new_features & SUPPORT_CLEAR_PLAYLIST: + self._features[KEY_CLEAR_PLAYLIST].add(entity_id) + else: + self._features[KEY_CLEAR_PLAYLIST].discard(entity_id) + if new_features & (SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK): + self._features[KEY_TRACKS].add(entity_id) + else: + self._features[KEY_TRACKS].discard(entity_id) + if new_features & (SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP): + self._features[KEY_PAUSE_PLAY_STOP].add(entity_id) + else: + self._features[KEY_PAUSE_PLAY_STOP].discard(entity_id) + if new_features & SUPPORT_PLAY_MEDIA: + self._features[KEY_PLAY_MEDIA].add(entity_id) + else: + self._features[KEY_PLAY_MEDIA].discard(entity_id) + if new_features & SUPPORT_SEEK: + self._features[KEY_SEEK].add(entity_id) + else: + self._features[KEY_SEEK].discard(entity_id) + if new_features & SUPPORT_SHUFFLE_SET: + self._features[KEY_SHUFFLE].add(entity_id) + else: + self._features[KEY_SHUFFLE].discard(entity_id) + if new_features & (SUPPORT_TURN_ON | SUPPORT_TURN_OFF): + self._features[KEY_ON_OFF].add(entity_id) + else: + self._features[KEY_ON_OFF].discard(entity_id) + if new_features & ( + SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP + ): + self._features[KEY_VOLUME].add(entity_id) + else: + self._features[KEY_VOLUME].discard(entity_id) + + async def async_added_to_hass(self) -> None: + """Register listeners.""" + for entity_id in self._entities: + new_state = self.hass.states.get(entity_id) + self.async_update_supported_features(entity_id, new_state) + async_track_state_change_event( + self.hass, self._entities, self.async_on_state_change + ) + self.async_update_state() + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def state(self) -> str | None: + """Return the state of the media group.""" + return self._state + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def should_poll(self) -> bool: + """No polling needed for a media group.""" + return False + + @property + def extra_state_attributes(self) -> dict: + """Return the state attributes for the media group.""" + return {ATTR_ENTITY_ID: self._entities} + + async def async_clear_playlist(self) -> None: + """Clear players playlist.""" + data = {ATTR_ENTITY_ID: self._features[KEY_CLEAR_PLAYLIST]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_PLAYLIST, + data, + context=self._context, + ) + + async def async_media_next_track(self) -> None: + """Send next track command.""" + data = {ATTR_ENTITY_ID: self._features[KEY_TRACKS]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + data, + context=self._context, + ) + + async def async_media_pause(self) -> None: + """Send pause command.""" + data = {ATTR_ENTITY_ID: self._features[KEY_PAUSE_PLAY_STOP]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_MEDIA_PAUSE, + data, + context=self._context, + ) + + async def async_media_play(self) -> None: + """Send play command.""" + data = {ATTR_ENTITY_ID: self._features[KEY_PAUSE_PLAY_STOP]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_MEDIA_PLAY, + data, + context=self._context, + ) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + data = {ATTR_ENTITY_ID: self._features[KEY_TRACKS]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + data, + context=self._context, + ) + + async def async_media_seek(self, position: int) -> None: + """Send seek command.""" + data = { + ATTR_ENTITY_ID: self._features[KEY_SEEK], + ATTR_MEDIA_SEEK_POSITION: position, + } + await self.hass.services.async_call( + DOMAIN, + SERVICE_MEDIA_SEEK, + data, + context=self._context, + ) + + async def async_media_stop(self) -> None: + """Send stop command.""" + data = {ATTR_ENTITY_ID: self._features[KEY_PAUSE_PLAY_STOP]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_MEDIA_STOP, + data, + context=self._context, + ) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + data = { + ATTR_ENTITY_ID: self._features[KEY_VOLUME], + ATTR_MEDIA_VOLUME_MUTED: mute, + } + await self.hass.services.async_call( + DOMAIN, + SERVICE_VOLUME_MUTE, + data, + context=self._context, + ) + + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: + """Play a piece of media.""" + data = { + ATTR_ENTITY_ID: self._features[KEY_PLAY_MEDIA], + ATTR_MEDIA_CONTENT_ID: media_id, + ATTR_MEDIA_CONTENT_TYPE: media_type, + } + await self.hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + data, + context=self._context, + ) + + async def async_set_shuffle(self, shuffle: bool) -> None: + """Enable/disable shuffle mode.""" + data = { + ATTR_ENTITY_ID: self._features[KEY_SHUFFLE], + ATTR_MEDIA_SHUFFLE: shuffle, + } + await self.hass.services.async_call( + DOMAIN, + SERVICE_SHUFFLE_SET, + data, + context=self._context, + ) + + async def async_turn_on(self) -> None: + """Forward the turn_on command to all media in the media group.""" + data = {ATTR_ENTITY_ID: self._features[KEY_ON_OFF]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + data, + context=self._context, + ) + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level(s).""" + data = { + ATTR_ENTITY_ID: self._features[KEY_VOLUME], + ATTR_MEDIA_VOLUME_LEVEL: volume, + } + await self.hass.services.async_call( + DOMAIN, + SERVICE_VOLUME_SET, + data, + context=self._context, + ) + + async def async_turn_off(self) -> None: + """Forward the turn_off command to all media in the media group.""" + data = {ATTR_ENTITY_ID: self._features[KEY_ON_OFF]} + await self.hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + data, + context=self._context, + ) + + async def async_volume_up(self) -> None: + """Turn volume up for media player(s).""" + for entity in self._features[KEY_VOLUME]: + volume_level = self.hass.states.get(entity).attributes["volume_level"] # type: ignore + if volume_level < 1: + await self.async_set_volume_level(min(1, volume_level + 0.1)) + + async def async_volume_down(self) -> None: + """Turn volume down for media player(s).""" + for entity in self._features[KEY_VOLUME]: + volume_level = self.hass.states.get(entity).attributes["volume_level"] # type: ignore + if volume_level > 0: + await self.async_set_volume_level(max(0, volume_level - 0.1)) + + @callback + def async_update_state(self) -> None: + """Query all members and determine the media group state.""" + states = [self.hass.states.get(entity) for entity in self._entities] + states_values = [state.state for state in states if state is not None] + off_values = STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN + + if states_values: + if states_values.count(states_values[0]) == len(states_values): + self._state = states_values[0] + elif any(state for state in states_values if state not in off_values): + self._state = STATE_ON + else: + self._state = STATE_OFF + else: + self._state = None + + supported_features = 0 + supported_features |= ( + SUPPORT_CLEAR_PLAYLIST if self._features[KEY_CLEAR_PLAYLIST] else 0 + ) + supported_features |= ( + SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + if self._features[KEY_TRACKS] + else 0 + ) + supported_features |= ( + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP + if self._features[KEY_PAUSE_PLAY_STOP] + else 0 + ) + supported_features |= ( + SUPPORT_PLAY_MEDIA if self._features[KEY_PLAY_MEDIA] else 0 + ) + supported_features |= SUPPORT_SEEK if self._features[KEY_SEEK] else 0 + supported_features |= SUPPORT_SHUFFLE_SET if self._features[KEY_SHUFFLE] else 0 + supported_features |= ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF if self._features[KEY_ON_OFF] else 0 + ) + supported_features |= ( + SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP + if self._features[KEY_VOLUME] + else 0 + ) + + self._supported_features = supported_features + self.async_write_ha_state() diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index f1e64a60022b3..3e7f1eb203db1 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -41,7 +41,6 @@ set: all: name: All description: Enable this option if the group should only turn on when all entities are on. - example: true selector: boolean: diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index caa6ee98ea84c..798a8e1e7c6e7 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -4,14 +4,14 @@ "closed": "\u05e1\u05d2\u05d5\u05e8", "home": "\u05d1\u05d1\u05d9\u05ea", "locked": "\u05e0\u05e2\u05d5\u05dc", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "not_home": "\u05d1\u05d7\u05d5\u05e5", "off": "\u05db\u05d1\u05d5\u05d9", "ok": "\u05ea\u05e7\u05d9\u05df", - "on": "\u05d3\u05dc\u05d5\u05e7", + "on": "\u05de\u05d5\u05e4\u05e2\u05dc", "open": "\u05e4\u05ea\u05d5\u05d7", "problem": "\u05d1\u05e2\u05d9\u05d4", "unlocked": "\u05e4\u05ea\u05d5\u05d7" } }, - "title": "\u05e7\u05b0\u05d1\u05d5\u05bc\u05e6\u05b8\u05d4" + "title": "\u05e7\u05d1\u05d5\u05e6\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/group/translations/hr.json b/homeassistant/components/group/translations/hr.json index 85abe33638b8d..fbf123b0e88ff 100644 --- a/homeassistant/components/group/translations/hr.json +++ b/homeassistant/components/group/translations/hr.json @@ -5,7 +5,7 @@ "home": "Doma", "locked": "Zaklju\u010dano", "not_home": "Odsutan", - "off": "Uklju\u010deno", + "off": "Isklju\u010deno", "ok": "U redu", "on": "Uklju\u010deno", "open": "Otvoreno", diff --git a/homeassistant/components/group/translations/ja.json b/homeassistant/components/group/translations/ja.json index d6f283d5ef638..02aff2e2b8480 100644 --- a/homeassistant/components/group/translations/ja.json +++ b/homeassistant/components/group/translations/ja.json @@ -4,10 +4,13 @@ "closed": "\u9589\u9396", "home": "\u5728\u5b85", "locked": "\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f", - "not_home": "\u5916\u51fa", + "not_home": "\u96e2\u5e2d(away)", "off": "\u30aa\u30d5", "ok": "OK", - "on": "\u30aa\u30f3" + "on": "\u30aa\u30f3", + "open": "\u30aa\u30fc\u30d7\u30f3", + "problem": "\u554f\u984c", + "unlocked": "\u30ed\u30c3\u30af\u89e3\u9664" } }, "title": "\u30b0\u30eb\u30fc\u30d7" diff --git a/homeassistant/components/group/translations/ru.json b/homeassistant/components/group/translations/ru.json index 7103b9f75d0fe..7e8ab4d8be157 100644 --- a/homeassistant/components/group/translations/ru.json +++ b/homeassistant/components/group/translations/ru.json @@ -5,9 +5,9 @@ "home": "\u0414\u043e\u043c\u0430", "locked": "\u0417\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e", "not_home": "\u041d\u0435 \u0434\u043e\u043c\u0430", - "off": "\u0412\u044b\u043a\u043b", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", "ok": "\u041e\u041a", - "on": "\u0412\u043a\u043b", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e", "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u043e", "problem": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c\u0430", "unlocked": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e" diff --git a/homeassistant/components/group/translations/tr.json b/homeassistant/components/group/translations/tr.json index 5a596efdf0108..a491fe6b244e5 100644 --- a/homeassistant/components/group/translations/tr.json +++ b/homeassistant/components/group/translations/tr.json @@ -9,7 +9,7 @@ "ok": "Tamam", "on": "A\u00e7\u0131k", "open": "A\u00e7\u0131k", - "problem": "Problem", + "problem": "Sorun", "unlocked": "Kilitli de\u011fil" } }, diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py new file mode 100644 index 0000000000000..da67e071f2772 --- /dev/null +++ b/homeassistant/components/group/util.py @@ -0,0 +1,84 @@ +"""Utility functions to combine state attributes from multiple entities.""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from itertools import groupby +from typing import Any + +from homeassistant.core import State + + +def find_state_attributes(states: list[State], key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + if (value := state.attributes.get(key)) is not None: + yield value + + +def find_state(states: list[State]) -> Iterator[Any]: + """Find state from states.""" + for state in states: + yield state.state + + +def mean_int(*args: Any) -> int: + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def mean_tuple(*args: Any) -> tuple[float | Any, ...]: + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(x) / len(x) for x in zip(*args)) + + +def attribute_equal(states: list[State], key: str) -> bool: + """Return True if all attributes found matching key from states are equal. + + Note: Returns True if no matching attribute is found. + """ + return _values_equal(find_state_attributes(states, key)) + + +def most_frequent_attribute(states: list[State], key: str) -> Any | None: + """Find attributes with matching key from states.""" + if attrs := list(find_state_attributes(states, key)): + return max(set(attrs), key=attrs.count) + return None + + +def states_equal(states: list[State]) -> bool: + """Return True if all states are equal. + + Note: Returns True if no matching attribute is found. + """ + return _values_equal(find_state(states)) + + +def _values_equal(values: Iterator[Any]) -> bool: + """Return True if all values are equal. + + Note: Returns True if no matching attribute is found. + """ + grp = groupby(values) + return bool(next(grp, True) and not next(grp, False)) + + +def reduce_attribute( + states: list[State], + key: str, + default: Any | None = None, + reduce: Callable[..., Any] = mean_int, +) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index 300c96746e797..11f082f1eab73 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -3,17 +3,22 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback -from .const import CONF_PLANT_ID, DOMAIN +from .const import ( + CONF_PLANT_ID, + DEFAULT_URL, + DOMAIN, + LOGIN_INVALID_AUTH_CODE, + SERVER_URLS, +) class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow class.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL def __init__(self): """Initialise growatt server flow.""" @@ -25,7 +30,11 @@ def __init__(self): def _async_show_user_form(self, errors=None): """Show the form to the user.""" data_schema = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS), + } ) return self.async_show_form( @@ -37,13 +46,17 @@ async def async_step_user(self, user_input=None): if not user_input: return self._async_show_user_form() + self.api.server_url = user_input[CONF_URL] login_response = await self.hass.async_add_executor_job( self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) - if not login_response["success"] and login_response["errCode"] == "102": + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): return self._async_show_user_form({"base": "invalid_auth"}) - self.user_id = login_response["userId"] + self.user_id = login_response["user"]["id"] self.data = user_input return await self.async_step_plant() @@ -72,7 +85,3 @@ async def async_step_plant(self, user_input=None): self._abort_if_unique_id_configured() self.data.update(user_input) return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) - - async def async_step_import(self, import_data): - """Migrate old yaml config to config flow.""" - return await self.async_step_user(import_data) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4dc09988e6f97..4fcc48878436e 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -1,10 +1,22 @@ """Define constants for the Growatt Server component.""" +from homeassistant.const import Platform + CONF_PLANT_ID = "plant_id" DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" +SERVER_URLS = [ + "https://server.growatt.com/", + "https://server-us.growatt.com/", + "http://server.smten.com/", +] + +DEFAULT_URL = SERVER_URLS[0] + DOMAIN = "growatt_server" -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] + +LOGIN_INVALID_AUTH_CODE = "502" diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 94fc293b8d710..79472359ab912 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -3,7 +3,7 @@ "name": "Growatt", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", - "requirements": ["growattServer==1.0.0"], - "codeowners": ["@indykoning", "@muppet3000"], + "requirements": ["growattServer==1.1.0"], + "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 0ccdc9425f649..3ad0044f93e48 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -1,576 +1,35 @@ """Read status of growatt inverters.""" +from __future__ import annotations + import datetime import json import logging -import re import growattServer -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - CURRENCY_EURO, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, - ENERGY_KILO_WATT_HOUR, - FREQUENCY_HERTZ, - PERCENTAGE, - POWER_KILO_WATT, - POWER_WATT, - TEMP_CELSIUS, - VOLT, -) -import homeassistant.helpers.config_validation as cv + +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DOMAIN +from .const import ( + CONF_PLANT_ID, + DEFAULT_PLANT_ID, + DEFAULT_URL, + DOMAIN, + LOGIN_INVALID_AUTH_CODE, +) +from .sensor_types.inverter import INVERTER_SENSOR_TYPES +from .sensor_types.mix import MIX_SENSOR_TYPES +from .sensor_types.sensor_entity_description import GrowattSensorEntityDescription +from .sensor_types.storage import STORAGE_SENSOR_TYPES +from .sensor_types.tlx import TLX_SENSOR_TYPES +from .sensor_types.total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=1) -# Sensor type order is: Sensor name, Unit of measurement, api data name, additional options -TOTAL_SENSOR_TYPES = { - "total_money_today": ("Total money today", CURRENCY_EURO, "plantMoneyText", {}), - "total_money_total": ("Money lifetime", CURRENCY_EURO, "totalMoneyText", {}), - "total_energy_today": ( - "Energy Today", - ENERGY_KILO_WATT_HOUR, - "todayEnergy", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "total_output_power": ( - "Output Power", - POWER_WATT, - "invTodayPpv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "total_energy_output": ( - "Lifetime energy output", - ENERGY_KILO_WATT_HOUR, - "totalEnergy", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "total_maximum_output": ( - "Maximum power", - POWER_WATT, - "nominalPower", - {"device_class": DEVICE_CLASS_POWER}, - ), -} - -INVERTER_SENSOR_TYPES = { - "inverter_energy_today": ( - "Energy today", - ENERGY_KILO_WATT_HOUR, - "powerToday", - {"round": 1, "device_class": DEVICE_CLASS_ENERGY}, - ), - "inverter_energy_total": ( - "Lifetime energy output", - ENERGY_KILO_WATT_HOUR, - "powerTotal", - {"round": 1, "device_class": DEVICE_CLASS_ENERGY}, - ), - "inverter_voltage_input_1": ( - "Input 1 voltage", - VOLT, - "vpv1", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_1": ( - "Input 1 Amperage", - ELECTRICAL_CURRENT_AMPERE, - "ipv1", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_1": ( - "Input 1 Wattage", - POWER_WATT, - "ppv1", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_voltage_input_2": ( - "Input 2 voltage", - VOLT, - "vpv2", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_2": ( - "Input 2 Amperage", - ELECTRICAL_CURRENT_AMPERE, - "ipv2", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_2": ( - "Input 2 Wattage", - POWER_WATT, - "ppv2", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_voltage_input_3": ( - "Input 3 voltage", - VOLT, - "vpv3", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_3": ( - "Input 3 Amperage", - ELECTRICAL_CURRENT_AMPERE, - "ipv3", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_3": ( - "Input 3 Wattage", - POWER_WATT, - "ppv3", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_internal_wattage": ( - "Internal wattage", - POWER_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_reactive_voltage": ( - "Reactive voltage", - VOLT, - "vacr", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_inverter_reactive_amperage": ( - "Reactive amperage", - ELECTRICAL_CURRENT_AMPERE, - "iacr", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_frequency": ("AC frequency", FREQUENCY_HERTZ, "fac", {"round": 1}), - "inverter_current_wattage": ( - "Output power", - POWER_WATT, - "pac", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_current_reactive_wattage": ( - "Reactive wattage", - POWER_WATT, - "pacr", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_ipm_temperature": ( - "Intelligent Power Management temperature", - TEMP_CELSIUS, - "ipmTemperature", - {"device_class": DEVICE_CLASS_TEMPERATURE, "round": 1}, - ), - "inverter_temperature": ( - "Temperature", - TEMP_CELSIUS, - "temperature", - {"device_class": DEVICE_CLASS_TEMPERATURE, "round": 1}, - ), -} - -STORAGE_SENSOR_TYPES = { - "storage_storage_production_today": ( - "Storage production today", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_storage_production_lifetime": ( - "Lifetime Storage production", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_grid_discharge_today": ( - "Grid discharged today", - ENERGY_KILO_WATT_HOUR, - "eacDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption_today": ( - "Load consumption today", - ENERGY_KILO_WATT_HOUR, - "eopDischrToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption_lifetime": ( - "Lifetime load consumption", - ENERGY_KILO_WATT_HOUR, - "eopDischrTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_grid_charged_today": ( - "Grid charged today", - ENERGY_KILO_WATT_HOUR, - "eacChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_charge_storage_lifetime": ( - "Lifetime storaged charged", - ENERGY_KILO_WATT_HOUR, - "eChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_solar_production": ( - "Solar power production", - POWER_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_battery_percentage": ( - "Battery percentage", - PERCENTAGE, - "capacity", - {"device_class": DEVICE_CLASS_BATTERY}, - ), - "storage_power_flow": ( - "Storage charging/ discharging(-ve)", - POWER_WATT, - "pCharge", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_load_consumption_solar_storage": ( - "Load consumption(Solar + Storage)", - "VA", - "rateVA", - {}, - ), - "storage_charge_today": ( - "Charge today", - ENERGY_KILO_WATT_HOUR, - "eChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_import_from_grid": ( - "Import from grid", - POWER_WATT, - "pAcInPut", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_import_from_grid_today": ( - "Import from grid today", - ENERGY_KILO_WATT_HOUR, - "eToUserToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_import_from_grid_total": ( - "Import from grid total", - ENERGY_KILO_WATT_HOUR, - "eToUserTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption": ( - "Load consumption", - POWER_WATT, - "outPutPower", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_grid_voltage": ( - "AC input voltage", - VOLT, - "vGrid", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_pv_charging_voltage": ( - "PV charging voltage", - VOLT, - "vpv", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_ac_input_frequency_out": ( - "AC input frequency", - FREQUENCY_HERTZ, - "freqOutPut", - {"round": 2}, - ), - "storage_output_voltage": ( - "Output voltage", - VOLT, - "outPutVolt", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_ac_output_frequency": ( - "Ac output frequency", - FREQUENCY_HERTZ, - "freqGrid", - {"round": 2}, - ), - "storage_current_PV": ( - "Solar charge current", - ELECTRICAL_CURRENT_AMPERE, - "iAcCharge", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_current_1": ( - "Solar current to storage", - ELECTRICAL_CURRENT_AMPERE, - "iChargePV1", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_grid_amperage_input": ( - "Grid charge current", - ELECTRICAL_CURRENT_AMPERE, - "chgCurr", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_grid_out_current": ( - "Grid out current", - ELECTRICAL_CURRENT_AMPERE, - "outPutCurrent", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_battery_voltage": ( - "Battery voltage", - VOLT, - "vBat", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_load_percentage": ( - "Load percentage", - PERCENTAGE, - "loadPercent", - {"device_class": DEVICE_CLASS_BATTERY, "round": 2}, - ), -} - -MIX_SENSOR_TYPES = { - # Values from 'mix_info' API call - "mix_statement_of_charge": ( - "Statement of charge", - PERCENTAGE, - "capacity", - {"device_class": DEVICE_CLASS_BATTERY}, - ), - "mix_battery_charge_today": ( - "Battery charged today", - ENERGY_KILO_WATT_HOUR, - "eBatChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_charge_lifetime": ( - "Lifetime battery charged", - ENERGY_KILO_WATT_HOUR, - "eBatChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_discharge_today": ( - "Battery discharged today", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_discharge_lifetime": ( - "Lifetime battery discharged", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_solar_generation_today": ( - "Solar energy today", - ENERGY_KILO_WATT_HOUR, - "epvToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_solar_generation_lifetime": ( - "Lifetime solar energy", - ENERGY_KILO_WATT_HOUR, - "epvTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_discharge_w": ( - "Battery discharging W", - POWER_WATT, - "pDischarge1", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_battery_voltage": ( - "Battery voltage", - VOLT, - "vbat", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - "mix_pv1_voltage": ( - "PV1 voltage", - VOLT, - "vpv1", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - "mix_pv2_voltage": ( - "PV2 voltage", - VOLT, - "vpv2", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - # Values from 'mix_totals' API call - "mix_load_consumption_today": ( - "Load consumption today", - ENERGY_KILO_WATT_HOUR, - "elocalLoadToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_load_consumption_lifetime": ( - "Lifetime load consumption", - ENERGY_KILO_WATT_HOUR, - "elocalLoadTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_export_to_grid_today": ( - "Export to grid today", - ENERGY_KILO_WATT_HOUR, - "etoGridToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_export_to_grid_lifetime": ( - "Lifetime export to grid", - ENERGY_KILO_WATT_HOUR, - "etogridTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - # Values from 'mix_system_status' API call - "mix_battery_charge": ( - "Battery charging", - POWER_KILO_WATT, - "chargePower", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_load_consumption": ( - "Load consumption", - POWER_KILO_WATT, - "pLocalLoad", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_wattage_pv_1": ( - "PV1 Wattage", - POWER_WATT, - "pPv1", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_wattage_pv_2": ( - "PV2 Wattage", - POWER_WATT, - "pPv2", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_wattage_pv_all": ( - "All PV Wattage", - POWER_KILO_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_export_to_grid": ( - "Export to grid", - POWER_KILO_WATT, - "pactogrid", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_import_from_grid": ( - "Import from grid", - POWER_KILO_WATT, - "pactouser", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_battery_discharge_kw": ( - "Battery discharging kW", - POWER_KILO_WATT, - "pdisCharge1", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_grid_voltage": ( - "Grid voltage", - VOLT, - "vAc1", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - # Values from 'mix_detail' API call - "mix_system_production_today": ( - "System production today (self-consumption + export)", - ENERGY_KILO_WATT_HOUR, - "eCharge", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_load_consumption_solar_today": ( - "Load consumption today (solar)", - ENERGY_KILO_WATT_HOUR, - "eChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_self_consumption_today": ( - "Self consumption today (solar + battery)", - ENERGY_KILO_WATT_HOUR, - "eChargeToday1", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_load_consumption_battery_today": ( - "Load consumption today (battery)", - ENERGY_KILO_WATT_HOUR, - "echarge1", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_import_from_grid_today": ( - "Import from grid today (load)", - ENERGY_KILO_WATT_HOUR, - "etouser", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - # This sensor is manually created using the most recent X-Axis value from the chartData - "mix_last_update": ( - "Last Data Update", - None, - "lastdataupdate", - {"device_class": DEVICE_CLASS_TIMESTAMP}, - ), - # Values from 'dashboard_data' API call - "mix_import_from_grid_today_combined": ( - "Import from grid today (load + charging)", - ENERGY_KILO_WATT_HOUR, - "etouser_combined", # This id is not present in the raw API data, it is added by the sensor - {"device_class": DEVICE_CLASS_ENERGY}, - ), -} - -SENSOR_TYPES = { - **TOTAL_SENSOR_TYPES, - **INVERTER_SENSOR_TYPES, - **STORAGE_SENSOR_TYPES, - **MIX_SENSOR_TYPES, -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up growatt server from yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - _LOGGER.warning( - "Loading Growatt via platform setup is deprecated." - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - def get_device_list(api, config): """Retrieve the device list for the selected plant.""" @@ -578,10 +37,13 @@ def get_device_list(api, config): # Log in to api and fetch first plant if no plant id is defined. login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if not login_response["success"] and login_response["errCode"] == "102": - _LOGGER.error("Username or Password may be incorrect!") + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): + _LOGGER.error("Username, Password or URL may be incorrect!") return - user_id = login_response["userId"] + user_id = login_response["user"]["id"] if plant_id == DEFAULT_PLANT_ID: plant_info = api.plant_list(user_id) plant_id = plant_info["data"][0]["plantId"] @@ -596,48 +58,59 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config = config_entry.data username = config[CONF_USERNAME] password = config[CONF_PASSWORD] + url = config.get(CONF_URL, DEFAULT_URL) name = config[CONF_NAME] api = growattServer.GrowattApi() + api.server_url = url devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - entities = [] probe = GrowattData(api, username, password, plant_id, "total") - for sensor in TOTAL_SENSOR_TYPES: - entities.append( - GrowattInverter(probe, f"{name} Total", sensor, f"{plant_id}-{sensor}") + entities = [ + GrowattInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, ) + for description in TOTAL_SENSOR_TYPES + ] # Add sensors for each device in the specified plant. for device in devices: probe = GrowattData( api, username, password, device["deviceSn"], device["deviceType"] ) - sensors = [] + sensor_descriptions = () if device["deviceType"] == "inverter": - sensors = INVERTER_SENSOR_TYPES + sensor_descriptions = INVERTER_SENSOR_TYPES + elif device["deviceType"] == "tlx": + probe.plant_id = plant_id + sensor_descriptions = TLX_SENSOR_TYPES elif device["deviceType"] == "storage": probe.plant_id = plant_id - sensors = STORAGE_SENSOR_TYPES + sensor_descriptions = STORAGE_SENSOR_TYPES elif device["deviceType"] == "mix": probe.plant_id = plant_id - sensors = MIX_SENSOR_TYPES + sensor_descriptions = MIX_SENSOR_TYPES else: _LOGGER.debug( "Device type %s was found but is not supported right now", device["deviceType"], ) - for sensor in sensors: - entities.append( + entities.extend( + [ GrowattInverter( probe, - f"{device['deviceAilas']}", - sensor, - f"{device['deviceSn']}-{sensor}", + name=f"{device['deviceAilas']}", + unique_id=f"{device['deviceSn']}-{description.key}", + description=description, ) - ) + for description in sensor_descriptions + ] + ) async_add_entities(entities, True) @@ -645,47 +118,39 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GrowattInverter(SensorEntity): """Representation of a Growatt Sensor.""" - def __init__(self, probe, name, sensor, unique_id): + entity_description: GrowattSensorEntityDescription + + def __init__( + self, probe, name, unique_id, description: GrowattSensorEntityDescription + ): """Initialize a PVOutput sensor.""" - self.sensor = sensor self.probe = probe - self._name = name - self._state = None - self._unique_id = unique_id + self.entity_description = description - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSOR_TYPES[self.sensor][0]}" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return self._unique_id + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = "mdi:solar-power" - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:solar-power" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, probe.device_id)}, + manufacturer="Growatt", + name=name, + ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - result = self.probe.get_data(SENSOR_TYPES[self.sensor][2]) - round_to = SENSOR_TYPES[self.sensor][3].get("round") - if round_to is not None: - result = round(result, round_to) + result = self.probe.get_data(self.entity_description.api_key) + if self.entity_description.precision is not None: + result = round(result, self.entity_description.precision) return result @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self.sensor][3].get("device_class") - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self.sensor][1] + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + if self.entity_description.currency: + return self.probe.get_data("currency") + return super().native_unit_of_measurement def update(self): """Get the latest data from the Growat API and updates the state.""" @@ -710,19 +175,22 @@ def __init__(self, api, username, password, device_id, growatt_type): def update(self): """Update probe data.""" self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s", self.device_id) + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) try: if self.growatt_type == "total": total_info = self.api.plant_info(self.device_id) del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" remove anything that isn't part of the number - total_info["plantMoneyText"] = re.sub( - r"[^\d.,]", "", total_info["plantMoneyText"] - ) + # PlantMoneyText comes in as "3.1/€" split between value and currency + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency self.data = total_info elif self.growatt_type == "inverter": inverter_info = self.api.inverter_detail(self.device_id) self.data = inverter_info + elif self.growatt_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] elif self.growatt_type == "storage": storage_info_detail = self.api.storage_params(self.device_id)[ "storageDetailBean" @@ -738,9 +206,7 @@ def update(self): self.device_id, self.plant_id ) - mix_detail = self.api.mix_detail( - self.device_id, self.plant_id, date=datetime.datetime.now() - ) + mix_detail = self.api.mix_detail(self.device_id, self.plant_id) # Get the chart data and work out the time of the last entry, use this as the last time data was published to the Growatt Server mix_chart_entries = mix_detail["chartData"] sorted_keys = sorted(mix_chart_entries) @@ -761,7 +227,9 @@ def update(self): # Dashboard values have units e.g. "kWh" as part of their returned string, so we remove it dashboard_values_for_mix = { # etouser is already used by the results from 'mix_detail' so we rebrand it as 'etouser_combined' - "etouser_combined": dashboard_data["etouser"].replace("kWh", "") + "etouser_combined": float( + dashboard_data["etouser"].replace("kWh", "") + ) } self.data = { **mix_info, diff --git a/homeassistant/components/growatt_server/sensor_types/__init__.py b/homeassistant/components/growatt_server/sensor_types/__init__.py new file mode 100644 index 0000000000000..3f5be3be7f55c --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Growatt systems.""" diff --git a/homeassistant/components/growatt_server/sensor_types/inverter.py b/homeassistant/components/growatt_server/sensor_types/inverter.py new file mode 100644 index 0000000000000..eb9315c077789 --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/inverter.py @@ -0,0 +1,169 @@ +"""Growatt Sensor definitions for the Inverter type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + POWER_WATT, + TEMP_CELSIUS, +) + +from .sensor_entity_description import GrowattSensorEntityDescription + +INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_key="powerToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_key="powerTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + precision=1, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_1", + name="Input 1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_1", + name="Input 1 Amperage", + api_key="ipv1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_1", + name="Input 1 Wattage", + api_key="ppv1", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_2", + name="Input 2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_2", + name="Input 2 Amperage", + api_key="ipv2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_2", + name="Input 2 Wattage", + api_key="ppv2", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_3", + name="Input 3 voltage", + api_key="vpv3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_3", + name="Input 3 Amperage", + api_key="ipv3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_3", + name="Input 3 Wattage", + api_key="ppv3", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_internal_wattage", + name="Internal wattage", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_reactive_voltage", + name="Reactive voltage", + api_key="vacr", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_inverter_reactive_amperage", + name="Reactive amperage", + api_key="iacr", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_key="fac", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_key="pac", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_current_reactive_wattage", + name="Reactive wattage", + api_key="pacr", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_ipm_temperature", + name="Intelligent Power Management temperature", + api_key="ipmTemperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_key="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), +) diff --git a/homeassistant/components/growatt_server/sensor_types/mix.py b/homeassistant/components/growatt_server/sensor_types/mix.py new file mode 100644 index 0000000000000..6cb61ea2e08fb --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/mix.py @@ -0,0 +1,245 @@ +"""Growatt Sensor definitions for the Mix type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_KILO_WATT, + POWER_WATT, +) + +from .sensor_entity_description import GrowattSensorEntityDescription + +MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + # Values from 'mix_info' API call + GrowattSensorEntityDescription( + key="mix_statement_of_charge", + name="Statement of charge", + api_key="capacity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge_today", + name="Battery charged today", + api_key="eBatChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge_lifetime", + name="Lifetime battery charged", + api_key="eBatChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_today", + name="Battery discharged today", + api_key="eBatDisChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_lifetime", + name="Lifetime battery discharged", + api_key="eBatDisChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="mix_solar_generation_today", + name="Solar energy today", + api_key="epvToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_solar_generation_lifetime", + name="Lifetime solar energy", + api_key="epvTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_w", + name="Battery discharging W", + api_key="pDischarge1", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_battery_voltage", + name="Battery voltage", + api_key="vbat", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_pv1_voltage", + name="PV1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_pv2_voltage", + name="PV2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + # Values from 'mix_totals' API call + GrowattSensorEntityDescription( + key="mix_load_consumption_today", + name="Load consumption today", + api_key="elocalLoadToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_lifetime", + name="Lifetime load consumption", + api_key="elocalLoadTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid_today", + name="Export to grid today", + api_key="etoGridToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid_lifetime", + name="Lifetime export to grid", + api_key="etogridTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + # Values from 'mix_system_status' API call + GrowattSensorEntityDescription( + key="mix_battery_charge", + name="Battery charging", + api_key="chargePower", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption", + name="Load consumption", + api_key="pLocalLoad", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_1", + name="PV1 Wattage", + api_key="pPv1", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_2", + name="PV2 Wattage", + api_key="pPv2", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_all", + name="All PV Wattage", + api_key="ppv", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid", + name="Export to grid", + api_key="pactogrid", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_import_from_grid", + name="Import from grid", + api_key="pactouser", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_kw", + name="Battery discharging kW", + api_key="pdisCharge1", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="mix_grid_voltage", + name="Grid voltage", + api_key="vAc1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + # Values from 'mix_detail' API call + GrowattSensorEntityDescription( + key="mix_system_production_today", + name="System production today (self-consumption + export)", + api_key="eCharge", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_solar_today", + name="Load consumption today (solar)", + api_key="eChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_self_consumption_today", + name="Self consumption today (solar + battery)", + api_key="eChargeToday1", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_battery_today", + name="Load consumption today (battery)", + api_key="echarge1", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_import_from_grid_today", + name="Import from grid today (load)", + api_key="etouser", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + # This sensor is manually created using the most recent X-Axis value from the chartData + GrowattSensorEntityDescription( + key="mix_last_update", + name="Last Data Update", + api_key="lastdataupdate", + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + ), + # Values from 'dashboard_data' API call + GrowattSensorEntityDescription( + key="mix_import_from_grid_today_combined", + name="Import from grid today (load + charging)", + api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) diff --git a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py new file mode 100644 index 0000000000000..04822fca35b46 --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py @@ -0,0 +1,21 @@ +"""Sensor Entity Description for the Growatt integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class GrowattRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): + """Describes Growatt sensor entity.""" + + precision: int | None = None + currency: bool = False diff --git a/homeassistant/components/growatt_server/sensor_types/storage.py b/homeassistant/components/growatt_server/sensor_types/storage.py new file mode 100644 index 0000000000000..11e642746866a --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/storage.py @@ -0,0 +1,218 @@ +"""Growatt Sensor definitions for the Storage type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_WATT, +) + +from .sensor_entity_description import GrowattSensorEntityDescription + +STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="storage_storage_production_today", + name="Storage production today", + api_key="eBatDisChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_storage_production_lifetime", + name="Lifetime Storage production", + api_key="eBatDisChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="storage_grid_discharge_today", + name="Grid discharged today", + api_key="eacDisChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_today", + name="Load consumption today", + api_key="eopDischrToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_lifetime", + name="Lifetime load consumption", + api_key="eopDischrTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="storage_grid_charged_today", + name="Grid charged today", + api_key="eacChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_charge_storage_lifetime", + name="Lifetime storaged charged", + api_key="eChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="storage_solar_production", + name="Solar power production", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="storage_battery_percentage", + name="Battery percentage", + api_key="capacity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + GrowattSensorEntityDescription( + key="storage_power_flow", + name="Storage charging/ discharging(-ve)", + api_key="pCharge", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_solar_storage", + name="Load consumption(Solar + Storage)", + api_key="rateVA", + native_unit_of_measurement="VA", + ), + GrowattSensorEntityDescription( + key="storage_charge_today", + name="Charge today", + api_key="eChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid", + name="Import from grid", + api_key="pAcInPut", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid_today", + name="Import from grid today", + api_key="eToUserToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid_total", + name="Import from grid total", + api_key="eToUserTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption", + name="Load consumption", + api_key="outPutPower", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="storage_grid_voltage", + name="AC input voltage", + api_key="vGrid", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_pv_charging_voltage", + name="PV charging voltage", + api_key="vpv", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_ac_input_frequency_out", + name="AC input frequency", + api_key="freqOutPut", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_output_voltage", + name="Output voltage", + api_key="outPutVolt", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_ac_output_frequency", + name="Ac output frequency", + api_key="freqGrid", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_current_PV", + name="Solar charge current", + api_key="iAcCharge", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_current_1", + name="Solar current to storage", + api_key="iChargePV1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_grid_amperage_input", + name="Grid charge current", + api_key="chgCurr", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_grid_out_current", + name="Grid out current", + api_key="outPutCurrent", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_battery_voltage", + name="Battery voltage", + api_key="vBat", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_load_percentage", + name="Load percentage", + api_key="loadPercent", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + precision=2, + ), +) diff --git a/homeassistant/components/growatt_server/sensor_types/tlx.py b/homeassistant/components/growatt_server/sensor_types/tlx.py new file mode 100644 index 0000000000000..597ddd789cf94 --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/tlx.py @@ -0,0 +1,189 @@ +"""Growatt Sensor definitions for the TLX type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + POWER_WATT, + TEMP_CELSIUS, +) + +from .sensor_entity_description import GrowattSensorEntityDescription + +TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="tlx_energy_today", + name="Energy today", + api_key="eacToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total", + name="Lifetime energy output", + api_key="eacTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total_input_1", + name="Lifetime total energy input 1", + api_key="epv1Total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_today_input_1", + name="Energy Today Input 1", + api_key="epv1Today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_voltage_input_1", + name="Input 1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_amperage_input_1", + name="Input 1 Amperage", + api_key="ipv1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_wattage_input_1", + name="Input 1 Wattage", + api_key="ppv1", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total_input_2", + name="Lifetime total energy input 2", + api_key="epv2Total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_today_input_2", + name="Energy Today Input 2", + api_key="epv2Today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_voltage_input_2", + name="Input 2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_amperage_input_2", + name="Input 2 Amperage", + api_key="ipv2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_wattage_input_2", + name="Input 2 Wattage", + api_key="ppv2", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_internal_wattage", + name="Internal wattage", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_reactive_voltage", + name="Reactive voltage", + api_key="vacrs", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_frequency", + name="AC frequency", + api_key="fac", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_current_wattage", + name="Output power", + api_key="pac", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_1", + name="Temperature 1", + api_key="temp1", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_2", + name="Temperature 2", + api_key="temp2", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_3", + name="Temperature 3", + api_key="temp3", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_4", + name="Temperature 4", + api_key="temp4", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_5", + name="Temperature 5", + api_key="temp5", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + precision=1, + ), +) diff --git a/homeassistant/components/growatt_server/sensor_types/total.py b/homeassistant/components/growatt_server/sensor_types/total.py new file mode 100644 index 0000000000000..f3d48cf80272a --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/total.py @@ -0,0 +1,51 @@ +"""Growatt Sensor definitions for Totals.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT + +from .sensor_entity_description import GrowattSensorEntityDescription + +TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="total_money_today", + name="Total money today", + api_key="plantMoneyText", + currency=True, + ), + GrowattSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_key="totalMoneyText", + currency=True, + ), + GrowattSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_key="todayEnergy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + GrowattSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_key="invTodayPpv", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), + GrowattSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_key="totalEnergy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + GrowattSensorEntityDescription( + key="total_maximum_output", + name="Maximum power", + api_key="nominalPower", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + ), +) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 903ba400a6f99..45e25c0ba33e6 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -16,12 +16,13 @@ "user": { "data": { "name": "[%key:common::config_flow::data::name%]", - "password": "[%key:common::config_flow::data::name%]", - "username": "[%key:common::config_flow::data::username%]" + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "url": "[%key:common::config_flow::data::url%]" }, "title": "Enter your Growatt information" } } }, "title": "Growatt Server" -} +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/bg.json b/homeassistant/components/growatt_server/translations/bg.json new file mode 100644 index 0000000000000..46573dc14b461 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "name": "\u0418\u043c\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "URL", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/ca.json b/homeassistant/components/growatt_server/translations/ca.json new file mode 100644 index 0000000000000..39dc115343461 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/ca.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "No s'ha trobat cap planta en aquest compte" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "plant": { + "data": { + "plant_id": "Planta" + }, + "title": "Selecciona la teva planta" + }, + "user": { + "data": { + "name": "Nom", + "password": "Contrasenya", + "url": "URL", + "username": "Nom d'usuari" + }, + "title": "Introdueix la teva informaci\u00f3 de Growatt" + } + } + }, + "title": "Servidor Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/cs.json b/homeassistant/components/growatt_server/translations/cs.json new file mode 100644 index 0000000000000..02c83a6e9167b --- /dev/null +++ b/homeassistant/components/growatt_server/translations/cs.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/de.json b/homeassistant/components/growatt_server/translations/de.json new file mode 100644 index 0000000000000..adb769baa2de0 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Es wurden keine Pflanzen auf diesem Konto gefunden" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "plant": { + "data": { + "plant_id": "Pflanze" + }, + "title": "W\u00e4hle deine Pflanze aus" + }, + "user": { + "data": { + "name": "Name", + "password": "Passwort", + "url": "URL", + "username": "Benutzername" + }, + "title": "Gib deine Growatt-Informationen ein" + } + } + }, + "title": "Growatt Server" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/en.json b/homeassistant/components/growatt_server/translations/en.json index 365f577a007e4..86196783133f9 100644 --- a/homeassistant/components/growatt_server/translations/en.json +++ b/homeassistant/components/growatt_server/translations/en.json @@ -16,7 +16,8 @@ "user": { "data": { "name": "Name", - "password": "Name", + "password": "Password", + "url": "URL", "username": "Username" }, "title": "Enter your Growatt information" diff --git a/homeassistant/components/growatt_server/translations/es.json b/homeassistant/components/growatt_server/translations/es.json new file mode 100644 index 0000000000000..8fe4ae8b791a7 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/es.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "No se han encontrado plantas en esta cuenta." + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "step": { + "plant": { + "data": { + "plant_id": "Planta" + }, + "title": "Selecciona tu planta" + }, + "user": { + "data": { + "name": "Nombre", + "password": "Nombre", + "url": "URL", + "username": "Usuario" + }, + "title": "Introduce tu informaci\u00f3n de Growatt." + } + } + }, + "title": "Servidor Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/et.json b/homeassistant/components/growatt_server/translations/et.json new file mode 100644 index 0000000000000..c3327e3d67696 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Kontolt ei leitud \u00fchtegi taime" + }, + "error": { + "invalid_auth": "Autentimine nurjus" + }, + "step": { + "plant": { + "data": { + "plant_id": "Taim" + }, + "title": "Vali oma taim" + }, + "user": { + "data": { + "name": "Nimi", + "password": "Salas\u00f5na", + "url": "URL", + "username": "Kasutajanimi" + }, + "title": "Sisesta oma Growatti teave" + } + } + }, + "title": "Growatt Server" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/fr.json b/homeassistant/components/growatt_server/translations/fr.json new file mode 100644 index 0000000000000..939111c4151aa --- /dev/null +++ b/homeassistant/components/growatt_server/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Aucune plante n'a \u00e9t\u00e9 trouv\u00e9e sur ce compte" + }, + "error": { + "invalid_auth": "Authentification invalide" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plante" + }, + "title": "S\u00e9lectionner votre plante" + }, + "user": { + "data": { + "name": "Nom", + "password": "Mot de passe", + "url": "URL", + "username": "Nom d'utilisateur" + }, + "title": "Entrer vos informations Growatt" + } + } + }, + "title": "Serveur Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/he.json b/homeassistant/components/growatt_server/translations/he.json new file mode 100644 index 0000000000000..8d430b5f4b2b4 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "plant": { + "data": { + "plant_id": "\u05e6\u05de\u05d7" + } + }, + "user": { + "data": { + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/hu.json b/homeassistant/components/growatt_server/translations/hu.json new file mode 100644 index 0000000000000..5b2efc737feb9 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Ezen a sz\u00e1ml\u00e1n nem tal\u00e1ltak n\u00f6v\u00e9nyeket" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "plant": { + "data": { + "plant_id": "N\u00f6v\u00e9ny" + }, + "title": "V\u00e1lassza ki a n\u00f6v\u00e9ny\u00e9t" + }, + "user": { + "data": { + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Adja meg Growatt adatait" + } + } + }, + "title": "Growatt szerver" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/id.json b/homeassistant/components/growatt_server/translations/id.json new file mode 100644 index 0000000000000..dcc75c42fa09e --- /dev/null +++ b/homeassistant/components/growatt_server/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Tidak ada pembangkit yang ditemukan di akun ini" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "plant": { + "data": { + "plant_id": "Pembangkit" + }, + "title": "Pilih pembangkit Anda" + }, + "user": { + "data": { + "name": "Nama", + "password": "Kata Sandi", + "url": "URL", + "username": "Nama Pengguna" + }, + "title": "Masukkan informasi Growatt Anda" + } + } + }, + "title": "Server Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/it.json b/homeassistant/components/growatt_server/translations/it.json index 7676ecfff3ca3..a3160c4164bda 100644 --- a/homeassistant/components/growatt_server/translations/it.json +++ b/homeassistant/components/growatt_server/translations/it.json @@ -16,7 +16,8 @@ "user": { "data": { "name": "Nome", - "password": "Nome", + "password": "Password", + "url": "URL", "username": "Utente" }, "title": "Inserisci le tue informazioni Growatt" diff --git a/homeassistant/components/growatt_server/translations/ja.json b/homeassistant/components/growatt_server/translations/ja.json new file mode 100644 index 0000000000000..a32991ab9e0cb --- /dev/null +++ b/homeassistant/components/growatt_server/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "\u3053\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u690d\u7269(plants)\u306f\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "plant": { + "data": { + "plant_id": "\u30d7\u30e9\u30f3\u30c8" + }, + "title": "\u30d7\u30e9\u30f3\u30c8\u3092\u9078\u629e" + }, + "user": { + "data": { + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Growatt\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + }, + "title": "Growatt Server" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/nl.json b/homeassistant/components/growatt_server/translations/nl.json new file mode 100644 index 0000000000000..8d27f2b22af7f --- /dev/null +++ b/homeassistant/components/growatt_server/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Er zijn geen planten gevonden op dit account" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Kies uw plant" + }, + "user": { + "data": { + "name": "Naam", + "password": "Wachtwoord", + "url": "URL", + "username": "Gebruikersnaam" + }, + "title": "Vul uw Growatt gegevens in" + } + } + }, + "title": "Growatt Server" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/no.json b/homeassistant/components/growatt_server/translations/no.json new file mode 100644 index 0000000000000..8977a7e86a322 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Ingen planter er funnet p\u00e5 denne kontoen" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plante" + }, + "title": "Velg din plante" + }, + "user": { + "data": { + "name": "Navn", + "password": "Passord", + "url": "URL", + "username": "Brukernavn" + }, + "title": "Skriv inn Growatt-informasjonen din" + } + } + }, + "title": "Growatt Server" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/pl.json b/homeassistant/components/growatt_server/translations/pl.json new file mode 100644 index 0000000000000..01e37307d7ff7 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Nie znaleziono plantacji na tym koncie" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plantacja" + }, + "title": "Wybierz swoj\u0105 plantacj\u0119" + }, + "user": { + "data": { + "name": "Nazwa", + "password": "Has\u0142o", + "url": "URL", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Wprowad\u017a dane Growatt." + } + } + }, + "title": "Serwer Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/ru.json b/homeassistant/components/growatt_server/translations/ru.json new file mode 100644 index 0000000000000..c5eedf66ad3ec --- /dev/null +++ b/homeassistant/components/growatt_server/translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "\u0412 \u044d\u0442\u043e\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043d\u0438 \u043e\u0434\u043d\u043e\u0433\u043e \u0440\u0430\u0441\u0442\u0435\u043d\u0438\u044f." + }, + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "plant": { + "data": { + "plant_id": "\u0420\u0430\u0441\u0442\u0435\u043d\u0438\u0435" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0440\u0430\u0441\u0442\u0435\u043d\u0438\u0435" + }, + "user": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "url": "URL-\u0430\u0434\u0440\u0435\u0441", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Growatt." + } + } + }, + "title": "Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/tr.json b/homeassistant/components/growatt_server/translations/tr.json new file mode 100644 index 0000000000000..482ed6a427c8d --- /dev/null +++ b/homeassistant/components/growatt_server/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Bu hesapta bitki bulunamad\u0131" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "plant": { + "data": { + "plant_id": "Bitki" + }, + "title": "Tesisinizi se\u00e7in" + }, + "user": { + "data": { + "name": "Ad", + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Growatt bilgilerinizi girin" + } + } + }, + "title": "Growatt Sunucusu" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/zh-Hans.json b/homeassistant/components/growatt_server/translations/zh-Hans.json new file mode 100644 index 0000000000000..d217ccdc8429d --- /dev/null +++ b/homeassistant/components/growatt_server/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/zh-Hant.json b/homeassistant/components/growatt_server/translations/zh-Hant.json new file mode 100644 index 0000000000000..62991306cb862 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u690d\u7269" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "plant": { + "data": { + "plant_id": "\u690d\u7269" + }, + "title": "\u9078\u64c7\u690d\u7269" + }, + "user": { + "data": { + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "url": "\u7db2\u5740", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u8f38\u5165 Growatt \u8cc7\u8a0a" + } + } + }, + "title": "Growatt \u4f3a\u670d\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json index d987899463f79..4de42e3190afa 100644 --- a/homeassistant/components/gtfs/manifest.json +++ b/homeassistant/components/gtfs/manifest.json @@ -2,7 +2,7 @@ "domain": "gtfs", "name": "General Transit Feed Specification (GTFS)", "documentation": "https://www.home-assistant.io/integrations/gtfs", - "requirements": ["pygtfs==0.1.5"], + "requirements": ["pygtfs==0.1.6"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index d71a2fab67d15..f3e1d678d48f3 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -1,24 +1,23 @@ """Support for GTFS (Google/General Transport Format Schema).""" from __future__ import annotations +from collections.abc import Callable import datetime import logging import os import threading -from typing import Any, Callable +from typing import Any import pygtfs from sqlalchemy.sql import text import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_NAME, - CONF_OFFSET, - DEVICE_CLASS_TIMESTAMP, - STATE_UNKNOWN, +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, ) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET, STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -254,8 +253,8 @@ WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { # type: ignore +PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( + { vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, @@ -490,11 +489,10 @@ def setup_platform( origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - offset = config.get(CONF_OFFSET) + offset: datetime.timedelta = config[CONF_OFFSET] include_tomorrow = config[CONF_TOMORROW] - if not os.path.exists(gtfs_dir): - os.makedirs(gtfs_dir) + os.makedirs(gtfs_dir, exist_ok=True) if not os.path.exists(os.path.join(gtfs_dir, data)): _LOGGER.error("The given GTFS data file/folder was not found") @@ -518,6 +516,8 @@ def setup_platform( class GTFSDepartureSensor(SensorEntity): """Implementation of a GTFS departure sensor.""" + _attr_device_class = SensorDeviceClass.TIMESTAMP + def __init__( self, gtfs: Any, @@ -538,11 +538,11 @@ def __init__( self._available = False self._icon = ICON self._name = "" - self._state: str | None = None - self._attributes = {} + self._state: datetime.datetime | None = None + self._attributes: dict[str, Any] = {} self._agency = None - self._departure = {} + self._departure: dict[str, Any] = {} self._destination = None self._origin = None self._route = None @@ -557,7 +557,7 @@ def name(self) -> str: return self._name @property - def state(self) -> str | None: # type: ignore + def native_value(self) -> datetime.datetime | None: """Return the state of the sensor.""" return self._state @@ -576,11 +576,6 @@ def icon(self) -> str: """Icon to use in the frontend, if any.""" return self._icon - @property - def device_class(self) -> str: - """Return the class of this device.""" - return DEVICE_CLASS_TIMESTAMP - def update(self) -> None: """Get the latest data from GTFS and update the states.""" with self.lock: @@ -618,9 +613,7 @@ def update(self) -> None: if not self._departure: self._state = None else: - self._state = dt_util.as_utc( - self._departure["departure_time"] - ).isoformat() + self._state = self._departure["departure_time"] # Fetch trip and route details once, unless updated if not self._departure: diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 89e038b047e0b..55af1619da597 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,14 +2,33 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, cast from aioguardian import Client - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT -from homeassistant.core import HomeAssistant, callback +from aioguardian.errors import GuardianError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_FILENAME, + CONF_IP_ADDRESS, + CONF_PORT, + CONF_URL, + Platform, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,57 +44,115 @@ CONF_UID, DATA_CLIENT, DATA_COORDINATOR, - DATA_PAIRED_SENSOR_MANAGER, - DATA_UNSUB_DISPATCHER_CONNECT, + DATA_COORDINATOR_PAIRED_SENSOR, DOMAIN, LOGGER, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .util import GuardianDataUpdateCoordinator -DATA_LAST_SENSOR_PAIR_DUMP = "last_sensor_pair_dump" +DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" + +SERVICE_NAME_DISABLE_AP = "disable_ap" +SERVICE_NAME_ENABLE_AP = "enable_ap" +SERVICE_NAME_PAIR_SENSOR = "pair_sensor" +SERVICE_NAME_REBOOT = "reboot" +SERVICE_NAME_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" +SERVICE_NAME_UNPAIR_SENSOR = "unpair_sensor" +SERVICE_NAME_UPGRADE_FIRMWARE = "upgrade_firmware" + +SERVICES = ( + SERVICE_NAME_DISABLE_AP, + SERVICE_NAME_ENABLE_AP, + SERVICE_NAME_PAIR_SENSOR, + SERVICE_NAME_REBOOT, + SERVICE_NAME_RESET_VALVE_DIAGNOSTICS, + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_NAME_UPGRADE_FIRMWARE, +) -PLATFORMS = ["binary_sensor", "sensor", "switch"] +SERVICE_BASE_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), +) +SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(CONF_UID): cv.string, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), +) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the Elexa Guardian component.""" - hass.data[DOMAIN] = { - DATA_CLIENT: {}, - DATA_COORDINATOR: {}, - DATA_LAST_SENSOR_PAIR_DUMP: {}, - DATA_PAIRED_SENSOR_MANAGER: {}, - DATA_UNSUB_DISPATCHER_CONNECT: {}, - } - return True +SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_URL): cv.url, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_FILENAME): cv.string, + }, + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), +) + + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + + +@callback +def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str: + """Get the entry ID related to a service call (by device ID).""" + if ATTR_ENTITY_ID in call.data: + entity_registry = er.async_get(hass) + entity_registry_entry = entity_registry.async_get(call.data[ATTR_ENTITY_ID]) + if TYPE_CHECKING: + assert entity_registry_entry + assert entity_registry_entry.config_entry_id + return entity_registry_entry.config_entry_id + + device_id = call.data[CONF_DEVICE_ID] + device_registry = dr.async_get(hass) + + if device_entry := device_registry.async_get(device_id): + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.entry_id in device_entry.config_entries: + return entry.entry_id + + raise ValueError(f"No client for device ID: {device_id}") async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elexa Guardian from a config entry.""" - client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = Client( - entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT] - ) - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = { - API_SENSOR_PAIRED_SENSOR_STATUS: {} - } - hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id] = [] + client = Client(entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT]) # The valve controller's UDP-based API can't handle concurrent requests very well, # so we use a lock to ensure that only one API request is reaching it at a time: api_lock = asyncio.Lock() # Set up DataUpdateCoordinators for the valve controller: + coordinators: dict[str, GuardianDataUpdateCoordinator] = {} init_valve_controller_tasks = [] - for api, api_coro in [ + for api, api_coro in ( (API_SENSOR_PAIR_DUMP, client.sensor.pair_dump), (API_SYSTEM_DIAGNOSTICS, client.system.diagnostics), (API_SYSTEM_ONBOARD_SENSOR_STATUS, client.system.onboard_sensor_status), (API_VALVE_STATUS, client.valve.status), (API_WIFI_STATUS, client.wifi.status), - ]: - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - api - ] = GuardianDataUpdateCoordinator( + ): + coordinator = coordinators[api] = GuardianDataUpdateCoordinator( hass, client=client, api_name=api, @@ -89,25 +166,128 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up an object to evaluate each batch of paired sensor UIDs and add/remove # devices as appropriate: - paired_sensor_manager = hass.data[DOMAIN][DATA_PAIRED_SENSOR_MANAGER][ - entry.entry_id - ] = PairedSensorManager(hass, entry, client, api_lock) + paired_sensor_manager = PairedSensorManager(hass, entry, client, api_lock) await paired_sensor_manager.async_process_latest_paired_sensor_uids() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_COORDINATOR: coordinators, + DATA_COORDINATOR_PAIRED_SENSOR: {}, + DATA_PAIRED_SENSOR_MANAGER: paired_sensor_manager, + } + @callback - def async_process_paired_sensor_uids(): + def async_process_paired_sensor_uids() -> None: """Define a callback for when new paired sensor data is received.""" hass.async_create_task( paired_sensor_manager.async_process_latest_paired_sensor_uids() ) - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIR_DUMP - ].async_add_listener(async_process_paired_sensor_uids) + coordinators[API_SENSOR_PAIR_DUMP].async_add_listener( + async_process_paired_sensor_uids + ) # Set up all of the Guardian entity platforms: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + @callback + def extract_client(func: Callable) -> Callable: + """Define a decorator to get the correct client for a service call.""" + + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + entry_id = async_get_entry_id_for_service_call(hass, call) + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + + try: + async with client: + await func(call, client) + except GuardianError as err: + raise HomeAssistantError( + f"Error while executing {func.__name__}: {err}" + ) from err + + return wrapper + + @extract_client + async def async_disable_ap(call: ServiceCall, client: Client) -> None: + """Disable the onboard AP.""" + await client.wifi.disable_ap() + + @extract_client + async def async_enable_ap(call: ServiceCall, client: Client) -> None: + """Enable the onboard AP.""" + await client.wifi.enable_ap() + + @extract_client + async def async_pair_sensor(call: ServiceCall, client: Client) -> None: + """Add a new paired sensor.""" + entry_id = async_get_entry_id_for_service_call(hass, call) + paired_sensor_manager = hass.data[DOMAIN][entry_id][DATA_PAIRED_SENSOR_MANAGER] + uid = call.data[CONF_UID] + + await client.sensor.pair_sensor(uid) + await paired_sensor_manager.async_pair_sensor(uid) + + @extract_client + async def async_reboot(call: ServiceCall, client: Client) -> None: + """Reboot the valve controller.""" + await client.system.reboot() + + @extract_client + async def async_reset_valve_diagnostics(call: ServiceCall, client: Client) -> None: + """Fully reset system motor diagnostics.""" + await client.valve.reset() + + @extract_client + async def async_unpair_sensor(call: ServiceCall, client: Client) -> None: + """Remove a paired sensor.""" + entry_id = async_get_entry_id_for_service_call(hass, call) + paired_sensor_manager = hass.data[DOMAIN][entry_id][DATA_PAIRED_SENSOR_MANAGER] + uid = call.data[CONF_UID] + + await client.sensor.unpair_sensor(uid) + await paired_sensor_manager.async_unpair_sensor(uid) + + @extract_client + async def async_upgrade_firmware(call: ServiceCall, client: Client) -> None: + """Upgrade the device firmware.""" + await client.system.upgrade_firmware( + url=call.data[CONF_URL], + port=call.data[CONF_PORT], + filename=call.data[CONF_FILENAME], + ) + + for service_name, schema, method in ( + (SERVICE_NAME_DISABLE_AP, SERVICE_BASE_SCHEMA, async_disable_ap), + (SERVICE_NAME_ENABLE_AP, SERVICE_BASE_SCHEMA, async_enable_ap), + ( + SERVICE_NAME_PAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_pair_sensor, + ), + (SERVICE_NAME_REBOOT, SERVICE_BASE_SCHEMA, async_reboot), + ( + SERVICE_NAME_RESET_VALVE_DIAGNOSTICS, + SERVICE_BASE_SCHEMA, + async_reset_valve_diagnostics, + ), + ( + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_unpair_sensor, + ), + ( + SERVICE_NAME_UPGRADE_FIRMWARE, + SERVICE_UPGRADE_FIRMWARE_SCHEMA, + async_upgrade_firmware, + ), + ): + if hass.services.has_service(DOMAIN, service_name): + continue + hass.services.async_register(DOMAIN, service_name, method, schema=schema) + return True @@ -115,12 +295,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) - hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) - hass.data[DOMAIN][DATA_LAST_SENSOR_PAIR_DUMP].pop(entry.entry_id) - for unsub in hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id]: - unsub() - hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT].pop(entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) + + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # If this is the last loaded instance of Guardian, deregister any services + # defined during integration setup: + for service_name in SERVICES: + hass.services.async_remove(DOMAIN, service_name) return unload_ok @@ -140,8 +326,7 @@ def __init__( self._client = client self._entry = entry self._hass = hass - self._listeners = [] - self._paired_uids = set() + self._paired_uids: set[str] = set() async def async_pair_sensor(self, uid: str) -> None: """Add a new paired sensor coordinator.""" @@ -149,13 +334,15 @@ async def async_pair_sensor(self, uid: str) -> None: self._paired_uids.add(uid) - coordinator = self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + coordinator = self._hass.data[DOMAIN][self._entry.entry_id][ + DATA_COORDINATOR_PAIRED_SENSOR ][uid] = GuardianDataUpdateCoordinator( self._hass, client=self._client, api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}", - api_coro=lambda: self._client.sensor.paired_sensor_status(uid), + api_coro=lambda: cast( + Awaitable, self._client.sensor.paired_sensor_status(uid) + ), api_lock=self._api_lock, valve_controller_uid=self._entry.data[CONF_UID], ) @@ -171,7 +358,7 @@ async def async_process_latest_paired_sensor_uids(self) -> None: """Process a list of new UIDs.""" try: uids = set( - self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ + self._hass.data[DOMAIN][self._entry.entry_id][DATA_COORDINATOR][ API_SENSOR_PAIR_DUMP ].data["paired_uids"] ) @@ -198,8 +385,8 @@ async def async_unpair_sensor(self, uid: str) -> None: # Clear out objects related to this paired sensor: self._paired_uids.remove(uid) - self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + self._hass.data[DOMAIN][self._entry.entry_id][ + DATA_COORDINATOR_PAIRED_SENSOR ].pop(uid) # Remove the paired sensor device from the device registry (which will @@ -215,52 +402,22 @@ class GuardianEntity(CoordinatorEntity): """Define a base Guardian entity.""" def __init__( # pylint: disable=super-init-not-called - self, entry: ConfigEntry, kind: str, name: str, device_class: str, icon: str + self, entry: ConfigEntry, description: EntityDescription ) -> None: """Initialize.""" - self._attrs = {ATTR_ATTRIBUTION: "Data provided by Elexa"} - self._available = True + self._attr_device_info = DeviceInfo(manufacturer="Elexa") + self._attr_extra_state_attributes = {} self._entry = entry - self._device_class = device_class - self._device_info = {"manufacturer": "Elexa"} - self._icon = icon - self._kind = kind - self._name = name - - @property - def device_class(self) -> str: - """Return the device class.""" - return self._device_class - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return self._device_info - - @property - def extra_state_attributes(self) -> dict: - """Return the state attributes.""" - return self._attrs - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon + self.entity_description = description @callback - def _async_update_from_latest_data(self): + def _async_update_from_latest_data(self) -> None: """Update the entity. This should be extended by Guardian platforms. """ raise NotImplementedError - @callback - def _async_update_state_callback(self): - """Update the entity's state.""" - self._async_update_from_latest_data() - self.async_write_ha_state() - class PairedSensorEntity(GuardianEntity): """Define a Guardian paired sensor entity.""" @@ -269,32 +426,23 @@ def __init__( self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str, - icon: str, + description: EntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, kind, name, device_class, icon) + super().__init__(entry, description) + paired_sensor_uid = coordinator.data["uid"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, paired_sensor_uid)}, + name=f"Guardian Paired Sensor {paired_sensor_uid}", + via_device=(DOMAIN, entry.data[CONF_UID]), + ) + self._attr_name = ( + f"Guardian Paired Sensor {paired_sensor_uid}: {description.name}" + ) + self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" self.coordinator = coordinator - self._paired_sensor_uid = coordinator.data["uid"] - - self._device_info["identifiers"] = {(DOMAIN, self._paired_sensor_uid)} - self._device_info["name"] = f"Guardian Paired Sensor {self._paired_sensor_uid}" - self._device_info["via_device"] = (DOMAIN, self._entry.data[CONF_UID]) - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"Guardian Paired Sensor {self._paired_sensor_uid}: {self._name}" - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"{self._paired_sensor_uid}_{self._kind}" - async def async_added_to_hass(self) -> None: """Perform tasks when the entity is added.""" self._async_update_from_latest_data() @@ -307,40 +455,29 @@ def __init__( self, entry: ConfigEntry, coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str, - icon: str, + description: EntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, kind, name, device_class, icon) + super().__init__(entry, description) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.data[CONF_UID])}, + model=coordinators[API_SYSTEM_DIAGNOSTICS].data["firmware"], + name=f"Guardian Valve Controller {entry.data[CONF_UID]}", + ) + self._attr_name = f"Guardian {entry.data[CONF_UID]}: {description.name}" + self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" self.coordinators = coordinators - self._device_info["identifiers"] = {(DOMAIN, self._entry.data[CONF_UID])} - self._device_info[ - "name" - ] = f"Guardian Valve Controller {self._entry.data[CONF_UID]}" - self._device_info["model"] = self.coordinators[API_SYSTEM_DIAGNOSTICS].data[ - "firmware" - ] - @property - def availabile(self) -> bool: + def available(self) -> bool: """Return if entity is available.""" - return any(coordinator.last_update_success for coordinator in self.coordinators) - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"Guardian {self._entry.data[CONF_UID]}: {self._name}" - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"{self._entry.data[CONF_UID]}_{self._kind}" + return any( + coordinator.last_update_success + for coordinator in self.coordinators.values() + ) - async def _async_continue_entity_setup(self): + async def _async_continue_entity_setup(self) -> None: """Perform additional, internal tasks when the entity is about to be added. This should be extended by Guardian platforms. @@ -350,9 +487,14 @@ async def _async_continue_entity_setup(self): @callback def async_add_coordinator_update_listener(self, api: str) -> None: """Add a listener to a DataUpdateCoordinator based on the API referenced.""" - self.async_on_remove( - self.coordinators[api].async_add_listener(self._async_update_state_callback) - ) + + @callback + def update() -> None: + """Update the entity's state.""" + self._async_update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(self.coordinators[api].async_add_listener(update)) async def async_added_to_hass(self) -> None: """Perform tasks when the entity is added.""" @@ -365,12 +507,12 @@ async def async_update(self) -> None: Only used by the generic entity update service. """ - # Ignore manual update requests if the entity is disabled if not self.enabled: return refresh_tasks = [ - coordinator.async_request_refresh() for coordinator in self.coordinators + coordinator.async_request_refresh() + for coordinator in self.coordinators.values() ] await asyncio.gather(*refresh_tasks) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 869acc094d565..1d7195a8f1758 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -2,25 +2,24 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOVING, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( - API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_WIFI_STATUS, CONF_UID, DATA_COORDINATOR, - DATA_UNSUB_DISPATCHER_CONNECT, + DATA_COORDINATOR_PAIRED_SENSOR, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) @@ -31,14 +30,32 @@ SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -SENSOR_ATTRS_MAP = { - SENSOR_KIND_AP_INFO: ("Onboard AP Enabled", DEVICE_CLASS_CONNECTIVITY), - SENSOR_KIND_LEAK_DETECTED: ("Leak Detected", DEVICE_CLASS_MOISTURE), - SENSOR_KIND_MOVED: ("Recently Moved", DEVICE_CLASS_MOVING), -} +SENSOR_DESCRIPTION_AP_ENABLED = BinarySensorEntityDescription( + key=SENSOR_KIND_AP_INFO, + name="Onboard AP Enabled", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, +) +SENSOR_DESCRIPTION_LEAK_DETECTED = BinarySensorEntityDescription( + key=SENSOR_KIND_LEAK_DETECTED, + name="Leak Detected", + device_class=BinarySensorDeviceClass.MOISTURE, +) +SENSOR_DESCRIPTION_MOVED = BinarySensorEntityDescription( + key=SENSOR_KIND_MOVED, + name="Recently Moved", + device_class=BinarySensorDeviceClass.MOVING, + entity_category=EntityCategory.DIAGNOSTIC, +) -PAIRED_SENSOR_SENSORS = [SENSOR_KIND_LEAK_DETECTED, SENSOR_KIND_MOVED] -VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_AP_INFO, SENSOR_KIND_LEAK_DETECTED] +PAIRED_SENSOR_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_LEAK_DETECTED, + SENSOR_DESCRIPTION_MOVED, +) +VALVE_CONTROLLER_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_AP_ENABLED, + SENSOR_DESCRIPTION_LEAK_DETECTED, +) async def async_setup_entry( @@ -49,28 +66,19 @@ async def async_setup_entry( @callback def add_new_paired_sensor(uid: str) -> None: """Add a new paired sensor.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ][uid] - - entities = [] - for kind in PAIRED_SENSOR_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - entities.append( - PairedSensorBinarySensor( - entry, - coordinator, - kind, - name, - device_class, - None, - ) - ) - - async_add_entities(entities) + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR_PAIRED_SENSOR][ + uid + ] + + async_add_entities( + [ + PairedSensorBinarySensor(entry, coordinator, description) + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) # Handle adding paired sensors after HASS startup: - hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append( + entry.async_on_unload( async_dispatcher_connect( hass, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED.format(entry.data[CONF_UID]), @@ -78,38 +86,24 @@ def add_new_paired_sensor(uid: str) -> None: ) ) - sensors = [] - # Add all valve controller-specific binary sensors: - for kind in VALVE_CONTROLLER_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - sensors.append( - ValveControllerBinarySensor( - entry, - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], - kind, - name, - device_class, - None, - ) + sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [ + ValveControllerBinarySensor( + entry, hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], description ) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ] # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ].values(): - for kind in PAIRED_SENSOR_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - sensors.append( - PairedSensorBinarySensor( - entry, - coordinator, - kind, - name, - device_class, - None, - ) - ) + sensors.extend( + [ + PairedSensorBinarySensor(entry, coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR_PAIRED_SENSOR + ].values() + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -121,33 +115,20 @@ def __init__( self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: BinarySensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, coordinator, kind, name, device_class, icon) - - self._is_on = True - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self.coordinator.last_update_success + super().__init__(entry, coordinator, description) - @property - def is_on(self) -> bool: - """Return True if the binary sensor is on.""" - return self._is_on + self._attr_is_on = True @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_LEAK_DETECTED: - self._is_on = self.coordinator.data["wet"] - elif self._kind == SENSOR_KIND_MOVED: - self._is_on = self.coordinator.data["moved"] + if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: + self._attr_is_on = self.coordinator.data["wet"] + elif self.entity_description.key == SENSOR_KIND_MOVED: + self._attr_is_on = self.coordinator.data["moved"] class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): @@ -157,52 +138,41 @@ def __init__( self, entry: ConfigEntry, coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: BinarySensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, coordinators, kind, name, device_class, icon) + super().__init__(entry, coordinators, description) - self._is_on = True - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - if self._kind == SENSOR_KIND_AP_INFO: - return self.coordinators[API_WIFI_STATUS].last_update_success - if self._kind == SENSOR_KIND_LEAK_DETECTED: - return self.coordinators[ - API_SYSTEM_ONBOARD_SENSOR_STATUS - ].last_update_success - return False - - @property - def is_on(self) -> bool: - """Return True if the binary sensor is on.""" - return self._is_on + self._attr_is_on = True async def _async_continue_entity_setup(self) -> None: """Add an API listener.""" - if self._kind == SENSOR_KIND_AP_INFO: + if self.entity_description.key == SENSOR_KIND_AP_INFO: self.async_add_coordinator_update_listener(API_WIFI_STATUS) - elif self._kind == SENSOR_KIND_LEAK_DETECTED: + elif self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_AP_INFO: - self._is_on = self.coordinators[API_WIFI_STATUS].data["station_connected"] - self._attrs.update( + if self.entity_description.key == SENSOR_KIND_AP_INFO: + self._attr_available = self.coordinators[ + API_WIFI_STATUS + ].last_update_success + self._attr_is_on = self.coordinators[API_WIFI_STATUS].data[ + "station_connected" + ] + self._attr_extra_state_attributes.update( { ATTR_CONNECTED_CLIENTS: self.coordinators[API_WIFI_STATUS].data.get( "ap_clients" ) } ) - elif self._kind == SENSOR_KIND_LEAK_DETECTED: - self._is_on = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ + elif self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: + self._attr_available = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].last_update_success + self._attr_is_on = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ "wet" ] diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index 05be79da344e0..ea4589ddd423d 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -1,12 +1,17 @@ """Config flow for Elexa Guardian integration.""" +from __future__ import annotations + +from typing import Any + from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant import config_entries, core -from homeassistant.components.dhcp import IP_ADDRESS +from homeassistant import config_entries +from homeassistant.components import dhcp, zeroconf from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from .const import CONF_UID, DOMAIN, LOGGER @@ -23,18 +28,18 @@ @callback -def async_get_pin_from_discovery_hostname(hostname): +def async_get_pin_from_discovery_hostname(hostname: str) -> str: """Get the device's 4-digit PIN from its zeroconf-discovered hostname.""" return hostname.split(".")[0].split("-")[1] @callback -def async_get_pin_from_uid(uid): +def async_get_pin_from_uid(uid: str) -> str: """Get the device's 4-digit PIN from its UID.""" return uid[-4:] -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -52,11 +57,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self.discovery_info = {} + self.discovery_info: dict[str, Any] = {} - async def _async_set_unique_id(self, pin): + async def _async_set_unique_id(self, pin: str) -> None: """Set the config entry's unique ID (based on the device's 4-digit PIN).""" await self.async_set_unique_id(UNIQUE_ID.format(pin)) if self.discovery_info: @@ -66,7 +71,9 @@ async def _async_set_unique_id(self, pin): else: self._abort_if_unique_id_configured() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle configuration via the UI.""" if user_input is None: return self.async_show_form( @@ -90,25 +97,27 @@ async def async_step_user(self, user_input=None): title=info[CONF_UID], data={CONF_UID: info["uid"], **user_input} ) - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle the configuration via dhcp.""" self.discovery_info = { - CONF_IP_ADDRESS: discovery_info[IP_ADDRESS], + CONF_IP_ADDRESS: discovery_info.ip, CONF_PORT: DEFAULT_PORT, } return await self._async_handle_discovery() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle the configuration via zeroconf.""" self.discovery_info = { - CONF_IP_ADDRESS: discovery_info["host"], - CONF_PORT: discovery_info["port"], + CONF_IP_ADDRESS: discovery_info.host, + CONF_PORT: discovery_info.port, } - pin = async_get_pin_from_discovery_hostname(discovery_info["hostname"]) + pin = async_get_pin_from_discovery_hostname(discovery_info.hostname) await self._async_set_unique_id(pin) return await self._async_handle_discovery() - async def _async_handle_discovery(self): + async def _async_handle_discovery(self) -> FlowResult: """Handle any discovery.""" self.context[CONF_IP_ADDRESS] = self.discovery_info[CONF_IP_ADDRESS] if any( @@ -119,7 +128,9 @@ async def _async_handle_discovery(self): return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Finish the configuration via any discovery.""" if user_input is None: self._set_confirm_only() diff --git a/homeassistant/components/guardian/const.py b/homeassistant/components/guardian/const.py index 750a8c407cab9..3499db24c037b 100644 --- a/homeassistant/components/guardian/const.py +++ b/homeassistant/components/guardian/const.py @@ -16,7 +16,6 @@ DATA_CLIENT = "client" DATA_COORDINATOR = "coordinator" -DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" -DATA_UNSUB_DISPATCHER_CONNECT = "unsub_dispatcher_connect" +DATA_COORDINATOR_PAIRED_SENSOR = "coordinator_paired_sensor" SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED = "guardian_paired_sensor_coordinator_added_{0}" diff --git a/homeassistant/components/guardian/manifest.json b/homeassistant/components/guardian/manifest.json index 60411c5292b0f..90e33a82452a8 100644 --- a/homeassistant/components/guardian/manifest.json +++ b/homeassistant/components/guardian/manifest.json @@ -3,7 +3,7 @@ "name": "Elexa Guardian", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/guardian", - "requirements": ["aioguardian==1.0.4"], + "requirements": ["aioguardian==2021.11.0"], "zeroconf": ["_api._udp.local."], "codeowners": ["@bachya"], "iot_class": "local_polling", @@ -12,6 +12,10 @@ "hostname": "gvc*", "macaddress": "30AEA4*" }, + { + "hostname": "gvc*", + "macaddress": "B4E62D*" + }, { "hostname": "guardian*", "macaddress": "30AEA4*" diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 2d62fe2c6135b..895452e0fda2e 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,28 +1,26 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_FAHRENHEIT, - TIME_MINUTES, +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, TEMP_FAHRENHEIT, TIME_MINUTES from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( - API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, CONF_UID, DATA_COORDINATOR, - DATA_UNSUB_DISPATCHER_CONNECT, + DATA_COORDINATOR_PAIRED_SENSOR, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) @@ -31,19 +29,36 @@ SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -SENSOR_ATTRS_MAP = { - SENSOR_KIND_BATTERY: ("Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), - SENSOR_KIND_TEMPERATURE: ( - "Temperature", - DEVICE_CLASS_TEMPERATURE, - None, - TEMP_FAHRENHEIT, - ), - SENSOR_KIND_UPTIME: ("Uptime", None, "mdi:timer", TIME_MINUTES), -} +SENSOR_DESCRIPTION_BATTERY = SensorEntityDescription( + key=SENSOR_KIND_BATTERY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, +) +SENSOR_DESCRIPTION_TEMPERATURE = SensorEntityDescription( + key=SENSOR_KIND_TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, +) +SENSOR_DESCRIPTION_UPTIME = SensorEntityDescription( + key=SENSOR_KIND_UPTIME, + name="Uptime", + icon="mdi:timer", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=TIME_MINUTES, +) -PAIRED_SENSOR_SENSORS = [SENSOR_KIND_BATTERY, SENSOR_KIND_TEMPERATURE] -VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_TEMPERATURE, SENSOR_KIND_UPTIME] +PAIRED_SENSOR_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_BATTERY, + SENSOR_DESCRIPTION_TEMPERATURE, +) +VALVE_CONTROLLER_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_TEMPERATURE, + SENSOR_DESCRIPTION_UPTIME, +) async def async_setup_entry( @@ -54,23 +69,19 @@ async def async_setup_entry( @callback def add_new_paired_sensor(uid: str) -> None: """Add a new paired sensor.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ][uid] - - entities = [] - for kind in PAIRED_SENSOR_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - entities.append( - PairedSensorSensor( - entry, coordinator, kind, name, device_class, icon, unit - ) - ) - - async_add_entities(entities, True) + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR_PAIRED_SENSOR][ + uid + ] + + async_add_entities( + [ + PairedSensorSensor(entry, coordinator, description) + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) # Handle adding paired sensors after HASS startup: - hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append( + entry.async_on_unload( async_dispatcher_connect( hass, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED.format(entry.data[CONF_UID]), @@ -78,34 +89,24 @@ def add_new_paired_sensor(uid: str) -> None: ) ) - sensors = [] - # Add all valve controller-specific binary sensors: - for kind in VALVE_CONTROLLER_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - sensors.append( - ValveControllerSensor( - entry, - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], - kind, - name, - device_class, - icon, - unit, - ) + sensors: list[PairedSensorSensor | ValveControllerSensor] = [ + ValveControllerSensor( + entry, hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], description ) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ] # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ].values(): - for kind in PAIRED_SENSOR_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - sensors.append( - PairedSensorSensor( - entry, coordinator, kind, name, device_class, icon, unit - ) - ) + sensors.extend( + [ + PairedSensorSensor(entry, coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR_PAIRED_SENSOR + ].values() + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -113,97 +114,37 @@ def add_new_paired_sensor(uid: str) -> None: class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str | None, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinator, kind, name, device_class, icon) - - self._state = None - self._unit = unit - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self.coordinator.last_update_success - - @property - def state(self) -> str: - """Return the sensor state.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._unit - @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_BATTERY: - self._state = self.coordinator.data["battery"] - elif self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinator.data["temperature"] + if self.entity_description.key == SENSOR_KIND_BATTERY: + self._attr_native_value = self.coordinator.data["battery"] + elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: + self._attr_native_value = self.coordinator.data["temperature"] class ValveControllerSensor(ValveControllerEntity, SensorEntity): """Define a generic Guardian sensor.""" - def __init__( - self, - entry: ConfigEntry, - coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str | None, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinators, kind, name, device_class, icon) - - self._state = None - self._unit = unit - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - if self._kind == SENSOR_KIND_TEMPERATURE: - return self.coordinators[ - API_SYSTEM_ONBOARD_SENSOR_STATUS - ].last_update_success - if self._kind == SENSOR_KIND_UPTIME: - return self.coordinators[API_SYSTEM_DIAGNOSTICS].last_update_success - return False - - @property - def state(self) -> str: - """Return the sensor state.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._unit - async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" - if self._kind == SENSOR_KIND_TEMPERATURE: + if self.entity_description.key == SENSOR_KIND_TEMPERATURE: self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ - "temperature" + if self.entity_description.key == SENSOR_KIND_TEMPERATURE: + self._attr_available = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].last_update_success + self._attr_native_value = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].data["temperature"] + elif self.entity_description.key == SENSOR_KIND_UPTIME: + self._attr_available = self.coordinators[ + API_SYSTEM_DIAGNOSTICS + ].last_update_success + self._attr_native_value = self.coordinators[API_SYSTEM_DIAGNOSTICS].data[ + "uptime" ] - elif self._kind == SENSOR_KIND_UPTIME: - self._state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] diff --git a/homeassistant/components/guardian/services.yaml b/homeassistant/components/guardian/services.yaml index dc78503eb1229..4d48783c955c5 100644 --- a/homeassistant/components/guardian/services.yaml +++ b/homeassistant/components/guardian/services.yaml @@ -1,58 +1,112 @@ # Describes the format for available Elexa Guardians services disable_ap: + name: Disable AP description: Disable the device's onboard access point. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller whose AP should be disabled + required: true + selector: + device: + integration: guardian enable_ap: + name: Enable AP description: Enable the device's onboard access point. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller whose AP should be enabled + required: true + selector: + device: + integration: guardian pair_sensor: + name: Pair Sensor description: Add a new paired sensor to the valve controller. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller to add the sensor to + required: true + selector: + device: + integration: guardian uid: + name: UID description: The UID of the paired sensor + required: true example: 5410EC688BCF + selector: + text: reboot: + name: Reboot description: Reboot the device. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller to reboot + required: true + selector: + device: + integration: guardian reset_valve_diagnostics: + name: Reset Valve Diagnostics description: Fully (and irrecoverably) reset all valve diagnostics. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller whose diagnostics should be reset + required: true + selector: + device: + integration: guardian unpair_sensor: + name: Unpair Sensor description: Remove a paired sensor from the valve controller. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller to remove the sensor from + required: true + selector: + device: + integration: guardian uid: + name: UID description: The UID of the paired sensor + required: true example: 5410EC688BCF + selector: + text: upgrade_firmware: + name: Upgrade firmware description: Upgrade the device firmware. fields: - entity_id: - description: The Guardian valve controller to affect. - example: switch.guardian_abcde_valve + device_id: + name: Valve Controller + description: The valve controller whose firmware should be upgraded + required: true + selector: + device: + integration: guardian url: - description: (optional) The URL of the server hosting the firmware file. + name: URL + description: The URL of the server hosting the firmware file. example: https://repo.guardiancloud.services/gvc/fw + selector: + text: port: - description: (optional) The port on which the firmware file is served. + name: Port + description: The port on which the firmware file is served. example: 443 + selector: + number: + min: 1 + max: 65535 filename: - description: (optional) The firmware filename. + name: Filename + description: The firmware filename. example: latest.bin + selector: + text: diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index c8ec4c6c64574..9a4f70fd3d23d 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,78 +1,45 @@ """Switches for the Elexa Guardian integration.""" from __future__ import annotations +from typing import Any + from aioguardian import Client from aioguardian.errors import GuardianError -import voluptuous as vol -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILENAME, CONF_PORT, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import ValveControllerEntity -from .const import ( - API_VALVE_STATUS, - CONF_UID, - DATA_CLIENT, - DATA_COORDINATOR, - DATA_PAIRED_SENSOR_MANAGER, - DOMAIN, - LOGGER, -) +from .const import API_VALVE_STATUS, DATA_CLIENT, DATA_COORDINATOR, DOMAIN ATTR_AVG_CURRENT = "average_current" ATTR_INST_CURRENT = "instantaneous_current" ATTR_INST_CURRENT_DDT = "instantaneous_current_ddt" ATTR_TRAVEL_COUNT = "travel_count" -SERVICE_DISABLE_AP = "disable_ap" -SERVICE_ENABLE_AP = "enable_ap" -SERVICE_PAIR_SENSOR = "pair_sensor" -SERVICE_REBOOT = "reboot" -SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" -SERVICE_UNPAIR_SENSOR = "unpair_sensor" -SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware" +SWITCH_KIND_VALVE = "valve" + +SWITCH_DESCRIPTION_VALVE = SwitchEntityDescription( + key=SWITCH_KIND_VALVE, + name="Valve Controller", + icon="mdi:water", +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Guardian switches based on a config entry.""" - platform = entity_platform.async_get_current_platform() - - for service_name, schema, method in [ - (SERVICE_DISABLE_AP, {}, "async_disable_ap"), - (SERVICE_ENABLE_AP, {}, "async_enable_ap"), - (SERVICE_PAIR_SENSOR, {vol.Required(CONF_UID): cv.string}, "async_pair_sensor"), - (SERVICE_REBOOT, {}, "async_reboot"), - (SERVICE_RESET_VALVE_DIAGNOSTICS, {}, "async_reset_valve_diagnostics"), - ( - SERVICE_UPGRADE_FIRMWARE, - { - vol.Optional(CONF_URL): cv.url, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_FILENAME): cv.string, - }, - "async_upgrade_firmware", - ), - ( - SERVICE_UNPAIR_SENSOR, - {vol.Required(CONF_UID): cv.string}, - "async_unpair_sensor", - ), - ]: - platform.async_register_entity_service(service_name, schema, method) - async_add_entities( [ ValveControllerSwitch( entry, - hass.data[DOMAIN][DATA_CLIENT][entry.entry_id], - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], + hass.data[DOMAIN][entry.entry_id][DATA_CLIENT], + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], ) ] ) @@ -86,40 +53,29 @@ def __init__( entry: ConfigEntry, client: Client, coordinators: dict[str, DataUpdateCoordinator], - ): + ) -> None: """Initialize.""" - super().__init__( - entry, coordinators, "valve", "Valve Controller", None, "mdi:water" - ) + super().__init__(entry, coordinators, SWITCH_DESCRIPTION_VALVE) + self._attr_is_on = True self._client = client - self._is_on = True - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self.coordinators[API_VALVE_STATUS].last_update_success - @property - def is_on(self) -> bool: - """Return True if the valve is open.""" - return self._is_on - - async def _async_continue_entity_setup(self): + async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" self.async_add_coordinator_update_listener(API_VALVE_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - self._is_on = self.coordinators[API_VALVE_STATUS].data["state"] in ( + self._attr_available = self.coordinators[API_VALVE_STATUS].last_update_success + self._attr_is_on = self.coordinators[API_VALVE_STATUS].data["state"] in ( "start_opening", "opening", "finish_opening", "opened", ) - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_AVG_CURRENT: self.coordinators[API_VALVE_STATUS].data[ "average_current" @@ -136,96 +92,24 @@ def _async_update_from_latest_data(self) -> None: } ) - async def async_disable_ap(self): - """Disable the device's onboard access point.""" - try: - async with self._client: - await self._client.wifi.disable_ap() - except GuardianError as err: - LOGGER.error("Error while disabling valve controller AP: %s", err) - - async def async_enable_ap(self): - """Enable the device's onboard access point.""" - try: - async with self._client: - await self._client.wifi.enable_ap() - except GuardianError as err: - LOGGER.error("Error while enabling valve controller AP: %s", err) - - async def async_pair_sensor(self, *, uid): - """Add a new paired sensor.""" - try: - async with self._client: - await self._client.sensor.pair_sensor(uid) - except GuardianError as err: - LOGGER.error("Error while adding paired sensor: %s", err) - return - - await self.hass.data[DOMAIN][DATA_PAIRED_SENSOR_MANAGER][ - self._entry.entry_id - ].async_pair_sensor(uid) - - async def async_reboot(self): - """Reboot the device.""" - try: - async with self._client: - await self._client.system.reboot() - except GuardianError as err: - LOGGER.error("Error while rebooting valve controller: %s", err) - - async def async_reset_valve_diagnostics(self): - """Fully reset system motor diagnostics.""" - try: - async with self._client: - await self._client.valve.reset() - except GuardianError as err: - LOGGER.error("Error while resetting valve diagnostics: %s", err) - - async def async_unpair_sensor(self, *, uid): - """Add a new paired sensor.""" - try: - async with self._client: - await self._client.sensor.unpair_sensor(uid) - except GuardianError as err: - LOGGER.error("Error while removing paired sensor: %s", err) - return - - await self.hass.data[DOMAIN][DATA_PAIRED_SENSOR_MANAGER][ - self._entry.entry_id - ].async_unpair_sensor(uid) - - async def async_upgrade_firmware(self, *, url, port, filename): - """Upgrade the device firmware.""" - try: - async with self._client: - await self._client.system.upgrade_firmware( - url=url, - port=port, - filename=filename, - ) - except GuardianError as err: - LOGGER.error("Error while upgrading firmware: %s", err) - - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the valve off (closed).""" try: async with self._client: await self._client.valve.close() except GuardianError as err: - LOGGER.error("Error while closing the valve: %s", err) - return + raise HomeAssistantError(f"Error while closing the valve: {err}") from err - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the valve on (open).""" try: async with self._client: await self._client.valve.open() except GuardianError as err: - LOGGER.error("Error while opening the valve: %s", err) - return + raise HomeAssistantError(f"Error while opening the valve: {err}") from err - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() diff --git a/homeassistant/components/guardian/translations/bg.json b/homeassistant/components/guardian/translations/bg.json new file mode 100644 index 0000000000000..de9699e4a210b --- /dev/null +++ b/homeassistant/components/guardian/translations/bg.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/ca.json b/homeassistant/components/guardian/translations/ca.json index 476bf49ee5aaf..0831975511e5d 100644 --- a/homeassistant/components/guardian/translations/ca.json +++ b/homeassistant/components/guardian/translations/ca.json @@ -6,6 +6,9 @@ "cannot_connect": "Ha fallat la connexi\u00f3" }, "step": { + "discovery_confirm": { + "description": "Vols configurar aquest dispositiu Guardian?" + }, "user": { "data": { "ip_address": "Adre\u00e7a IP", diff --git a/homeassistant/components/guardian/translations/de.json b/homeassistant/components/guardian/translations/de.json index 432afe8df27ee..63949b22de6ec 100644 --- a/homeassistant/components/guardian/translations/de.json +++ b/homeassistant/components/guardian/translations/de.json @@ -6,6 +6,9 @@ "cannot_connect": "Verbindung fehlgeschlagen" }, "step": { + "discovery_confirm": { + "description": "M\u00f6chtest du dieses Guardian-Ger\u00e4t einrichten?" + }, "user": { "data": { "ip_address": "IP-Adresse", diff --git a/homeassistant/components/guardian/translations/en.json b/homeassistant/components/guardian/translations/en.json index 310f550bcc106..52932cce02bb8 100644 --- a/homeassistant/components/guardian/translations/en.json +++ b/homeassistant/components/guardian/translations/en.json @@ -15,6 +15,9 @@ "port": "Port" }, "description": "Configure a local Elexa Guardian device." + }, + "zeroconf_confirm": { + "description": "Do you want to set up this Guardian device?" } } } diff --git a/homeassistant/components/guardian/translations/es.json b/homeassistant/components/guardian/translations/es.json index 9e6cfdaa7c792..981534cca9b2b 100644 --- a/homeassistant/components/guardian/translations/es.json +++ b/homeassistant/components/guardian/translations/es.json @@ -6,6 +6,9 @@ "cannot_connect": "No se pudo conectar" }, "step": { + "discovery_confirm": { + "description": "\u00bfQuieres configurar este dispositivo Guardian?" + }, "user": { "data": { "ip_address": "Direcci\u00f3n IP", diff --git a/homeassistant/components/guardian/translations/et.json b/homeassistant/components/guardian/translations/et.json index 42c425ec85f25..56aec0e00c71c 100644 --- a/homeassistant/components/guardian/translations/et.json +++ b/homeassistant/components/guardian/translations/et.json @@ -6,6 +6,9 @@ "cannot_connect": "\u00dchendamine nurjus" }, "step": { + "discovery_confirm": { + "description": "Kas soovid seadistada seda Guardian'i seadet?" + }, "user": { "data": { "ip_address": "IP aadress", diff --git a/homeassistant/components/guardian/translations/fr.json b/homeassistant/components/guardian/translations/fr.json index ca5635a17b7c1..e1e1bcb4fcf42 100644 --- a/homeassistant/components/guardian/translations/fr.json +++ b/homeassistant/components/guardian/translations/fr.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "Ce p\u00e9riph\u00e9rique Guardian a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9.", - "already_in_progress": "La configuration de l'appareil Guardian est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "step": { + "discovery_confirm": { + "description": "Voulez-vous configurer cet appareil Guardian\u00a0?" + }, "user": { "data": { "ip_address": "Adresse IP", diff --git a/homeassistant/components/guardian/translations/he.json b/homeassistant/components/guardian/translations/he.json new file mode 100644 index 0000000000000..8d77b3cd257a9 --- /dev/null +++ b/homeassistant/components/guardian/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/hu.json b/homeassistant/components/guardian/translations/hu.json index bd43ce7672c38..04b62bb660ecb 100644 --- a/homeassistant/components/guardian/translations/hu.json +++ b/homeassistant/components/guardian/translations/hu.json @@ -2,15 +2,22 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" + }, "user": { "data": { "ip_address": "IP c\u00edm", "port": "Port" - } + }, + "description": "Konfigur\u00e1lja a helyi Elexa Guardian eszk\u00f6zt." + }, + "zeroconf_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" } } } diff --git a/homeassistant/components/guardian/translations/id.json b/homeassistant/components/guardian/translations/id.json index b5b753210378d..8193386fb62e1 100644 --- a/homeassistant/components/guardian/translations/id.json +++ b/homeassistant/components/guardian/translations/id.json @@ -6,6 +6,9 @@ "cannot_connect": "Gagal terhubung" }, "step": { + "discovery_confirm": { + "description": "Ingin menyiapkan perangkat Guardian ini?" + }, "user": { "data": { "ip_address": "Alamat IP", diff --git a/homeassistant/components/guardian/translations/it.json b/homeassistant/components/guardian/translations/it.json index 5cd20f78a3f29..5db0956d80faa 100644 --- a/homeassistant/components/guardian/translations/it.json +++ b/homeassistant/components/guardian/translations/it.json @@ -6,12 +6,15 @@ "cannot_connect": "Impossibile connettersi" }, "step": { + "discovery_confirm": { + "description": "Vuoi configurare questo dispositivo Guardian?" + }, "user": { "data": { "ip_address": "Indirizzo IP", "port": "Porta" }, - "description": "Configurare un dispositivo Elexa Guardian locale." + "description": "Configura un dispositivo Elexa Guardian locale." }, "zeroconf_confirm": { "description": "Vuoi configurare questo dispositivo Guardian?" diff --git a/homeassistant/components/guardian/translations/ja.json b/homeassistant/components/guardian/translations/ja.json new file mode 100644 index 0000000000000..0c282a324bd87 --- /dev/null +++ b/homeassistant/components/guardian/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "discovery_confirm": { + "description": "\u3053\u306eGuardian\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u30ed\u30fc\u30ab\u30eb\u306eElexa Guardian\u30c7\u30d0\u30a4\u30b9\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002" + }, + "zeroconf_confirm": { + "description": "\u3053\u306eGuardian\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/nl.json b/homeassistant/components/guardian/translations/nl.json index a33cb9357a9e6..409c3db9bedfa 100644 --- a/homeassistant/components/guardian/translations/nl.json +++ b/homeassistant/components/guardian/translations/nl.json @@ -6,6 +6,9 @@ "cannot_connect": "Kan geen verbinding maken" }, "step": { + "discovery_confirm": { + "description": "Wil je dit Guardian apparaat instellen?" + }, "user": { "data": { "ip_address": "IP-adres", diff --git a/homeassistant/components/guardian/translations/no.json b/homeassistant/components/guardian/translations/no.json index 18410f382ca88..28313fa85201b 100644 --- a/homeassistant/components/guardian/translations/no.json +++ b/homeassistant/components/guardian/translations/no.json @@ -6,6 +6,9 @@ "cannot_connect": "Tilkobling mislyktes" }, "step": { + "discovery_confirm": { + "description": "Vil du konfigurere denne Guardian-enheten?" + }, "user": { "data": { "ip_address": "IP adresse", diff --git a/homeassistant/components/guardian/translations/pl.json b/homeassistant/components/guardian/translations/pl.json index 7dfa4b06c5bd8..663265a7a115d 100644 --- a/homeassistant/components/guardian/translations/pl.json +++ b/homeassistant/components/guardian/translations/pl.json @@ -6,6 +6,9 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, "step": { + "discovery_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 to urz\u0105dzenie Guardian?" + }, "user": { "data": { "ip_address": "Adres IP", diff --git a/homeassistant/components/guardian/translations/ru.json b/homeassistant/components/guardian/translations/ru.json index 20e1710f12085..ca904d980af86 100644 --- a/homeassistant/components/guardian/translations/ru.json +++ b/homeassistant/components/guardian/translations/ru.json @@ -6,12 +6,15 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." }, "step": { + "discovery_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Guardian?" + }, "user": { "data": { "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441", "port": "\u041f\u043e\u0440\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Elexa Guardian." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Elexa Guardian." }, "zeroconf_confirm": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Elexa Guardian?" diff --git a/homeassistant/components/guardian/translations/tr.json b/homeassistant/components/guardian/translations/tr.json index 1e520a16995d9..9d3e903a2d60c 100644 --- a/homeassistant/components/guardian/translations/tr.json +++ b/homeassistant/components/guardian/translations/tr.json @@ -6,11 +6,18 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { + "discovery_confirm": { + "description": "Bu Guardian cihaz\u0131n\u0131 kurmak istiyor musunuz?" + }, "user": { "data": { - "ip_address": "\u0130p Adresi", + "ip_address": "IP Adresi", "port": "Port" - } + }, + "description": "Yerel bir Elexa Guardian cihaz\u0131 yap\u0131land\u0131r\u0131n." + }, + "zeroconf_confirm": { + "description": "Bu Guardian cihaz\u0131n\u0131 kurmak istiyor musunuz?" } } } diff --git a/homeassistant/components/guardian/translations/zh-Hant.json b/homeassistant/components/guardian/translations/zh-Hant.json index e2a8c03dbbf41..dc3bde8ec1c9d 100644 --- a/homeassistant/components/guardian/translations/zh-Hant.json +++ b/homeassistant/components/guardian/translations/zh-Hant.json @@ -6,6 +6,9 @@ "cannot_connect": "\u9023\u7dda\u5931\u6557" }, "step": { + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Guardian \u88dd\u7f6e\uff1f" + }, "user": { "data": { "ip_address": "IP \u4f4d\u5740", diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 884bbcde7c192..d83334e7a40bc 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import timedelta -from typing import Callable +from typing import Any, Dict, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -29,7 +29,7 @@ def __init__( api_coro: Callable[..., Awaitable], api_lock: asyncio.Lock, valve_controller_uid: str, - ): + ) -> None: """Initialize.""" super().__init__( hass, @@ -42,11 +42,11 @@ def __init__( self._api_lock = api_lock self._client = client - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Execute a "locked" API request against the valve controller.""" async with self._api_lock, self._client: try: resp = await self._api_coro() except GuardianError as err: raise UpdateFailed(err) from err - return resp["data"] + return cast(Dict[str, Any], resp["data"]) diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index e8846d1f85a19..af67178185d11 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -12,13 +12,16 @@ CONF_NAME, CONF_SENSORS, CONF_URL, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ARGS, + ATTR_DATA, ATTR_PATH, CONF_API_USER, DEFAULT_URL, @@ -71,7 +74,7 @@ def has_all_unique_users_names(value): ) CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] SERVICE_API_CALL_SCHEMA = vol.Schema( { @@ -82,7 +85,7 @@ def has_all_unique_users_names(value): ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Habitica service.""" configs = config.get(DOMAIN, []) @@ -111,7 +114,12 @@ def __call__(self, **kwargs): async def handle_api_call(call): name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] - api = hass.data[DOMAIN].get(name) + entries = hass.config_entries.async_entries(DOMAIN) + api = None + for entry in entries: + if entry.data[CONF_NAME] == name: + api = hass.data[DOMAIN].get(entry.entry_id) + break if api is None: _LOGGER.error("API_CALL: User '%s' not configured", name) return @@ -126,7 +134,7 @@ async def handle_api_call(call): kwargs = call.data.get(ATTR_ARGS, {}) data = await api(**kwargs) hass.bus.async_fire( - EVENT_API_CALL_SUCCESS, {"name": name, "path": path, "data": data} + EVENT_API_CALL_SUCCESS, {ATTR_NAME: name, ATTR_PATH: path, ATTR_DATA: data} ) data = hass.data.setdefault(DOMAIN, {}) @@ -157,7 +165,7 @@ async def handle_api_call(call): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 02a46334c7a05..1379f0a64477a 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -7,7 +7,11 @@ DEFAULT_URL = "https://habitica.com" DOMAIN = "habitica" +# service constants SERVICE_API_CALL = "api_call" ATTR_PATH = CONF_PATH ATTR_ARGS = "args" + +# event constants EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" +ATTR_DATA = "data" diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 52748ddadad8a..cd488819edabc 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -1,12 +1,13 @@ """Support for Habitica sensors.""" from collections import namedtuple from datetime import timedelta +from http import HTTPStatus import logging from aiohttp import ClientResponseError from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_NAME, HTTP_TOO_MANY_REQUESTS +from homeassistant.const import CONF_NAME from homeassistant.util import Throttle from .const import DOMAIN @@ -26,7 +27,7 @@ "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), - "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), + "gp": ST("Gold", "mdi:circle-multiple", "Gold", ["stats", "gp"]), "class": ST("Class", "mdi:sword", "", ["stats", "class"]), } @@ -94,7 +95,7 @@ async def update(self): try: self.data = await self.api.user.get() except ClientResponseError as error: - if error.status == HTTP_TOO_MANY_REQUESTS: + if error.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.warning( "Sensor data update for %s has too many API requests;" " Skipping the update", @@ -111,7 +112,7 @@ async def update(self): try: self.tasks[task_type] = await self.api.tasks.user.get(type=task_type) except ClientResponseError as error: - if error.status == HTTP_TOO_MANY_REQUESTS: + if error.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.warning( "Sensor data update for %s has too many API requests;" " Skipping the update", @@ -155,12 +156,12 @@ def name(self): return f"{DOMAIN}_{self._name}_{self._sensor_name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._sensor_type.unit @@ -195,7 +196,7 @@ def name(self): return f"{DOMAIN}_{self._name}_{self._task_name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -213,13 +214,12 @@ def extra_state_attributes(self): task_id = received_task[TASKS_MAP_ID] task = {} for map_key, map_value in TASKS_MAP.items(): - value = received_task.get(map_value) - if value: + if value := received_task.get(map_value): task[map_key] = value attrs[task_id] = task return attrs @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._task_type.unit diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 6fa8589ba4c11..e60e223808819 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -1,13 +1,25 @@ # Describes the format for Habitica service api_call: + name: API name description: Call Habitica API fields: name: + name: Name description: Habitica's username to call for + required: true example: "xxxNotAValidNickxxx" + selector: + text: path: + name: Path description: "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks" + required: true example: '["tasks", "user", "post"]' + selector: + object: args: + name: Args description: Any additional JSON or URL parameter arguments. See apidoc mentioned for path. Example uses same API endpoint example: '{"text": "Use API from Home Assistant", "type": "todo"}' + selector: + object: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 868d024b02efa..d25b840d76108 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -1,20 +1,19 @@ { - "config": { - "error": { - "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "url": "[%key:common::config_flow::data::url%]", - "name": "Override for Habitica’s username. Will be used for service calls", - "api_user": "Habitica’s API user ID", - "api_key": "[%key:common::config_flow::data::api_key%]" - }, - "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" - } - } + "config": { + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, - "title": "Habitica" + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "name": "Override for Habitica’s username. Will be used for service calls", + "api_user": "Habitica’s API user ID", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + } + } + } } diff --git a/homeassistant/components/habitica/translations/bg.json b/homeassistant/components/habitica/translations/bg.json index 02c83a6e9167b..ce37c7da82c1c 100644 --- a/homeassistant/components/habitica/translations/bg.json +++ b/homeassistant/components/habitica/translations/bg.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "url": "URL" } } diff --git a/homeassistant/components/habitica/translations/de.json b/homeassistant/components/habitica/translations/de.json index 04f985946fb50..694bbdd65d495 100644 --- a/homeassistant/components/habitica/translations/de.json +++ b/homeassistant/components/habitica/translations/de.json @@ -8,8 +8,11 @@ "user": { "data": { "api_key": "API-Schl\u00fcssel", + "api_user": "Habitica API-Benutzer-ID", + "name": "Override f\u00fcr den Benutzernamen von Habitica. Wird f\u00fcr Serviceaufrufe verwendet", "url": "URL" - } + }, + "description": "Verbinde dein Habitica-Profil, um die \u00dcberwachung des Profils und der Aufgaben deines Benutzers zu erm\u00f6glichen. Beachte, dass api_id und api_key von https://habitica.com/user/settings/api bezogen werden m\u00fcssen." } } }, diff --git a/homeassistant/components/habitica/translations/he.json b/homeassistant/components/habitica/translations/he.json new file mode 100644 index 0000000000000..9ef8ea8a34577 --- /dev/null +++ b/homeassistant/components/habitica/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_credentials": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "name": "\u05e2\u05e7\u05d5\u05e3 \u05e2\u05d1\u05d5\u05e8 \u05e9\u05dd \u05d4\u05de\u05e9\u05ea\u05de\u05e9 \u05e9\u05dc Habitica. \u05d9\u05e9\u05de\u05e9 \u05e2\u05d1\u05d5\u05e8 \u05e7\u05e8\u05d9\u05d0\u05d5\u05ea \u05e9\u05d9\u05e8\u05d5\u05ea", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/hu.json b/homeassistant/components/habitica/translations/hu.json index 4914a1bd27a71..589f53e852c9d 100644 --- a/homeassistant/components/habitica/translations/hu.json +++ b/homeassistant/components/habitica/translations/hu.json @@ -9,8 +9,10 @@ "data": { "api_key": "API kulcs", "api_user": "Habitica API felhaszn\u00e1l\u00f3i azonos\u00edt\u00f3ja", + "name": "A Habitica felhaszn\u00e1l\u00f3n\u00e9v fel\u00fcl\u00edr\u00e1sa. A szolg\u00e1ltat\u00e1si h\u00edv\u00e1sokhoz lesz haszn\u00e1lva", "url": "URL" - } + }, + "description": "Csatlakoztassa Habitica-profilj\u00e1t, hogy figyelemmel k\u00eds\u00e9rhesse felhaszn\u00e1l\u00f3i profilj\u00e1t \u00e9s feladatait. Ne feledje, hogy az api_id \u00e9s api_key c\u00edmeket a https://habitica.com/user/settings/api webhelyr\u0151l kell beszerezni" } } }, diff --git a/homeassistant/components/habitica/translations/ja.json b/homeassistant/components/habitica/translations/ja.json new file mode 100644 index 0000000000000..28d877a47888d --- /dev/null +++ b/homeassistant/components/habitica/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "api_user": "Habitica API\u306e\u30e6\u30fc\u30b6\u30fcID", + "name": "Habitica\u2019s\u306e\u30e6\u30fc\u30b6\u30fc\u540d\u3092\u4e0a\u66f8\u304d\u3057\u307e\u3059\u3002\u30b5\u30fc\u30d3\u30b9\u30b3\u30fc\u30eb\u3067\u4f7f\u7528\u3055\u308c\u307e\u3059", + "url": "URL" + }, + "description": "Habitica profile\u306b\u63a5\u7d9a\u3057\u3066\u3001\u3042\u306a\u305f\u306e\u30e6\u30fc\u30b6\u30fc\u306e\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u3068\u30bf\u30b9\u30af\u3092\u76e3\u8996\u3067\u304d\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002 \u6ce8\u610f: api_id\u3068api_key\u306f\u3001https://habitica.com/user/settings/api \u304b\u3089\u53d6\u5f97\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/tr.json b/homeassistant/components/habitica/translations/tr.json index f77cc77798c5e..32ad5aa4957f1 100644 --- a/homeassistant/components/habitica/translations/tr.json +++ b/homeassistant/components/habitica/translations/tr.json @@ -1,3 +1,20 @@ { + "config": { + "error": { + "invalid_credentials": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "api_user": "Habitica'n\u0131n API kullan\u0131c\u0131 kimli\u011fi", + "name": "Habitica'n\u0131n kullan\u0131c\u0131 ad\u0131n\u0131 ge\u00e7ersiz k\u0131l. Servis \u00e7a\u011fr\u0131lar\u0131 i\u00e7in kullan\u0131lacakt\u0131r", + "url": "URL" + }, + "description": "Kullan\u0131c\u0131n\u0131z\u0131n profilinin ve g\u00f6revlerinin izlenmesine izin vermek i\u00e7in Habitica profilinizi ba\u011flay\u0131n. api_id ve api_key'in https://habitica.com/user/settings/api adresinden al\u0131nmas\u0131 gerekti\u011fini unutmay\u0131n." + } + } + }, "title": "Habitica" } \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/zh-Hant.json b/homeassistant/components/habitica/translations/zh-Hant.json index 001682b5c8857..6591868516134 100644 --- a/homeassistant/components/habitica/translations/zh-Hant.json +++ b/homeassistant/components/habitica/translations/zh-Hant.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470", + "api_key": "API \u91d1\u9470", "api_user": "Habitica \u4e4b API \u4f7f\u7528\u8005 ID", "name": "\u8986\u5beb Habitica \u4f7f\u7528\u8005\u540d\u7a31\u3001\u7528\u4ee5\u670d\u52d9\u547c\u53eb", "url": "\u7db2\u5740" diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 04814a9c3e9f2..820aab8cb73e7 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -57,8 +57,7 @@ async def async_setup(hass, config): """Set up the Hangouts bot component.""" - config = config.get(DOMAIN) - if config is None: + if (config := config.get(DOMAIN)) is None: hass.data[DOMAIN] = { CONF_INTENTS: {}, CONF_DEFAULT_CONVERSATIONS: [], diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py index 2f0dafba0c360..adf62d348f44d 100644 --- a/homeassistant/components/hangouts/config_flow.py +++ b/homeassistant/components/hangouts/config_flow.py @@ -6,7 +6,6 @@ from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import callback from .const import ( CONF_2FA, @@ -22,15 +21,6 @@ ) -@callback -def configured_hangouts(hass): - """Return the configures Google Hangouts Account.""" - entries = hass.config_entries.async_entries(HANGOUTS_DOMAIN) - if entries: - return entries[0] - return None - - @config_entries.HANDLERS.register(HANGOUTS_DOMAIN) class HangoutsFlowHandler(config_entries.ConfigFlow): """Config flow Google Hangouts.""" @@ -46,8 +36,7 @@ async def async_step_user(self, user_input=None): """Handle a flow start.""" errors = {} - if configured_hangouts(self.hass) is not None: - return self.async_abort(reason="already_configured") + self._async_abort_entries_match() if user_input is not None: user_email = user_input[CONF_EMAIL] diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index 24be9fff77963..5c0625411aea4 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -1,6 +1,7 @@ """The Hangouts Bot.""" import asyncio from contextlib import suppress +from http import HTTPStatus import io import logging @@ -8,7 +9,6 @@ import hangups from hangups import ChatMessageEvent, ChatMessageSegment, Client, get_auth, hangouts_pb2 -from homeassistant.const import HTTP_OK from homeassistant.core import callback from homeassistant.helpers import dispatcher, intent from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -182,9 +182,7 @@ async def _async_process(self, intents, text, conv_id): """Detect a matching intent.""" for intent_type, data in intents.items(): for matcher in data.get(CONF_MATCHERS, []): - match = matcher.match(text) - - if not match: + if not (match := matcher.match(text)): continue if intent_type == INTENT_HELP: return await self.hass.helpers.intent.async_handle( @@ -273,7 +271,7 @@ async def _async_send_message(self, message, targets, data): try: websession = async_get_clientsession(self.hass) async with websession.get(uri, timeout=5) as response: - if response.status != HTTP_OK: + if response.status != HTTPStatus.OK: _LOGGER.error( "Fetch image failed, %s, %s", response.status, response ) diff --git a/homeassistant/components/hangouts/manifest.json b/homeassistant/components/hangouts/manifest.json index 69cfa515c02b0..187a748e3f670 100644 --- a/homeassistant/components/hangouts/manifest.json +++ b/homeassistant/components/hangouts/manifest.json @@ -3,7 +3,7 @@ "name": "Google Hangouts", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hangouts", - "requirements": ["hangups==0.4.11"], + "requirements": ["hangups==0.4.16"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/hangouts/services.yaml b/homeassistant/components/hangouts/services.yaml index 717e28884931e..041c21b5c25d6 100644 --- a/homeassistant/components/hangouts/services.yaml +++ b/homeassistant/components/hangouts/services.yaml @@ -1,18 +1,32 @@ update: + name: Update description: Updates the list of conversations. send_message: + name: Send message description: Send a notification to a specific target. fields: target: - description: List of targets with id or name. [Required] + name: Target + description: List of targets with id or name. + required: true example: '[{"id": "UgxrXzVrARmjx_C6AZx4AaABAagBo-6UCw"}, {"name": "Test Conversation"}]' + selector: + object: message: - description: List of message segments, only the "text" field is required in every segment. [Required] + name: Message + description: List of message segments, only the "text" field is required in every segment. + required: true example: '[{"text":"test", "is_bold": false, "is_italic": false, "is_strikethrough": false, "is_underline": false, "parse_str": false, "link_target": "http://google.com"}]' + selector: + object: data: + name: Data description: Other options ['image_file' / 'image_url'] example: '{ "image_file": "file" }' + selector: + object: reconnect: + name: Reconnect description: Reconnect the bot. diff --git a/homeassistant/components/hangouts/translations/cs.json b/homeassistant/components/hangouts/translations/cs.json index 8e721ed5ff139..11bef6d1d1a1c 100644 --- a/homeassistant/components/hangouts/translations/cs.json +++ b/homeassistant/components/hangouts/translations/cs.json @@ -14,6 +14,7 @@ "data": { "2fa": "Dvoufaktorov\u00fd ov\u011b\u0159ovac\u00ed k\u00f3d" }, + "description": "Pr\u00e1zdn\u00e9", "title": "Dvoufaktorov\u00e9 ov\u011b\u0159en\u00ed" }, "user": { @@ -22,6 +23,7 @@ "email": "E-mail", "password": "Heslo" }, + "description": "Pr\u00e1zdn\u00e9", "title": "P\u0159ihl\u00e1\u0161en\u00ed do slu\u017eby Google Hangouts" } } diff --git a/homeassistant/components/hangouts/translations/de.json b/homeassistant/components/hangouts/translations/de.json index 7b888cf531e07..42770308346eb 100644 --- a/homeassistant/components/hangouts/translations/de.json +++ b/homeassistant/components/hangouts/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Google Hangouts ist bereits konfiguriert", + "already_configured": "Der Dienst ist bereits konfiguriert", "unknown": "Unerwarteter Fehler" }, "error": { @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Autorisierungscode (f\u00fcr die manuelle Authentifizierung erforderlich)", - "email": "E-Mail-Adresse", + "email": "E-Mail", "password": "Passwort" }, "description": "Leer", diff --git a/homeassistant/components/hangouts/translations/fi.json b/homeassistant/components/hangouts/translations/fi.json index 959a2c06a6309..e93642a952dff 100644 --- a/homeassistant/components/hangouts/translations/fi.json +++ b/homeassistant/components/hangouts/translations/fi.json @@ -3,11 +3,17 @@ "abort": { "unknown": "Tapahtui tuntematon virhe." }, + "error": { + "invalid_2fa": "Virheellinen kaksitekij\u00e4todennus, yrit\u00e4 uudelleen.", + "invalid_2fa_method": "Virheellinen 2FA-menetelm\u00e4 (tarkista puhelimessa).", + "invalid_login": "Virheellinen kirjautuminen, yrit\u00e4 uudelleen." + }, "step": { "2fa": { "data": { "2fa": "2FA-pin" }, + "description": "Tyhj\u00e4", "title": "Kaksivaiheinen tunnistus" }, "user": { @@ -15,6 +21,7 @@ "email": "S\u00e4hk\u00f6postiosoite", "password": "Salasana" }, + "description": "Tyhj\u00e4", "title": "Google Hangouts -kirjautuminen" } } diff --git a/homeassistant/components/hangouts/translations/fr.json b/homeassistant/components/hangouts/translations/fr.json index 68e652db30956..ab2d2fc51688c 100644 --- a/homeassistant/components/hangouts/translations/fr.json +++ b/homeassistant/components/hangouts/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Google Hangouts est d\u00e9j\u00e0 configur\u00e9", - "unknown": "Une erreur inconnue s'est produite" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" }, "error": { "invalid_2fa": "Authentification \u00e0 2 facteurs invalide, veuillez r\u00e9essayer.", @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Code d'autorisation (requis pour l'authentification manuelle)", - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "description": "Vide", diff --git a/homeassistant/components/hangouts/translations/he.json b/homeassistant/components/hangouts/translations/he.json index c3863a860f443..9f0e3b48a6269 100644 --- a/homeassistant/components/hangouts/translations/he.json +++ b/homeassistant/components/hangouts/translations/he.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Google Hangouts \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", - "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { "invalid_2fa": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d1\u05d1\u05e7\u05e9\u05d4 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", @@ -18,7 +18,7 @@ }, "user": { "data": { - "email": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d3\u05d5\u05d0\"\u05dc", + "email": "\u05d3\u05d5\u05d0\"\u05dc", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, "title": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc- Google Hangouts" diff --git a/homeassistant/components/hangouts/translations/hu.json b/homeassistant/components/hangouts/translations/hu.json index b81e3fcf0dd20..eda0144a81842 100644 --- a/homeassistant/components/hangouts/translations/hu.json +++ b/homeassistant/components/hangouts/translations/hu.json @@ -12,15 +12,18 @@ "step": { "2fa": { "data": { - "2fa": "2FA Pin" + "2fa": "2FA PIN" }, + "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" }, "user": { "data": { + "authorization_code": "Enged\u00e9lyez\u00e9si k\u00f3d (k\u00e9zi hiteles\u00edt\u00e9shez sz\u00fcks\u00e9ges)", "email": "E-mail", "password": "Jelsz\u00f3" }, + "description": "\u00dcres", "title": "Google Hangouts Bejelentkez\u00e9s" } } diff --git a/homeassistant/components/hangouts/translations/it.json b/homeassistant/components/hangouts/translations/it.json index 3e89327ca30f0..3d9322b115202 100644 --- a/homeassistant/components/hangouts/translations/it.json +++ b/homeassistant/components/hangouts/translations/it.json @@ -5,9 +5,9 @@ "unknown": "Errore imprevisto" }, "error": { - "invalid_2fa": "Autenticazione a 2 fattori non valida, riprovare.", + "invalid_2fa": "Autenticazione a 2 fattori non valida, riprova.", "invalid_2fa_method": "Metodo 2FA non valido (verifica sul telefono).", - "invalid_login": "Accesso non valido, si prega di riprovare." + "invalid_login": "Accesso non valido, riprova." }, "step": { "2fa": { @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", - "email": "E-mail", + "email": "Email", "password": "Password" }, "description": "Vuoto", diff --git a/homeassistant/components/hangouts/translations/ja.json b/homeassistant/components/hangouts/translations/ja.json new file mode 100644 index 0000000000000..14637ee115580 --- /dev/null +++ b/homeassistant/components/hangouts/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "invalid_2fa": "2\u8981\u7d20\u8a8d\u8a3c\u304c\u7121\u52b9\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "invalid_2fa_method": "2\u8981\u7d20\u8a8d\u8a3c\u304c\u7121\u52b9(\u96fb\u8a71\u3067\u78ba\u8a8d)", + "invalid_login": "\u30ed\u30b0\u30a4\u30f3\u3067\u304d\u307e\u305b\u3093\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "2fa": { + "data": { + "2fa": "2\u8981\u7d20 PIN" + }, + "description": "\u7a7a", + "title": "2\u8981\u7d20\u8a8d\u8a3c" + }, + "user": { + "data": { + "authorization_code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9(\u624b\u52d5\u8a8d\u8a3c\u6642\u306b\u5fc5\u8981)", + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u7a7a", + "title": "Google \u30cf\u30f3\u30b0\u30a2\u30a6\u30c8 \u30ed\u30b0\u30a4\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/lt.json b/homeassistant/components/hangouts/translations/lt.json new file mode 100644 index 0000000000000..13dbbf8bdbce3 --- /dev/null +++ b/homeassistant/components/hangouts/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "2 veiksni\u0173 autentifikavimas" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/tr.json b/homeassistant/components/hangouts/translations/tr.json index a204200a2d843..84fb80abaf533 100644 --- a/homeassistant/components/hangouts/translations/tr.json +++ b/homeassistant/components/hangouts/translations/tr.json @@ -4,12 +4,27 @@ "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "unknown": "Beklenmeyen hata" }, + "error": { + "invalid_2fa": "Ge\u00e7ersiz 2 Fakt\u00f6rl\u00fc Kimlik Do\u011frulama, l\u00fctfen tekrar deneyin.", + "invalid_2fa_method": "Ge\u00e7ersiz 2FA Y\u00f6ntemi (Telefonda do\u011frulay\u0131n).", + "invalid_login": "Ge\u00e7ersiz Giri\u015f, l\u00fctfen tekrar deneyin." + }, "step": { + "2fa": { + "data": { + "2fa": "2FA PIN'i" + }, + "description": "Bo\u015f", + "title": "2-Fakt\u00f6rl\u00fc Kimlik Do\u011frulama" + }, "user": { "data": { + "authorization_code": "Yetkilendirme Kodu (manuel kimlik do\u011frulama i\u00e7in gereklidir)", "email": "E-posta", "password": "Parola" - } + }, + "description": "Bo\u015f", + "title": "Google Hangouts Giri\u015fi" } } } diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index d0172bf737895..4ec610f1f7570 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -1,12 +1,10 @@ """The Logitech Harmony Hub integration.""" -import asyncio import logging from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -23,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Logitech Harmony Hub from a config entry.""" # As there currently is no way to import options from yaml # when setting up a config entry, we fallback to adding @@ -34,13 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): address = entry.data[CONF_HOST] name = entry.data[CONF_NAME] data = HarmonyData(hass, address, name, entry.unique_id) - try: - connected_ok = await data.connect() - except (asyncio.TimeoutError, ValueError, AttributeError) as err: - raise ConfigEntryNotReady from err - - if not connected_ok: - raise ConfigEntryNotReady + await data.connect() await _migrate_old_unique_ids(hass, entry.entry_id, data) @@ -51,8 +43,7 @@ async def _async_on_stop(event): cancel_stop = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_on_stop) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { HARMONY_DATA: data, CANCEL_LISTENER: cancel_listener, CANCEL_STOP: cancel_stop, @@ -94,7 +85,7 @@ def _async_migrator(entity_entry: entity_registry.RegistryEntry): def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): options = dict(entry.options) modified = 0 - for importable_option in [ATTR_ACTIVITY, ATTR_DELAY_SECS]: + for importable_option in (ATTR_ACTIVITY, ATTR_DELAY_SECS): if importable_option not in entry.options and importable_option in entry.data: options[importable_option] = entry.data[importable_option] modified = 1 @@ -110,7 +101,7 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index d6ffa3d178717..4dca2192c6bf1 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -1,7 +1,10 @@ """Config flow for Logitech Harmony Hub integration.""" +import asyncio import logging from urllib.parse import urlparse +from aioharmony.hubconnector_websocket import HubConnector +import aiohttp import voluptuous as vol from homeassistant import config_entries, exceptions @@ -13,6 +16,7 @@ ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN, HARMONY_DATA, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID from .util import ( @@ -49,7 +53,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Harmony config flow.""" self.harmony_config = {} @@ -78,15 +82,14 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Harmony device.""" _LOGGER.debug("SSDP discovery_info: %s", discovery_info) - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) - friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + parsed_url = urlparse(discovery_info.ssdp_location) + friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - if self._host_already_configured(parsed_url.hostname): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: parsed_url.hostname}) self.context["title_placeholders"] = {"name": friendly_name} @@ -95,16 +98,20 @@ async def async_step_ssdp(self, discovery_info): CONF_NAME: friendly_name, } - harmony = await get_harmony_client_if_available(parsed_url.hostname) - - if harmony: - unique_id = find_unique_id_for_remote(harmony) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={CONF_HOST: self.harmony_config[CONF_HOST]} - ) - self.harmony_config[UNIQUE_ID] = unique_id - + connector = HubConnector(parsed_url.hostname, asyncio.Queue()) + try: + remote_id = await connector.get_remote_id() + except aiohttp.ClientError: + return self.async_abort(reason="cannot_connect") + finally: + await connector.async_close_session() + + unique_id = str(remote_id) + await self.async_set_unique_id(str(unique_id)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.harmony_config[CONF_HOST]} + ) + self.harmony_config[UNIQUE_ID] = unique_id return await self.async_step_link() async def async_step_link(self, user_input=None): @@ -147,16 +154,6 @@ async def _async_create_entry_from_valid_input(self, validated, user_input): return self.async_create_entry(title=validated[CONF_NAME], data=data) - def _host_already_configured(self, host): - """See if we already have a harmony entry matching the host.""" - for entry in self._async_current_entries(): - if CONF_HOST not in entry.data: - continue - - if entry.data[CONF_HOST] == host: - return True - return False - def _options_from_user_input(user_input): options = {} @@ -170,7 +167,7 @@ def _options_from_user_input(user_input): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Harmony.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/harmony/connection_state.py b/homeassistant/components/harmony/connection_state.py deleted file mode 100644 index 84ad353480c08..0000000000000 --- a/homeassistant/components/harmony/connection_state.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Mixin class for handling connection state changes.""" -import logging - -from homeassistant.helpers.event import async_call_later - -_LOGGER = logging.getLogger(__name__) - -TIME_MARK_DISCONNECTED = 10 - - -class ConnectionStateMixin: - """Base implementation for connection state handling.""" - - def __init__(self): - """Initialize this mixin instance.""" - super().__init__() - self._unsub_mark_disconnected = None - - async def async_got_connected(self, _=None): - """Notification that we're connected to the HUB.""" - _LOGGER.debug("%s: connected to the HUB", self._name) - self.async_write_ha_state() - - self._clear_disconnection_delay() - - async def async_got_disconnected(self, _=None): - """Notification that we're disconnected from the HUB.""" - _LOGGER.debug("%s: disconnected from the HUB", self._name) - # We're going to wait for 10 seconds before announcing we're - # unavailable, this to allow a reconnection to happen. - self._unsub_mark_disconnected = async_call_later( - self.hass, TIME_MARK_DISCONNECTED, self._mark_disconnected_if_unavailable - ) - - def _clear_disconnection_delay(self): - if self._unsub_mark_disconnected: - self._unsub_mark_disconnected() - self._unsub_mark_disconnected = None - - def _mark_disconnected_if_unavailable(self, _): - self._unsub_mark_disconnected = None - if not self.available: - # Still disconnected. Let the state engine know. - self.async_write_ha_state() diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 0d8d893a98e27..8df5b3d578c12 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -1,8 +1,10 @@ """Constants for the Harmony component.""" +from homeassistant.const import Platform + DOMAIN = "harmony" SERVICE_SYNC = "sync" SERVICE_CHANGE_CHANNEL = "change_channel" -PLATFORMS = ["remote", "switch"] +PLATFORMS = [Platform.REMOTE, Platform.SELECT, Platform.SWITCH] UNIQUE_ID = "unique_id" ACTIVITY_POWER_OFF = "PowerOff" HARMONY_OPTIONS_UPDATE = "harmony_options_update" diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 6fdf18df61248..aa373d5813ac5 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -1,6 +1,7 @@ """Harmony data object which contains the Harmony Client.""" from __future__ import annotations +import asyncio from collections.abc import Iterable import logging @@ -8,6 +9,9 @@ import aioharmony.exceptions as aioexc from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import DeviceInfo + from .const import ACTIVITY_POWER_OFF from .subscriber import HarmonySubscriberMixin @@ -79,20 +83,21 @@ def current_activity(self) -> tuple: """Return the current activity tuple.""" return self._client.current_activity - def device_info(self, domain: str): + def device_info(self, domain: str) -> DeviceInfo: """Return hub device info.""" model = "Harmony Hub" if "ethernetStatus" in self._client.hub_config.info: model = "Harmony Hub Pro 2400" - return { - "identifiers": {(domain, self.unique_id)}, - "manufacturer": "Logitech", - "sw_version": self._client.hub_config.info.get( + return DeviceInfo( + identifiers={(domain, self.unique_id)}, + manufacturer="Logitech", + model=model, + name=self.name, + sw_version=self._client.hub_config.info.get( "hubSwVersion", self._client.fw_version ), - "name": self.name, - "model": model, - } + configuration_url="https://www.logitech.com/en-us/my-account", + ) async def connect(self) -> bool: """Connect to the Harmony Hub.""" @@ -109,16 +114,24 @@ async def connect(self) -> bool: ip_address=self._address, callbacks=ClientCallbackType(**callbacks) ) + connected = False try: - if not await self._client.connect(): - _LOGGER.warning("%s: Unable to connect to HUB", self._name) - await self._client.close() - return False - except aioexc.TimeOut: - _LOGGER.warning("%s: Connection timed-out", self._name) - return False - - return True + connected = await self._client.connect() + except (asyncio.TimeoutError, aioexc.TimeOut) as err: + await self._client.close() + raise ConfigEntryNotReady( + f"{self._name}: Connection timed-out to {self._address}:8088" + ) from err + except (ValueError, AttributeError) as err: + await self._client.close() + raise ConfigEntryNotReady( + f"{self._name}: Error {err} while connected HUB at: {self._address}:8088" + ) from err + if not connected: + await self._client.close() + raise ConfigEntryNotReady( + f"{self._name}: Unable to connect to HUB at: {self._address}:8088" + ) async def shutdown(self): """Close connection on shutdown.""" diff --git a/homeassistant/components/harmony/entity.py b/homeassistant/components/harmony/entity.py new file mode 100644 index 0000000000000..24c72a771e745 --- /dev/null +++ b/homeassistant/components/harmony/entity.py @@ -0,0 +1,55 @@ +"""Base class Harmony entities.""" +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later + +from .data import HarmonyData + +_LOGGER = logging.getLogger(__name__) + +TIME_MARK_DISCONNECTED = 10 + + +class HarmonyEntity(Entity): + """Base entity for Harmony with connection state handling.""" + + def __init__(self, data: HarmonyData) -> None: + """Initialize the Harmony base entity.""" + super().__init__() + self._unsub_mark_disconnected = None + self._name = data.name + self._data = data + self._attr_should_poll = False + + @property + def available(self) -> bool: + """Return True if we're connected to the Hub, otherwise False.""" + return self._data.available + + async def async_got_connected(self, _=None): + """Notification that we're connected to the HUB.""" + _LOGGER.debug("%s: connected to the HUB", self._name) + self.async_write_ha_state() + + self._clear_disconnection_delay() + + async def async_got_disconnected(self, _=None): + """Notification that we're disconnected from the HUB.""" + _LOGGER.debug("%s: disconnected from the HUB", self._name) + # We're going to wait for 10 seconds before announcing we're + # unavailable, this to allow a reconnection to happen. + self._unsub_mark_disconnected = async_call_later( + self.hass, TIME_MARK_DISCONNECTED, self._mark_disconnected_if_unavailable + ) + + def _clear_disconnection_delay(self): + if self._unsub_mark_disconnected: + self._unsub_mark_disconnected() + self._unsub_mark_disconnected = None + + def _mark_disconnected_if_unavailable(self, _): + self._unsub_mark_disconnected = None + if not self.available: + # Still disconnected. Let the state engine know. + self.async_write_ha_state() diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index e28d525539b30..d1b1073ebad77 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -2,8 +2,14 @@ "domain": "harmony", "name": "Logitech Harmony Hub", "documentation": "https://www.home-assistant.io/integrations/harmony", - "requirements": ["aioharmony==0.2.7"], - "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"], + "requirements": ["aioharmony==0.2.8"], + "codeowners": [ + "@ehendrix23", + "@bramkragten", + "@bdraco", + "@mkeesey", + "@Aohzan" + ], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 593fbf3cb22d7..91022cf8d423c 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -21,7 +21,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity -from .connection_state import ConnectionStateMixin from .const import ( ACTIVITY_POWER_OFF, ATTR_ACTIVITY_STARTING, @@ -34,6 +33,7 @@ SERVICE_CHANGE_CHANNEL, SERVICE_SYNC, ) +from .entity import HarmonyEntity from .subscriber import HarmonyCallback _LOGGER = logging.getLogger(__name__) @@ -76,28 +76,24 @@ async def async_setup_entry( ) -class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): +class HarmonyRemote(HarmonyEntity, remote.RemoteEntity, RestoreEntity): """Remote representation used to control a Harmony device.""" def __init__(self, data, activity, delay_secs, out_path): """Initialize HarmonyRemote class.""" - super().__init__() - self._data = data - self._name = data.name + super().__init__(data=data) self._state = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None self._is_initial_update = True self.delay_secs = delay_secs - self._unique_id = data.unique_id self._last_activity = None self._config_path = out_path - - @property - def supported_features(self): - """Supported features for the remote.""" - return SUPPORT_ACTIVITY + self._attr_unique_id = data.unique_id + self._attr_device_info = self._data.device_info(DOMAIN) + self._attr_name = data.name + self._attr_supported_features = SUPPORT_ACTIVITY async def _async_update_options(self, data): """Change options when the options flow does.""" @@ -128,7 +124,7 @@ async def async_added_to_hass(self): """Complete the initialization.""" await super().async_added_to_hass() - _LOGGER.debug("%s: Harmony Hub added", self._name) + _LOGGER.debug("%s: Harmony Hub added", self.name) self.async_on_remove(self._clear_disconnection_delay) self._setup_callbacks() @@ -148,8 +144,7 @@ async def async_added_to_hass(self): # Restore the last activity so we know # how what to turn on if nothing # is specified - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ATTR_LAST_ACTIVITY not in last_state.attributes: return @@ -158,26 +153,6 @@ async def async_added_to_hass(self): self._last_activity = last_state.attributes[ATTR_LAST_ACTIVITY] - @property - def device_info(self): - """Return device info.""" - return self._data.device_info(DOMAIN) - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the Harmony device's name.""" - return self._name - - @property - def should_poll(self): - """Return the fact that we should not be polled.""" - return False - @property def current_activity(self): """Return the current activity.""" @@ -202,16 +177,11 @@ def is_on(self): """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, "PowerOff"] - @property - def available(self): - """Return True if connected to Hub, otherwise False.""" - return self._data.available - @callback def async_new_activity(self, activity_info: tuple) -> None: """Call for updating the current activity.""" activity_id, activity_name = activity_info - _LOGGER.debug("%s: activity reported as: %s", self._name, activity_name) + _LOGGER.debug("%s: activity reported as: %s", self.name, activity_name) self._current_activity = activity_name if self._is_initial_update: self._is_initial_update = False @@ -227,7 +197,7 @@ def async_new_activity(self, activity_info: tuple) -> None: async def async_new_config(self, _=None): """Call for updating the current activity.""" - _LOGGER.debug("%s: configuration has been updated", self._name) + _LOGGER.debug("%s: configuration has been updated", self.name) self.async_new_activity(self._data.current_activity) await self.hass.async_add_executor_job(self.write_config_file) @@ -241,8 +211,7 @@ async def async_turn_on(self, **kwargs): if self._last_activity: activity = self._last_activity else: - all_activities = self._data.activity_names - if all_activities: + if all_activities := self._data.activity_names: activity = all_activities[0] if activity: @@ -257,8 +226,7 @@ async def async_turn_off(self, **kwargs): async def async_send_command(self, command, **kwargs): """Send a list of commands to one device.""" _LOGGER.debug("%s: Send Command", self.name) - device = kwargs.get(ATTR_DEVICE) - if device is None: + if (device := kwargs.get(ATTR_DEVICE)) is None: _LOGGER.error("%s: Missing required argument: device", self.name) return @@ -286,8 +254,7 @@ def write_config_file(self): _LOGGER.debug( "%s: Writing hub configuration to file: %s", self.name, self._config_path ) - json_config = self._data.json_config - if json_config is None: + if (json_config := self._data.json_config) is None: _LOGGER.warning("%s: No configuration received from hub", self.name) return diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py new file mode 100644 index 0000000000000..18f273e4bfb8e --- /dev/null +++ b/homeassistant/components/harmony/select.py @@ -0,0 +1,75 @@ +"""Support for Harmony Hub select activities.""" +from __future__ import annotations + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import ACTIVITY_POWER_OFF, DOMAIN, HARMONY_DATA +from .data import HarmonyData +from .entity import HarmonyEntity +from .subscriber import HarmonyCallback + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up harmony activities select.""" + data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + _LOGGER.debug("creating select for %s hub activities", entry.data[CONF_NAME]) + async_add_entities( + [HarmonyActivitySelect(f"{entry.data[CONF_NAME]} Activities", data)] + ) + + +class HarmonyActivitySelect(HarmonyEntity, SelectEntity): + """Select representation of a Harmony activities.""" + + def __init__(self, name: str, data: HarmonyData) -> None: + """Initialize HarmonyActivitySelect class.""" + super().__init__(data=data) + self._data = data + self._attr_unique_id = self._data.unique_id + self._attr_device_info = self._data.device_info(DOMAIN) + self._attr_name = name + + @property + def icon(self): + """Return a representative icon.""" + if not self.available or self.current_option == ACTIVITY_POWER_OFF: + return "mdi:remote-tv-off" + return "mdi:remote-tv" + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return [ACTIVITY_POWER_OFF] + sorted(self._data.activity_names) + + @property + def current_option(self): + """Return the current activity.""" + _, activity_name = self._data.current_activity + return activity_name + + async def async_select_option(self, option: str) -> None: + """Change the current activity.""" + await self._data.async_start_activity(option) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + + callbacks = { + "connected": self.async_got_connected, + "disconnected": self.async_got_disconnected, + "activity_starting": self._async_activity_update, + "activity_started": self._async_activity_update, + "config_updated": None, + } + + self.async_on_remove(self._data.async_subscribe(HarmonyCallback(**callbacks))) + + @callback + def _async_activity_update(self, activity_info: tuple): + self.async_write_ha_state() diff --git a/homeassistant/components/harmony/services.yaml b/homeassistant/components/harmony/services.yaml index f20f0494a5f71..fd53912397a3f 100644 --- a/homeassistant/components/harmony/services.yaml +++ b/homeassistant/components/harmony/services.yaml @@ -1,16 +1,24 @@ sync: + name: Sync description: Syncs the remote's configuration. - fields: - entity_id: - description: Name(s) of entities to sync. - example: "remote.family_room" + target: + entity: + integration: harmony + domain: remote change_channel: + name: Change channel description: Sends change channel command to the Harmony HUB + target: + entity: + integration: harmony + domain: remote fields: - entity_id: - description: Name(s) of Harmony remote entities to send change channel command to - example: "remote.family_room" channel: + name: Channel description: Channel number to change to - example: "200" + required: true + selector: + number: + min: 1 + max: 100000 diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 16b83c80478de..02885289a06f6 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -5,9 +5,9 @@ from homeassistant.const import CONF_NAME from homeassistant.core import callback -from .connection_state import ConnectionStateMixin from .const import DOMAIN, HARMONY_DATA from .data import HarmonyData +from .entity import HarmonyEntity from .subscriber import HarmonyCallback _LOGGER = logging.getLogger(__name__) @@ -27,31 +27,18 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(switches, True) -class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): +class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): """Switch representation of a Harmony activity.""" - def __init__(self, name: str, activity: dict, data: HarmonyData): + def __init__(self, name: str, activity: dict, data: HarmonyData) -> None: """Initialize HarmonyActivitySwitch class.""" - super().__init__() - self._name = name + super().__init__(data=data) self._activity_name = activity["label"] self._activity_id = activity["id"] - self._data = data - - @property - def name(self): - """Return the Harmony activity's name.""" - return self._name - - @property - def unique_id(self): - """Return the unique id.""" - return f"activity_{self._activity_id}" - - @property - def device_info(self): - """Return device info.""" - return self._data.device_info(DOMAIN) + self._attr_entity_registry_enabled_default = False + self._attr_unique_id = f"activity_{self._activity_id}" + self._attr_name = name + self._attr_device_info = self._data.device_info(DOMAIN) @property def is_on(self): @@ -59,16 +46,6 @@ def is_on(self): _, activity_name = self._data.current_activity return activity_name == self._activity_name - @property - def should_poll(self): - """Return that we shouldn't be polled.""" - return False - - @property - def available(self): - """Return True if we're connected to the Hub, otherwise False.""" - return self._data.available - async def async_turn_on(self, **kwargs): """Start this activity.""" await self._data.async_start_activity(self._activity_name) diff --git a/homeassistant/components/harmony/translations/ca.json b/homeassistant/components/harmony/translations/ca.json index 7257ddb450dc8..e5a15705bace5 100644 --- a/homeassistant/components/harmony/translations/ca.json +++ b/homeassistant/components/harmony/translations/ca.json @@ -7,7 +7,7 @@ "cannot_connect": "Ha fallat la connexi\u00f3", "unknown": "Error inesperat" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Vols configurar {name} ({host})?", diff --git a/homeassistant/components/harmony/translations/de.json b/homeassistant/components/harmony/translations/de.json index 9cd07f09529f4..24cdc51cd65f2 100644 --- a/homeassistant/components/harmony/translations/de.json +++ b/homeassistant/components/harmony/translations/de.json @@ -7,18 +7,18 @@ "cannot_connect": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { - "description": "M\u00f6chten Sie {name} ({host}) einrichten?", - "title": "Richten Sie den Logitech Harmony Hub ein" + "description": "M\u00f6chtest du {name} ({host}) einrichten?", + "title": "Richte den Logitech Harmony Hub ein" }, "user": { "data": { "host": "Host", "name": "Hub-Name" }, - "title": "Richten Sie den Logitech Harmony Hub ein" + "title": "Richte den Logitech Harmony Hub ein" } } }, @@ -29,7 +29,7 @@ "activity": "Die Standardaktivit\u00e4t, die ausgef\u00fchrt werden soll, wenn keine angegeben ist.", "delay_secs": "Die Verz\u00f6gerung zwischen dem Senden von Befehlen." }, - "description": "Passen Sie die Harmony Hub-Optionen an" + "description": "Passe die Harmony Hub-Optionen an" } } } diff --git a/homeassistant/components/harmony/translations/et.json b/homeassistant/components/harmony/translations/et.json index 861282430149e..ef4c0c5ce69f9 100644 --- a/homeassistant/components/harmony/translations/et.json +++ b/homeassistant/components/harmony/translations/et.json @@ -7,7 +7,7 @@ "cannot_connect": "\u00dchenduse loomine nurjus. Proovi uuesti", "unknown": "Ootamatu t\u00f5rge" }, - "flow_title": "", + "flow_title": "{name}", "step": { "link": { "description": "Kas soovid seadistada {name}({host})?", diff --git a/homeassistant/components/harmony/translations/fr.json b/homeassistant/components/harmony/translations/fr.json index 4343ec3139dd1..077405be95f4f 100644 --- a/homeassistant/components/harmony/translations/fr.json +++ b/homeassistant/components/harmony/translations/fr.json @@ -4,10 +4,10 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Voulez-vous configurer {name} ( {host} ) ?", @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom du Hub" }, "title": "Configuration de Logitech Harmony Hub" diff --git a/homeassistant/components/harmony/translations/he.json b/homeassistant/components/harmony/translations/he.json new file mode 100644 index 0000000000000..49470b50ca9df --- /dev/null +++ b/homeassistant/components/harmony/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/hu.json b/homeassistant/components/harmony/translations/hu.json index a9cb6ccecee7d..900cd24324730 100644 --- a/homeassistant/components/harmony/translations/hu.json +++ b/homeassistant/components/harmony/translations/hu.json @@ -7,11 +7,29 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, + "flow_title": "{name}", "step": { + "link": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", + "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" + }, "user": { "data": { - "host": "Hoszt" - } + "host": "C\u00edm", + "name": "Hub neve" + }, + "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Az alap\u00e9rtelmezett tev\u00e9kenys\u00e9g, amelyet akkor kell v\u00e9grehajtani, ha nincs megadva.", + "delay_secs": "A parancsok k\u00fcld\u00e9se k\u00f6z\u00f6tti k\u00e9s\u00e9s." + }, + "description": "Harmony Hub be\u00e1ll\u00edt\u00e1sok" } } } diff --git a/homeassistant/components/harmony/translations/id.json b/homeassistant/components/harmony/translations/id.json index 0d2991b1feb10..86ab0be3274b2 100644 --- a/homeassistant/components/harmony/translations/id.json +++ b/homeassistant/components/harmony/translations/id.json @@ -7,7 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/harmony/translations/it.json b/homeassistant/components/harmony/translations/it.json index 3bc46a83b3a99..9f90460f7850b 100644 --- a/homeassistant/components/harmony/translations/it.json +++ b/homeassistant/components/harmony/translations/it.json @@ -7,7 +7,7 @@ "cannot_connect": "Impossibile connettersi", "unknown": "Errore imprevisto" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Vuoi impostare {name} ({host})?", @@ -18,7 +18,7 @@ "host": "Host", "name": "Nome Hub" }, - "title": "Configurare Logitech Harmony Hub" + "title": "Configura Logitech Harmony Hub" } } }, @@ -29,7 +29,7 @@ "activity": "L'attivit\u00e0 predefinita da eseguire quando nessuna \u00e8 specificata.", "delay_secs": "Il ritardo tra l'invio dei comandi." }, - "description": "Regolare le opzioni di Harmony Hub" + "description": "Regola le opzioni di Harmony Hub" } } } diff --git a/homeassistant/components/harmony/translations/ja.json b/homeassistant/components/harmony/translations/ja.json new file mode 100644 index 0000000000000..21af2f3bb2ae9 --- /dev/null +++ b/homeassistant/components/harmony/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "{name} ({host})\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b?", + "title": "Logitech Harmony Hub\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u30cf\u30d6\u540d" + }, + "title": "Logitech Harmony Hub\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "\u4f55\u3082\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u5b9f\u884c\u3055\u308c\u308b\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30a2\u30af\u30c6\u30a3\u30d3\u30c6\u30a3\u3002", + "delay_secs": "\u30b3\u30de\u30f3\u30c9\u3092\u9001\u4fe1\u3059\u308b\u969b\u306e\u9045\u5ef6\u6642\u9593\u3002" + }, + "description": "Harmony Hub\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/nl.json b/homeassistant/components/harmony/translations/nl.json index 33cbeca8893f7..aaf16ed2dc7da 100644 --- a/homeassistant/components/harmony/translations/nl.json +++ b/homeassistant/components/harmony/translations/nl.json @@ -7,7 +7,7 @@ "cannot_connect": "Kan geen verbinding maken", "unknown": "Onverwachte fout" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Wil je {name} ({host}) instellen?", diff --git a/homeassistant/components/harmony/translations/no.json b/homeassistant/components/harmony/translations/no.json index aeeb5ae3c8461..072837d600e4c 100644 --- a/homeassistant/components/harmony/translations/no.json +++ b/homeassistant/components/harmony/translations/no.json @@ -7,7 +7,7 @@ "cannot_connect": "Tilkobling mislyktes", "unknown": "Uventet feil" }, - "flow_title": "", + "flow_title": "{name}", "step": { "link": { "description": "Vil du konfigurere {name} ({host})?", diff --git a/homeassistant/components/harmony/translations/pl.json b/homeassistant/components/harmony/translations/pl.json index 0a288b0ce0742..7e29aa8ac9a02 100644 --- a/homeassistant/components/harmony/translations/pl.json +++ b/homeassistant/components/harmony/translations/pl.json @@ -7,7 +7,7 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Czy chcesz skonfigurowa\u0107 {name} ({host})?", diff --git a/homeassistant/components/harmony/translations/ru.json b/homeassistant/components/harmony/translations/ru.json index 5dfcff7091de0..8bf00fe863704 100644 --- a/homeassistant/components/harmony/translations/ru.json +++ b/homeassistant/components/harmony/translations/ru.json @@ -7,7 +7,7 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} ({host})?", diff --git a/homeassistant/components/harmony/translations/tr.json b/homeassistant/components/harmony/translations/tr.json index c77f0f8e07e7b..b4400dcb06a6b 100644 --- a/homeassistant/components/harmony/translations/tr.json +++ b/homeassistant/components/harmony/translations/tr.json @@ -7,11 +7,29 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name}", "step": { + "link": { + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?", + "title": "Logitech Harmony Hub'\u0131 Kur" + }, "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Sunucu", + "name": "Hub Ad\u0131" + }, + "title": "Logitech Harmony Hub'\u0131 Kur" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Hi\u00e7biri belirtilmedi\u011finde y\u00fcr\u00fct\u00fclecek varsay\u0131lan etkinlik.", + "delay_secs": "Komut g\u00f6nderme aras\u0131ndaki gecikme." + }, + "description": "Harmony Hub Se\u00e7eneklerini Ayarlay\u0131n" } } } diff --git a/homeassistant/components/harmony/translations/zh-Hant.json b/homeassistant/components/harmony/translations/zh-Hant.json index cf835421fc1e3..0ea95aecd67dc 100644 --- a/homeassistant/components/harmony/translations/zh-Hant.json +++ b/homeassistant/components/harmony/translations/zh-Hant.json @@ -7,7 +7,7 @@ "cannot_connect": "\u9023\u7dda\u5931\u6557", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "\u7f85\u6280 Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} ({host})\uff1f", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 2c25868dfcd8d..78927d2f3228d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1,10 +1,11 @@ """Support for Hass.io.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging import os -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol @@ -16,16 +17,24 @@ import homeassistant.config as conf_util from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_MANUFACTURER, ATTR_NAME, - ATTR_SERVICE, EVENT_CORE_CONFIG_UPDATE, + HASSIO_USER_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import DOMAIN as HASS_DOMAIN, Config, HomeAssistant, callback +from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, recorder -from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry +from homeassistant.helpers.device_registry import ( + DeviceEntryType, + DeviceRegistry, + async_get_registry, +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow @@ -42,12 +51,15 @@ ATTR_PASSWORD, ATTR_REPOSITORY, ATTR_SLUG, - ATTR_SNAPSHOT, + ATTR_STARTED, + ATTR_STATE, ATTR_URL, ATTR_VERSION, + DATA_KEY_ADDONS, DOMAIN, + SupervisorEntityModel, ) -from .discovery import async_setup_discovery_view +from .discovery import HassioServiceInfo, async_setup_discovery_view # noqa: F401 from .handler import HassIO, HassioAPIError, api_data from .http import HassIOView from .ingress import async_setup_ingress_view @@ -58,7 +70,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONF_FRONTEND_REPO = "development_repo" @@ -70,10 +82,12 @@ DATA_CORE_INFO = "hassio_core_info" DATA_HOST_INFO = "hassio_host_info" +DATA_STORE = "hassio_store" DATA_INFO = "hassio_info" DATA_OS_INFO = "hassio_os_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" -HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) +DATA_ADDONS_STATS = "hassio_addons_stats" +HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -84,8 +98,8 @@ SERVICE_ADDON_STDIN = "addon_stdin" SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" -SERVICE_SNAPSHOT_FULL = "snapshot_full" -SERVICE_SNAPSHOT_PARTIAL = "snapshot_partial" +SERVICE_BACKUP_FULL = "backup_full" +SERVICE_BACKUP_PARTIAL = "backup_partial" SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" @@ -98,11 +112,11 @@ {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} ) -SCHEMA_SNAPSHOT_FULL = vol.Schema( +SCHEMA_BACKUP_FULL = vol.Schema( {vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string} ) -SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( +SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), @@ -110,7 +124,10 @@ ) SCHEMA_RESTORE_FULL = vol.Schema( - {vol.Required(ATTR_SNAPSHOT): cv.slug, vol.Optional(ATTR_PASSWORD): cv.string} + { + vol.Required(ATTR_SLUG): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, + } ) SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( @@ -122,31 +139,47 @@ ) +class APIEndpointSettings(NamedTuple): + """Settings for API endpoint.""" + + command: str + schema: vol.Schema + timeout: int | None = 60 + pass_data: bool = False + + MAP_SERVICE_API = { - SERVICE_ADDON_START: ("/addons/{addon}/start", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STOP: ("/addons/{addon}/stop", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_RESTART: ("/addons/{addon}/restart", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_UPDATE: ("/addons/{addon}/update", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), - SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), - SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), - SERVICE_SNAPSHOT_FULL: ("/snapshots/new/full", SCHEMA_SNAPSHOT_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: ( - "/snapshots/new/partial", - SCHEMA_SNAPSHOT_PARTIAL, - 300, + SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), + SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), + SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), + SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON), + SERVICE_ADDON_STDIN: APIEndpointSettings( + "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN + ), + SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA), + SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA), + SERVICE_BACKUP_FULL: APIEndpointSettings( + "/backups/new/full", + SCHEMA_BACKUP_FULL, + None, True, ), - SERVICE_RESTORE_FULL: ( - "/snapshots/{snapshot}/restore/full", + SERVICE_BACKUP_PARTIAL: APIEndpointSettings( + "/backups/new/partial", + SCHEMA_BACKUP_PARTIAL, + None, + True, + ), + SERVICE_RESTORE_FULL: APIEndpointSettings( + "/backups/{slug}/restore/full", SCHEMA_RESTORE_FULL, - 300, + None, True, ), - SERVICE_RESTORE_PARTIAL: ( - "/snapshots/{snapshot}/restore/partial", + SERVICE_RESTORE_PARTIAL: APIEndpointSettings( + "/backups/{slug}/restore/partial", SCHEMA_RESTORE_PARTIAL, - 300, + None, True, ), } @@ -220,6 +253,18 @@ async def async_start_addon(hass: HomeAssistant, slug: str) -> dict: return await hassio.send_command(command, timeout=60) +@bind_hass +@api_data +async def async_restart_addon(hass: HomeAssistant, slug: str) -> dict: + """Restart add-on. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = f"/addons/{slug}/restart" + return await hassio.send_command(command, timeout=None) + + @bind_hass @api_data async def async_stop_addon(hass: HomeAssistant, slug: str) -> dict: @@ -257,16 +302,16 @@ async def async_get_addon_discovery_info(hass: HomeAssistant, slug: str) -> dict @bind_hass @api_data -async def async_create_snapshot( +async def async_create_backup( hass: HomeAssistant, payload: dict, partial: bool = False ) -> dict: - """Create a full or partial snapshot. + """Create a full or partial backup. The caller of the function should handle HassioAPIError. """ hassio = hass.data[DOMAIN] - snapshot_type = "partial" if partial else "full" - command = f"/snapshots/new/{snapshot_type}" + backup_type = "partial" if partial else "full" + command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -290,6 +335,16 @@ def get_host_info(hass): return hass.data.get(DATA_HOST_INFO) +@callback +@bind_hass +def get_store(hass): + """Return store information. + + Async friendly. + """ + return hass.data.get(DATA_STORE) + + @callback @bind_hass def get_supervisor_info(hass): @@ -300,6 +355,16 @@ def get_supervisor_info(hass): return hass.data.get(DATA_SUPERVISOR_INFO) +@callback +@bind_hass +def get_addons_stats(hass): + """Return Addons stats. + + Async friendly. + """ + return hass.data.get(DATA_ADDONS_STATS) + + @callback @bind_hass def get_os_info(hass): @@ -338,7 +403,7 @@ def get_supervisor_ip(): return os.environ["SUPERVISOR"].partition(":")[0] -async def async_setup(hass: HomeAssistant, config: Config) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up the Hass.io component.""" # Check local setup for env in ("HASSIO", "HASSIO_TOKEN"): @@ -358,12 +423,10 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool: # noqa: C90 hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) if not await hassio.is_connected(): - _LOGGER.warning("Not connected with Hass.io / system too busy!") + _LOGGER.warning("Not connected with the supervisor / system too busy!") store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - data = await store.async_load() - - if data is None: + if (data := await store.async_load()) is None: data = {} refresh_token = None @@ -376,8 +439,14 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool: # noqa: C90 if not user.is_admin: await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) + # Migrate old name + if user.name == "Hass.io": + await hass.auth.async_update_user(user, name=HASSIO_USER_NAME) + if refresh_token is None: - user = await hass.auth.async_create_system_user("Hass.io", [GROUP_ID_ADMIN]) + user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) refresh_token = await hass.auth.async_create_refresh_token(user) data["hassio_user"] = user.id await store.async_save(data) @@ -394,8 +463,6 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool: # noqa: C90 await hass.components.panel_custom.async_register_panel( frontend_url_path="hassio", webcomponent_name="hassio-main", - sidebar_title="Supervisor", - sidebar_icon="hass:home-assistant", js_url="/api/hassio/app/entrypoint.js", embed_iframe=True, require_admin=True, @@ -423,58 +490,86 @@ async def push_config(_): async def async_service_handler(service): """Handle service calls for Hass.io.""" - api_command = MAP_SERVICE_API[service.service][0] + api_endpoint = MAP_SERVICE_API[service.service] + data = service.data.copy() addon = data.pop(ATTR_ADDON, None) - snapshot = data.pop(ATTR_SNAPSHOT, None) + slug = data.pop(ATTR_SLUG, None) payload = None # Pass data to Hass.io API if service.service == SERVICE_ADDON_STDIN: payload = data[ATTR_INPUT] - elif MAP_SERVICE_API[service.service][3]: + elif api_endpoint.pass_data: payload = data # Call API try: await hassio.send_command( - api_command.format(addon=addon, snapshot=snapshot), + api_endpoint.command.format(addon=addon, slug=slug), payload=payload, - timeout=MAP_SERVICE_API[service.service][2], + timeout=api_endpoint.timeout, ) - except HassioAPIError as err: - _LOGGER.error("Error on Hass.io API: %s", err) + except HassioAPIError: + # The exceptions are logged properly in hassio.send_command + pass for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings[1] + DOMAIN, service, async_service_handler, schema=settings.schema ) + async def update_addon_stats(slug): + """Update single addon stats.""" + stats = await hassio.get_addon_stats(slug) + return (slug, stats) + async def update_info_data(now): """Update last available supervisor information.""" + try: - hass.data[DATA_INFO] = await hassio.get_info() - hass.data[DATA_HOST_INFO] = await hassio.get_host_info() - hass.data[DATA_CORE_INFO] = await hassio.get_core_info() - hass.data[DATA_SUPERVISOR_INFO] = await hassio.get_supervisor_info() - hass.data[DATA_OS_INFO] = await hassio.get_os_info() + ( + hass.data[DATA_INFO], + hass.data[DATA_HOST_INFO], + hass.data[DATA_STORE], + hass.data[DATA_CORE_INFO], + hass.data[DATA_SUPERVISOR_INFO], + hass.data[DATA_OS_INFO], + ) = await asyncio.gather( + hassio.get_info(), + hassio.get_host_info(), + hassio.get_store(), + hassio.get_core_info(), + hassio.get_supervisor_info(), + hassio.get_os_info(), + ) + + addons = [ + addon + for addon in hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + if addon[ATTR_STATE] == ATTR_STARTED + ] + stats_data = await asyncio.gather( + *[update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + ) + hass.data[DATA_ADDONS_STATS] = dict(stats_data) + if ADDONS_COORDINATOR in hass.data: await hass.data[ADDONS_COORDINATOR].async_refresh() except HassioAPIError as err: - _LOGGER.warning("Can't read last version: %s", err) + _LOGGER.warning("Can't read Supervisor data: %s", err) hass.helpers.event.async_track_point_in_utc_time( update_info_data, utcnow() + HASSIO_UPDATE_INTERVAL ) - # Fetch last version + # Fetch data await update_info_data(None) async def async_handle_core_service(call): """Service handler for handling core services.""" - if ( - call.service in SHUTDOWN_SERVICES - and await recorder.async_migration_in_progress(hass) + if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( + hass ): _LOGGER.error( "The system cannot %s while a database upgrade is in progress", @@ -565,17 +660,17 @@ def async_register_addons_in_dev_reg( ) -> None: """Register addons in the device registry.""" for addon in addons: - params = { - "config_entry_id": entry_id, - "identifiers": {(DOMAIN, addon[ATTR_SLUG])}, - "model": "Home Assistant Add-on", - "sw_version": addon[ATTR_VERSION], - "name": addon[ATTR_NAME], - "entry_type": ATTR_SERVICE, - } + params = DeviceInfo( + identifiers={(DOMAIN, addon[ATTR_SLUG])}, + model=SupervisorEntityModel.ADDON, + sw_version=addon[ATTR_VERSION], + name=addon[ATTR_NAME], + entry_type=DeviceEntryType.SERVICE, + configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}", + ) if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL): - params["manufacturer"] = manufacturer - dev_reg.async_get_or_create(**params) + params[ATTR_MANUFACTURER] = manufacturer + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) @callback @@ -583,22 +678,19 @@ def async_register_os_in_dev_reg( entry_id: str, dev_reg: DeviceRegistry, os_dict: dict[str, Any] ) -> None: """Register OS in the device registry.""" - params = { - "config_entry_id": entry_id, - "identifiers": {(DOMAIN, "OS")}, - "manufacturer": "Home Assistant", - "model": "Home Assistant Operating System", - "sw_version": os_dict[ATTR_VERSION], - "name": "Home Assistant Operating System", - "entry_type": ATTR_SERVICE, - } - dev_reg.async_get_or_create(**params) + params = DeviceInfo( + identifiers={(DOMAIN, "OS")}, + manufacturer="Home Assistant", + model=SupervisorEntityModel.OS, + sw_version=os_dict[ATTR_VERSION], + name="Home Assistant Operating System", + entry_type=DeviceEntryType.SERVICE, + ) + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) @callback -def async_remove_addons_from_dev_reg( - dev_reg: DeviceRegistry, addons: list[dict[str, Any]] -) -> None: +def async_remove_addons_from_dev_reg(dev_reg: DeviceRegistry, addons: set[str]) -> None: """Remove addons from the device registry.""" for addon_slug in addons: if dev := dev_reg.async_get_device({(DOMAIN, addon_slug)}): @@ -626,10 +718,24 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" new_data = {} - addon_data = get_supervisor_info(self.hass) + supervisor_info = get_supervisor_info(self.hass) + addons_stats = get_addons_stats(self.hass) + store_data = get_store(self.hass) + + repositories = { + repo[ATTR_SLUG]: repo[ATTR_NAME] + for repo in store_data.get("repositories", []) + } - new_data["addons"] = { - addon[ATTR_SLUG]: addon for addon in addon_data.get("addons", []) + new_data[DATA_KEY_ADDONS] = { + addon[ATTR_SLUG]: { + **addon, + **((addons_stats or {}).get(addon[ATTR_SLUG], {})), + ATTR_REPOSITORY: repositories.get( + addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "") + ), + } + for addon in supervisor_info.get("addons", []) } if self.is_hass_os: new_data["os"] = get_os_info(self.hass) @@ -637,22 +743,29 @@ async def _async_update_data(self) -> dict[str, Any]: # If this is the initial refresh, register all addons and return the dict if not self.data: async_register_addons_in_dev_reg( - self.entry_id, self.dev_reg, new_data["addons"].values() + self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values() ) if self.is_hass_os: async_register_os_in_dev_reg( self.entry_id, self.dev_reg, new_data["os"] ) - return new_data # Remove add-ons that are no longer installed from device registry - if removed_addons := list(set(self.data["addons"]) - set(new_data["addons"])): - async_remove_addons_from_dev_reg(self.dev_reg, removed_addons) + supervisor_addon_devices = { + list(device.identifiers)[0][1] + for device in self.dev_reg.devices.values() + if self.entry_id in device.config_entries + and device.model == SupervisorEntityModel.ADDON + } + if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]): + async_remove_addons_from_dev_reg(self.dev_reg, stale_addons) # If there are new add-ons, we should reload the config entry so we can # create new devices and entities. We can return an empty dict because # coordinator will be recreated. - if list(set(new_data["addons"]) - set(self.data["addons"])): + if self.data and set(new_data[DATA_KEY_ADDONS]) - set( + self.data[DATA_KEY_ADDONS] + ): self.hass.async_create_task( self.hass.config_entries.async_reload(self.entry_id) ) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index d540479d7792a..41107a6fa55a9 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -1,11 +1,12 @@ """Implement the Ingress Panel feature for Hass.io Add-ons.""" import asyncio +from http import HTTPStatus import logging from aiohttp import web from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ATTR_ICON, HTTP_BAD_REQUEST +from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_PANELS, ATTR_TITLE @@ -20,8 +21,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): hass.http.register_view(hassio_addon_panel) # If panels are exists - panels = await hassio_addon_panel.get_panels() - if not panels: + if not (panels := await hassio_addon_panel.get_panels()): return # Register available panels @@ -53,7 +53,7 @@ async def post(self, request, addon): # Panel exists for add-on slug if addon not in panels or not panels[addon][ATTR_ENABLE]: _LOGGER.error("Panel is not enable for %s", addon) - return web.Response(status=HTTP_BAD_REQUEST) + return web.Response(status=HTTPStatus.BAD_REQUEST) data = panels[addon] # Register panel diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 6c9b36fb3a071..2d76a758096ce 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -1,4 +1,5 @@ """Implement the auth feature from Hass.io for Add-ons.""" +from http import HTTPStatus from ipaddress import ip_address import logging import os @@ -12,7 +13,6 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import HTTP_OK from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -34,7 +34,7 @@ def async_setup_auth_view(hass: HomeAssistant, user: User): class HassIOBaseAuth(HomeAssistantView): """Hass.io view to handle auth requests.""" - def __init__(self, hass: HomeAssistant, user: User): + def __init__(self, hass: HomeAssistant, user: User) -> None: """Initialize WebView.""" self.hass = hass self.user = user @@ -83,7 +83,7 @@ async def post(self, request, data): except auth_ha.InvalidAuth: raise HTTPNotFound() from None - return web.Response(status=HTTP_OK) + return web.Response(status=HTTPStatus.OK) class HassIOPasswordReset(HassIOBaseAuth): @@ -113,4 +113,4 @@ async def post(self, request, data): except auth_ha.InvalidUser as err: raise HTTPNotFound() from err - return web.Response(status=HTTP_OK) + return web.Response(status=HTTPStatus.OK) diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 01930b5ec0e9d..b5525fe9ce4a0 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -1,16 +1,55 @@ """Binary sensor platform for Hass.io addons.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ADDONS_COORDINATOR -from .const import ATTR_UPDATE_AVAILABLE +from .const import ( + ATTR_STARTED, + ATTR_STATE, + ATTR_UPDATE_AVAILABLE, + DATA_KEY_ADDONS, + DATA_KEY_OS, +) from .entity import HassioAddonEntity, HassioOSEntity +@dataclass +class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): + """Hassio binary sensor entity description.""" + + target: str | None = None + + +COMMON_ENTITY_DESCRIPTIONS = ( + HassioBinarySensorEntityDescription( + device_class=BinarySensorDeviceClass.UPDATE, + entity_registry_enabled_default=False, + key=ATTR_UPDATE_AVAILABLE, + name="Update Available", + ), +) + +ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( + HassioBinarySensorEntityDescription( + device_class=BinarySensorDeviceClass.RUNNING, + entity_registry_enabled_default=False, + key=ATTR_STATE, + name="Running", + target=ATTR_STARTED, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -19,26 +58,44 @@ async def async_setup_entry( """Binary sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] - entities = [ - HassioAddonBinarySensor( - coordinator, addon, ATTR_UPDATE_AVAILABLE, "Update Available" - ) - for addon in coordinator.data["addons"].values() - ] + entities = [] + + for entity_description in ADDON_ENTITY_DESCRIPTIONS: + for addon in coordinator.data[DATA_KEY_ADDONS].values(): + entities.append( + HassioAddonBinarySensor( + addon=addon, + coordinator=coordinator, + entity_description=entity_description, + ) + ) + if coordinator.is_hass_os: - entities.append( - HassioOSBinarySensor(coordinator, ATTR_UPDATE_AVAILABLE, "Update Available") - ) + for entity_description in COMMON_ENTITY_DESCRIPTIONS: + entities.append( + HassioOSBinarySensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + async_add_entities(entities) class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): - """Binary sensor to track whether an update is available for a Hass.io add-on.""" + """Binary sensor for Hass.io add-ons.""" + + entity_description: HassioBinarySensorEntityDescription @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.addon_info[self.attribute_name] + value = self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + self.entity_description.key + ] + if self.entity_description.target is None: + return value + return value == self.entity_description.target class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): @@ -47,4 +104,4 @@ class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.os_info[self.attribute_name] + return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 435d42349fd04..7cdc87708aee3 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,4 +1,5 @@ """Hass.io const variables.""" +from enum import Enum DOMAIN = "hassio" @@ -14,7 +15,6 @@ ATTR_INPUT = "input" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" -ATTR_SNAPSHOT = "snapshot" ATTR_TITLE = "title" ATTR_USERNAME = "username" ATTR_UUID = "uuid" @@ -39,10 +39,24 @@ EVENT_SUPERVISOR_EVENT = "supervisor_event" -# Add-on keys ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_UPDATE_AVAILABLE = "update_available" +ATTR_CPU_PERCENT = "cpu_percent" +ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" +ATTR_STATE = "state" +ATTR_STARTED = "started" ATTR_URL = "url" ATTR_REPOSITORY = "repository" + + +DATA_KEY_ADDONS = "addons" +DATA_KEY_OS = "os" + + +class SupervisorEntityModel(str, Enum): + """Supervisor entity model.""" + + ADDON = "Home Assistant Add-on" + OS = "Home Assistant Operating System" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index e7f8df3b61df6..587457f2ca2ff 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -1,6 +1,10 @@ """Implement the services discovery feature from Hass.io for Add-ons.""" +from __future__ import annotations + import asyncio +from dataclasses import dataclass import logging +from typing import Any from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable @@ -8,7 +12,8 @@ from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import BaseServiceInfo from .const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_UUID from .handler import HassioAPIError @@ -16,8 +21,15 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class HassioServiceInfo(BaseServiceInfo): + """Prepared info from hassio entries.""" + + config: dict[str, Any] + + @callback -def async_setup_discovery_view(hass: HomeAssistantView, hassio): +def async_setup_discovery_view(hass: HomeAssistant, hassio): """Discovery setup.""" hassio_discovery = HassIODiscovery(hass, hassio) hass.http.register_view(hassio_discovery) @@ -49,7 +61,7 @@ class HassIODiscovery(HomeAssistantView): name = "api:hassio_push:discovery" url = "/api/hassio_push/discovery/{uuid}" - def __init__(self, hass: HomeAssistantView, hassio): + def __init__(self, hass: HomeAssistant, hassio): """Initialize WebView.""" self.hass = hass self.hassio = hassio @@ -88,7 +100,9 @@ async def async_process_new(self, data): # Use config flow await self.hass.config_entries.flow.async_init( - service, context={"source": config_entries.SOURCE_HASSIO}, data=config_data + service, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo(config=config_data), ) async def async_process_del(self, data): diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 4885ba8979f6e..5dd41166c32ff 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -4,11 +4,11 @@ from typing import Any from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator -from .const import ATTR_SLUG +from .const import ATTR_SLUG, DATA_KEY_ADDONS, DATA_KEY_OS class HassioAddonEntity(CoordinatorEntity): @@ -17,42 +17,25 @@ class HassioAddonEntity(CoordinatorEntity): def __init__( self, coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, addon: dict[str, Any], - attribute_name: str, - sensor_name: str, ) -> None: """Initialize base entity.""" - self.addon_slug = addon[ATTR_SLUG] - self.addon_name = addon[ATTR_NAME] - self._data_key = "addons" - self.attribute_name = attribute_name - self.sensor_name = sensor_name super().__init__(coordinator) + self.entity_description = entity_description + self._addon_slug = addon[ATTR_SLUG] + self._attr_name = f"{addon[ATTR_NAME]}: {entity_description.name}" + self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon[ATTR_SLUG])}) @property - def addon_info(self) -> dict[str, Any]: - """Return add-on info.""" - return self.coordinator.data[self._data_key][self.addon_slug] - - @property - def name(self) -> str: - """Return entity name.""" - return f"{self.addon_name}: {self.sensor_name}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"{self.addon_slug}_{self.attribute_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return {"identifiers": {(DOMAIN, self.addon_slug)}} + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.entity_description.key + in self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug] + ) class HassioOSEntity(CoordinatorEntity): @@ -61,36 +44,19 @@ class HassioOSEntity(CoordinatorEntity): def __init__( self, coordinator: HassioDataUpdateCoordinator, - attribute_name: str, - sensor_name: str, + entity_description: EntityDescription, ) -> None: """Initialize base entity.""" - self._data_key = "os" - self.attribute_name = attribute_name - self.sensor_name = sensor_name super().__init__(coordinator) - - @property - def os_info(self) -> dict[str, Any]: - """Return OS info.""" - return self.coordinator.data[self._data_key] - - @property - def name(self) -> str: - """Return entity name.""" - return f"Home Assistant Operating System: {self.sensor_name}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"home_assistant_os_{self.attribute_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return {"identifiers": {(DOMAIN, "OS")}} + self.entity_description = entity_description + self._attr_name = f"Home Assistant Operating System: {entity_description.name}" + self._attr_unique_id = f"home_assistant_os_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "OS")}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.entity_description.key in self.coordinator.data[DATA_KEY_OS] + ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 301d353faf053..4a0312bcecb36 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -1,5 +1,6 @@ """Handler for Hass.io.""" import asyncio +from http import HTTPStatus import logging import os @@ -10,7 +11,7 @@ CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, ) -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_OK, SERVER_PORT +from homeassistant.const import SERVER_PORT from .const import X_HASSIO @@ -118,6 +119,22 @@ def get_addon_info(self, addon): """ return self.send_command(f"/addons/{addon}/info", method="get") + @api_data + def get_addon_stats(self, addon): + """Return stats for an Add-on. + + This method returns a coroutine. + """ + return self.send_command(f"/addons/{addon}/stats", method="get") + + @api_data + def get_store(self): + """Return data from the store. + + This method return a coroutine. + """ + return self.send_command("/store", method="get") + @api_data def get_ingress_panels(self): """Return data for Add-on ingress panels. @@ -209,7 +226,7 @@ async def send_command(self, command, method="post", payload=None, timeout=10): timeout=aiohttp.ClientTimeout(total=timeout), ) - if request.status not in (HTTP_OK, HTTP_BAD_REQUEST): + if request.status not in (HTTPStatus.OK, HTTPStatus.BAD_REQUEST): _LOGGER.error("%s return code %d", command, request.status) raise HassioAPIError() diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index e1bd1cb095c53..532b947ac4907 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -2,19 +2,25 @@ from __future__ import annotations import asyncio +from http import HTTPStatus import logging import os import re import aiohttp from aiohttp import web -from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE +from aiohttp.client import ClientTimeout +from aiohttp.hdrs import ( + CACHE_CONTROL, + CONTENT_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + TRANSFER_ENCODING, +) from aiohttp.web_exceptions import HTTPBadGateway -import async_timeout from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded -from homeassistant.const import HTTP_UNAUTHORIZED from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO @@ -29,20 +35,20 @@ r"|hassos/update/cli" r"|supervisor/update" r"|addons/[^/]+/(?:update|install|rebuild)" - r"|snapshots/.+/full" - r"|snapshots/.+/partial" - r"|snapshots/[^/]+/(?:upload|download)" + r"|backups/.+/full" + r"|backups/.+/partial" + r"|backups/[^/]+/(?:upload|download)" r")$" ) -NO_AUTH_ONBOARDING = re.compile( - r"^(?:" r"|supervisor/logs" r"|snapshots/[^/]+/.+" r")$" -) +NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$") NO_AUTH = re.compile( r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" ) +NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") + class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" @@ -51,7 +57,7 @@ class HassIOView(HomeAssistantView): url = "/api/hassio/{path:.+}" requires_auth = False - def __init__(self, host: str, websession: aiohttp.ClientSession): + def __init__(self, host: str, websession: aiohttp.ClientSession) -> None: """Initialize a Hass.io base view.""" self._host = host self._websession = websession @@ -62,7 +68,7 @@ async def _handle( """Route data to Hass.io.""" hass = request.app["hass"] if _need_auth(hass, path) and not request[KEY_AUTHENTICATED]: - return web.Response(status=HTTP_UNAUTHORIZED) + return web.Response(status=HTTPStatus.UNAUTHORIZED) return await self._command_proxy(path, request) @@ -72,49 +78,32 @@ async def _handle( async def _command_proxy( self, path: str, request: web.Request - ) -> web.Response | web.StreamResponse: + ) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. This method is a coroutine. """ - read_timeout = _get_timeout(path) - client_timeout = 10 - data = None headers = _init_header(request) - if path == "snapshots/new/upload": + if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary headers[ "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access - # Snapshots are big, so we need to adjust the allowed size - request._client_max_size = ( # pylint: disable=protected-access - MAX_UPLOAD_SIZE - ) - client_timeout = 300 - try: - with async_timeout.timeout(client_timeout): - data = await request.read() - - method = getattr(self._websession, request.method.lower()) - client = await method( - f"http://{self._host}/{path}", - data=data, + client = await self._websession.request( + method=request.method, + url=f"http://{self._host}/{path}", + params=request.query, + data=request.content, headers=headers, - timeout=read_timeout, + timeout=_get_timeout(path), ) - # Simple request - if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000: - # Return Response - body = await client.read() - return web.Response( - content_type=client.content_type, status=client.status, body=body - ) - # Stream response - response = web.StreamResponse(status=client.status, headers=client.headers) + response = web.StreamResponse( + status=client.status, headers=_response_header(client, path) + ) response.content_type = client.content_type await response.prepare(request) @@ -140,19 +129,38 @@ def _init_header(request: web.Request) -> dict[str, str]: } # Add user data - user = request.get("hass_user") - if user is not None: + if request.get("hass_user") is not None: headers[X_HASS_USER_ID] = request["hass_user"].id headers[X_HASS_IS_ADMIN] = str(int(request["hass_user"].is_admin)) return headers -def _get_timeout(path: str) -> int: +def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: + """Create response header.""" + headers = {} + + for name, value in response.headers.items(): + if name in ( + TRANSFER_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + CONTENT_ENCODING, + ): + continue + headers[name] = value + + if NO_STORE.match(path): + headers[CACHE_CONTROL] = "no-store, max-age=0" + + return headers + + +def _get_timeout(path: str) -> ClientTimeout: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): - return 0 - return 300 + return ClientTimeout(connect=10, total=None) + return ClientTimeout(connect=10, total=300) def _need_auth(hass, path: str) -> bool: diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 7519c8603988d..6935bbdc7da1c 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -7,8 +7,8 @@ import os import aiohttp -from aiohttp import hdrs, web -from aiohttp.web_exceptions import HTTPBadGateway +from aiohttp import ClientTimeout, hdrs, web +from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from homeassistant.components.http import HomeAssistantView @@ -35,7 +35,7 @@ class HassIOIngress(HomeAssistantView): url = "/api/hassio_ingress/{token}/{path:.*}" requires_auth = False - def __init__(self, host: str, websession: aiohttp.ClientSession): + def __init__(self, host: str, websession: aiohttp.ClientSession) -> None: """Initialize a Hass.io ingress view.""" self._host = host self._websession = websession @@ -117,7 +117,6 @@ async def _handle_request( ) -> web.Response | web.StreamResponse: """Ingress route for request.""" url = self._create_url(token, path) - data = await request.read() source_header = _init_header(request, token) async with self._websession.request( @@ -126,7 +125,8 @@ async def _handle_request( headers=source_header, params=request.query, allow_redirects=False, - data=data, + data=request.content, + timeout=ClientTimeout(total=None), ) as result: headers = _response_header(result) @@ -168,6 +168,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st if name in ( hdrs.CONTENT_LENGTH, hdrs.CONTENT_ENCODING, + hdrs.TRANSFER_ENCODING, hdrs.SEC_WEBSOCKET_EXTENSIONS, hdrs.SEC_WEBSOCKET_PROTOCOL, hdrs.SEC_WEBSOCKET_VERSION, @@ -184,7 +185,11 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st # Set X-Forwarded-For forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) - connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + if (peername := request.transport.get_extra_info("peername")) is None: + _LOGGER.error("Can't set forward_for header, missing peername") + raise HTTPBadRequest() + + connected_ip = ip_address(peername[0]) if forward_for: forward_for = f"{forward_for}, {connected_ip!s}" else: @@ -192,8 +197,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st headers[hdrs.X_FORWARDED_FOR] = forward_for # Set X-Forwarded-Host - forward_host = request.headers.get(hdrs.X_FORWARDED_HOST) - if not forward_host: + if not (forward_host := request.headers.get(hdrs.X_FORWARDED_HOST)): forward_host = request.host headers[hdrs.X_FORWARDED_HOST] = forward_host @@ -251,3 +255,5 @@ async def _websocket_forward(ws_from, ws_to): await ws_to.close(code=ws_to.close_code, message=msg.extra) except RuntimeError: _LOGGER.debug("Ingress Websocket runtime error") + except ConnectionResetError: + _LOGGER.debug("Ingress Websocket Connection Reset") diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index aaa5b3669ade8..cc0bcc77265eb 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -1,9 +1,16 @@ { "domain": "hassio", "name": "Home Assistant Supervisor", - "documentation": "https://www.home-assistant.io/hassio", - "dependencies": ["http"], - "after_dependencies": ["panel_custom"], - "codeowners": ["@home-assistant/supervisor"], - "iot_class": "local_polling" -} + "documentation": "https://www.home-assistant.io/integrations/hassio", + "dependencies": [ + "http" + ], + "after_dependencies": [ + "panel_custom" + ], + "codeowners": [ + "@home-assistant/supervisor" + ], + "iot_class": "local_polling", + "quality_scale": "internal" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index e81980d78e106..42be1ff4b0a04 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -1,15 +1,61 @@ """Sensor platform for Hass.io addons.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ADDONS_COORDINATOR -from .const import ATTR_VERSION, ATTR_VERSION_LATEST +from .const import ( + ATTR_CPU_PERCENT, + ATTR_MEMORY_PERCENT, + ATTR_VERSION, + ATTR_VERSION_LATEST, + DATA_KEY_ADDONS, + DATA_KEY_OS, +) from .entity import HassioAddonEntity, HassioOSEntity +COMMON_ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_VERSION, + name="Version", + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_VERSION_LATEST, + name="Newest Version", + ), +) + +ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_CPU_PERCENT, + name="CPU Percent", + icon="mdi:cpu-64-bit", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_MEMORY_PERCENT, + name="Memory Percent", + icon="mdi:memory", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + async def async_setup_entry( hass: HomeAssistant, @@ -21,16 +67,24 @@ async def async_setup_entry( entities = [] - for attribute_name, sensor_name in ( - (ATTR_VERSION, "Version"), - (ATTR_VERSION_LATEST, "Newest Version"), - ): - for addon in coordinator.data["addons"].values(): + for addon in coordinator.data[DATA_KEY_ADDONS].values(): + for entity_description in ADDON_ENTITY_DESCRIPTIONS: + entities.append( + HassioAddonSensor( + addon=addon, + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + if coordinator.is_hass_os: + for entity_description in OS_ENTITY_DESCRIPTIONS: entities.append( - HassioAddonSensor(coordinator, addon, attribute_name, sensor_name) + HassioOSSensor( + coordinator=coordinator, + entity_description=entity_description, + ) ) - if coordinator.is_hass_os: - entities.append(HassioOSSensor(coordinator, attribute_name, sensor_name)) async_add_entities(entities) @@ -39,15 +93,17 @@ class HassioAddonSensor(HassioAddonEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" @property - def state(self) -> str: - """Return state of entity.""" - return self.addon_info[self.attribute_name] + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + self.entity_description.key + ] class HassioOSSensor(HassioOSEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" @property - def state(self) -> str: - """Return state of entity.""" - return self.os_info[self.attribute_name] + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 0652b65d6e281..6b77a180c0972 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -66,14 +66,14 @@ host_shutdown: name: Poweroff the host system. description: Poweroff the host system. -snapshot_full: - name: Create a full snapshot. - description: Create a full snapshot. +backup_full: + name: Create a full backup. + description: Create a full backup. fields: name: name: Name - description: Optional or it will be the current date and time. - example: "Snapshot 1" + description: Optional (default = current date and time). + example: "Backup 1" selector: text: password: @@ -83,13 +83,13 @@ snapshot_full: selector: text: -snapshot_partial: - name: Create a partial snapshot. - description: Create a partial snapshot. +backup_partial: + name: Create a partial backup. + description: Create a partial backup. fields: addons: name: Add-ons - description: Optional list of addon slugs. + description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: @@ -101,8 +101,8 @@ snapshot_partial: object: name: name: Name - description: Optional or it will be the current date and time. - example: "Partial Snapshot 1" + description: Optional (default = current date and time). + example: "Partial backup 1" selector: text: password: @@ -111,3 +111,54 @@ snapshot_partial: example: "password" selector: text: + +restore_full: + name: Restore from full backup. + description: Restore from full backup. + fields: + slug: + name: Slug + required: true + description: Slug of backup to restore from. + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +restore_partial: + name: Restore from partial backup. + description: Restore from partial backup. + fields: + slug: + name: Slug + required: true + description: Slug of backup to restore from. + selector: + text: + homeassistant: + name: Home Assistant settings + description: Restore Home Assistant + selector: + boolean: + folders: + name: Folders + description: Optional list of directories. + example: ["homeassistant", "share"] + selector: + object: + addons: + name: Add-ons + description: Optional list of add-on slugs. + example: ["core_ssh", "core_samba", "core_mosquitto"] + selector: + object: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: diff --git a/homeassistant/components/hassio/translations/af.json b/homeassistant/components/hassio/translations/af.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/af.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/bg.json b/homeassistant/components/hassio/translations/bg.json index 91588c5529aa4..960dc53b5eaa2 100644 --- a/homeassistant/components/hassio/translations/bg.json +++ b/homeassistant/components/hassio/translations/bg.json @@ -1,3 +1,8 @@ { - "title": "Home Assistant Supervisor" + "system_health": { + "info": { + "disk_total": "\u0414\u0438\u0441\u043a \u043e\u0431\u0449\u043e", + "disk_used": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d \u0434\u0438\u0441\u043a" + } + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ca.json b/homeassistant/components/hassio/translations/ca.json index d2e712c230d6f..7813d970d0ec1 100644 --- a/homeassistant/components/hassio/translations/ca.json +++ b/homeassistant/components/hassio/translations/ca.json @@ -14,6 +14,5 @@ "update_channel": "Canal d'actualitzaci\u00f3", "version_api": "Versi\u00f3 d'APIs" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/cs.json b/homeassistant/components/hassio/translations/cs.json index eb64ed58baac0..97f844a8c8152 100644 --- a/homeassistant/components/hassio/translations/cs.json +++ b/homeassistant/components/hassio/translations/cs.json @@ -14,6 +14,5 @@ "update_channel": "Kan\u00e1l aktualizac\u00ed", "version_api": "Verze API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/cy.json b/homeassistant/components/hassio/translations/cy.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/cy.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/da.json b/homeassistant/components/hassio/translations/da.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/da.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/de.json b/homeassistant/components/hassio/translations/de.json index 19538e0b3e15b..99747512e97c4 100644 --- a/homeassistant/components/hassio/translations/de.json +++ b/homeassistant/components/hassio/translations/de.json @@ -14,6 +14,5 @@ "update_channel": "Update-Channel", "version_api": "Versions-API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/el.json b/homeassistant/components/hassio/translations/el.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/el.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/en.json b/homeassistant/components/hassio/translations/en.json index 16911be41109b..bb5f8e6f01a65 100644 --- a/homeassistant/components/hassio/translations/en.json +++ b/homeassistant/components/hassio/translations/en.json @@ -14,6 +14,5 @@ "update_channel": "Update Channel", "version_api": "Version API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/es-419.json b/homeassistant/components/hassio/translations/es-419.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/es-419.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/es.json b/homeassistant/components/hassio/translations/es.json index f3bdf14c4468d..da3730fa45bfe 100644 --- a/homeassistant/components/hassio/translations/es.json +++ b/homeassistant/components/hassio/translations/es.json @@ -14,6 +14,5 @@ "update_channel": "Canal de actualizaci\u00f3n", "version_api": "Versi\u00f3n del API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/et.json b/homeassistant/components/hassio/translations/et.json index 9d3ef08afbed7..4449c058498a8 100644 --- a/homeassistant/components/hassio/translations/et.json +++ b/homeassistant/components/hassio/translations/et.json @@ -14,6 +14,5 @@ "update_channel": "V\u00e4rskenduskanal", "version_api": "API versioon" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/eu.json b/homeassistant/components/hassio/translations/eu.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/eu.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/fa.json b/homeassistant/components/hassio/translations/fa.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/fa.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/fi.json b/homeassistant/components/hassio/translations/fi.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/fi.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/fr.json b/homeassistant/components/hassio/translations/fr.json index e4fe8a63bba3d..6e20e37a2b9e7 100644 --- a/homeassistant/components/hassio/translations/fr.json +++ b/homeassistant/components/hassio/translations/fr.json @@ -14,6 +14,5 @@ "update_channel": "Mise \u00e0 jour", "version_api": "Version API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/he.json b/homeassistant/components/hassio/translations/he.json index 80c1a0c48eea7..8926338221ad8 100644 --- a/homeassistant/components/hassio/translations/he.json +++ b/homeassistant/components/hassio/translations/he.json @@ -1,3 +1,18 @@ { - "title": "Supervisor" + "system_health": { + "info": { + "board": "\u05dc\u05d5\u05d7", + "disk_total": "\u05e1\u05d4\"\u05db \u05d3\u05d9\u05e1\u05e7", + "disk_used": "\u05d3\u05d9\u05e1\u05e7 \u05d1\u05e9\u05d9\u05de\u05d5\u05e9", + "docker_version": "\u05d2\u05d9\u05e8\u05e1\u05ea Docker", + "healthy": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea", + "host_os": "\u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4 \u05de\u05d0\u05e8\u05d7\u05ea", + "installed_addons": "\u05d4\u05e8\u05d7\u05d1\u05d5\u05ea \u05de\u05d5\u05ea\u05e7\u05e0\u05d5\u05ea", + "supervisor_api": "API \u05e9\u05dc \u05de\u05e4\u05e7\u05d7", + "supervisor_version": "\u05d2\u05d9\u05e8\u05e1\u05ea \u05de\u05e4\u05e7\u05d7", + "supported": "\u05e0\u05ea\u05de\u05da", + "update_channel": "\u05e2\u05e8\u05d5\u05e5 \u05e2\u05d3\u05db\u05d5\u05df", + "version_api": "\u05d2\u05e8\u05e1\u05ea API" + } + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/hr.json b/homeassistant/components/hassio/translations/hr.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/hr.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/hu.json b/homeassistant/components/hassio/translations/hu.json index e0fc98408d49a..64b0f26ae4631 100644 --- a/homeassistant/components/hassio/translations/hu.json +++ b/homeassistant/components/hassio/translations/hu.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "board": "Alaplap", "disk_total": "\u00d6sszes hely", "disk_used": "Felhaszn\u00e1lt hely", "docker_version": "Docker verzi\u00f3", @@ -13,6 +14,5 @@ "update_channel": "Friss\u00edt\u00e9si csatorna", "version_api": "API verzi\u00f3" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/hy.json b/homeassistant/components/hassio/translations/hy.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/hy.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/id.json b/homeassistant/components/hassio/translations/id.json index b95ffb35d81c9..b87e1b47c4470 100644 --- a/homeassistant/components/hassio/translations/id.json +++ b/homeassistant/components/hassio/translations/id.json @@ -14,6 +14,5 @@ "update_channel": "Kanal Pembaruan", "version_api": "API Versi" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/is.json b/homeassistant/components/hassio/translations/is.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/is.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/it.json b/homeassistant/components/hassio/translations/it.json index 86d573cba40ab..b601a11241da5 100644 --- a/homeassistant/components/hassio/translations/it.json +++ b/homeassistant/components/hassio/translations/it.json @@ -6,14 +6,13 @@ "disk_used": "Disco utilizzato", "docker_version": "Versione Docker", "healthy": "Integrit\u00e0", - "host_os": "Sistema Operativo Host", + "host_os": "Sistema operativo dell'host", "installed_addons": "Componenti aggiuntivi installati", - "supervisor_api": "API Supervisor", - "supervisor_version": "Versione Supervisor", + "supervisor_api": "API supervisore", + "supervisor_version": "Versione supervisore", "supported": "Supportato", "update_channel": "Canale di aggiornamento", "version_api": "Versione API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ja.json b/homeassistant/components/hassio/translations/ja.json index 91588c5529aa4..0f704ceba1912 100644 --- a/homeassistant/components/hassio/translations/ja.json +++ b/homeassistant/components/hassio/translations/ja.json @@ -1,3 +1,18 @@ { - "title": "Home Assistant Supervisor" + "system_health": { + "info": { + "board": "\u30dc\u30fc\u30c9", + "disk_total": "\u30c7\u30a3\u30b9\u30af\u5408\u8a08", + "disk_used": "\u4f7f\u7528\u6e08\u307f\u30c7\u30a3\u30b9\u30af", + "docker_version": "Docker\u306e\u30d0\u30fc\u30b8\u30e7\u30f3", + "healthy": "\u5143\u6c17", + "host_os": "\u30db\u30b9\u30c8\u30aa\u30da\u30ec\u30fc\u30c6\u30a3\u30f3\u30b0\u30b7\u30b9\u30c6\u30e0", + "installed_addons": "\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u6e08\u307f\u306e\u30a2\u30c9\u30aa\u30f3", + "supervisor_api": "Supervisor API", + "supervisor_version": "Supervisor\u306e\u30d0\u30fc\u30b8\u30e7\u30f3", + "supported": "\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u3059", + "update_channel": "\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u30c1\u30e3\u30f3\u30cd\u30eb", + "version_api": "\u30d0\u30fc\u30b8\u30e7\u30f3API" + } + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ko.json b/homeassistant/components/hassio/translations/ko.json index aba9a665f703e..2328011564978 100644 --- a/homeassistant/components/hassio/translations/ko.json +++ b/homeassistant/components/hassio/translations/ko.json @@ -14,6 +14,5 @@ "update_channel": "\uc5c5\ub370\uc774\ud2b8 \ucc44\ub110", "version_api": "\ubc84\uc804 API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/lb.json b/homeassistant/components/hassio/translations/lb.json index c0d0f42ed94c8..55f5c0e9b3a30 100644 --- a/homeassistant/components/hassio/translations/lb.json +++ b/homeassistant/components/hassio/translations/lb.json @@ -14,6 +14,5 @@ "update_channel": "Aktualis\u00e9ierungs Kanal", "version_api": "API Versioun" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/lt.json b/homeassistant/components/hassio/translations/lt.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/lt.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/lv.json b/homeassistant/components/hassio/translations/lv.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/lv.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/nl.json b/homeassistant/components/hassio/translations/nl.json index 7224857a10c6a..e5541ff1d009a 100644 --- a/homeassistant/components/hassio/translations/nl.json +++ b/homeassistant/components/hassio/translations/nl.json @@ -14,6 +14,5 @@ "update_channel": "Update kanaal", "version_api": "API Versie" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/nn.json b/homeassistant/components/hassio/translations/nn.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/nn.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/no.json b/homeassistant/components/hassio/translations/no.json index 9f0c5ba89b24a..30ff8b903a9e6 100644 --- a/homeassistant/components/hassio/translations/no.json +++ b/homeassistant/components/hassio/translations/no.json @@ -14,6 +14,5 @@ "update_channel": "Oppdater kanal", "version_api": "Versjon API" } - }, - "title": "Home Assistant veileder" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/pl.json b/homeassistant/components/hassio/translations/pl.json index 5266d640d7c44..2f6b5cab1dcc0 100644 --- a/homeassistant/components/hassio/translations/pl.json +++ b/homeassistant/components/hassio/translations/pl.json @@ -14,6 +14,5 @@ "update_channel": "Kana\u0142 aktualizacji", "version_api": "Wersja API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/pt-BR.json b/homeassistant/components/hassio/translations/pt-BR.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/pt-BR.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/pt.json b/homeassistant/components/hassio/translations/pt.json index 326560409e4ea..b05d2d02ecf5e 100644 --- a/homeassistant/components/hassio/translations/pt.json +++ b/homeassistant/components/hassio/translations/pt.json @@ -12,6 +12,5 @@ "supervisor_version": "Vers\u00e3o do Supervisor", "supported": "Suportado" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ro.json b/homeassistant/components/hassio/translations/ro.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/ro.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ru.json b/homeassistant/components/hassio/translations/ru.json index 56c3522ba3c29..f9572edcd6f31 100644 --- a/homeassistant/components/hassio/translations/ru.json +++ b/homeassistant/components/hassio/translations/ru.json @@ -14,6 +14,5 @@ "update_channel": "\u041a\u0430\u043d\u0430\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0439", "version_api": "\u0412\u0435\u0440\u0441\u0438\u044f API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/sk.json b/homeassistant/components/hassio/translations/sk.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/sk.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/sl.json b/homeassistant/components/hassio/translations/sl.json index cfc71ce0832e1..35aafe322bb33 100644 --- a/homeassistant/components/hassio/translations/sl.json +++ b/homeassistant/components/hassio/translations/sl.json @@ -13,6 +13,5 @@ "update_channel": "Posodobi kanal", "version_api": "API razli\u010dica" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/sv.json b/homeassistant/components/hassio/translations/sv.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/sv.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/th.json b/homeassistant/components/hassio/translations/th.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/th.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/tr.json b/homeassistant/components/hassio/translations/tr.json index 06a8d3fd66179..16504c32372e4 100644 --- a/homeassistant/components/hassio/translations/tr.json +++ b/homeassistant/components/hassio/translations/tr.json @@ -14,6 +14,5 @@ "update_channel": "Kanal\u0131 G\u00fcncelle", "version_api": "S\u00fcr\u00fcm API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/uk.json b/homeassistant/components/hassio/translations/uk.json index d25ad6e79795a..05f39e905b46a 100644 --- a/homeassistant/components/hassio/translations/uk.json +++ b/homeassistant/components/hassio/translations/uk.json @@ -14,6 +14,5 @@ "update_channel": "\u041a\u0430\u043d\u0430\u043b \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u044c", "version_api": "\u0412\u0435\u0440\u0441\u0456\u044f API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/vi.json b/homeassistant/components/hassio/translations/vi.json deleted file mode 100644 index 91588c5529aa4..0000000000000 --- a/homeassistant/components/hassio/translations/vi.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Home Assistant Supervisor" -} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/zh-Hans.json b/homeassistant/components/hassio/translations/zh-Hans.json index a48cbeb95a8b0..dd5ad2b4eae18 100644 --- a/homeassistant/components/hassio/translations/zh-Hans.json +++ b/homeassistant/components/hassio/translations/zh-Hans.json @@ -14,6 +14,5 @@ "update_channel": "\u66f4\u65b0\u901a\u9053", "version_api": "API \u7248\u672c" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/zh-Hant.json b/homeassistant/components/hassio/translations/zh-Hant.json index b8b3a1e7b930f..91c7f64e39c58 100644 --- a/homeassistant/components/hassio/translations/zh-Hant.json +++ b/homeassistant/components/hassio/translations/zh-Hant.json @@ -14,6 +14,5 @@ "update_channel": "\u66f4\u65b0\u983b\u9053", "version_api": "\u7248\u672c API" } - }, - "title": "Home Assistant Supervisor" + } } \ No newline at end of file diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index dfc2b7dc01dc5..e2ffff5e1e3f7 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -1,11 +1,13 @@ """Websocekt API handlers for the hassio integration.""" import logging +import re import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import Unauthorized import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -34,6 +36,11 @@ extra=vol.ALLOW_EXTRA, ) +# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` +WS_NO_ADMIN_ENDPOINTS = re.compile( + r"^(?:" r"|/ingress/(session|validate_session)" r"|/addons/[^/]+/info" r")$" +) + _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -79,7 +86,6 @@ async def websocket_supervisor_event( connection.send_result(msg[WS_ID]) -@websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( { @@ -94,6 +100,10 @@ async def websocket_supervisor_api( hass: HomeAssistant, connection: ActiveConnection, msg: dict ): """Websocket handler to call Supervisor API.""" + if not connection.user.is_admin and not WS_NO_ADMIN_ENDPOINTS.match( + msg[ATTR_ENDPOINT] + ): + raise Unauthorized() supervisor: HassIO = hass.data[DOMAIN] try: result = await supervisor.send_command( diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 55b369c2fde8f..cdcc526c8d841 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -1,5 +1,6 @@ """Support for haveibeenpwned (email breaches) sensor.""" from datetime import timedelta +from http import HTTPStatus import logging from aiohttp.hdrs import USER_AGENT @@ -7,13 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_API_KEY, - CONF_EMAIL, - HTTP_NOT_FOUND, - HTTP_OK, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_EMAIL import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time from homeassistant.util import Throttle @@ -69,12 +64,12 @@ def name(self): return f"Breaches {self._email}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -163,7 +158,7 @@ def update(self, **kwargs): _LOGGER.error("Failed fetching data for %s", self._email) return - if req.status_code == HTTP_OK: + if req.status_code == HTTPStatus.OK: self.data[self._email] = sorted( req.json(), key=lambda k: k["AddedDate"], reverse=True ) @@ -172,7 +167,7 @@ def update(self, **kwargs): # the forced updates try this current email again self.set_next_email() - elif req.status_code == HTTP_NOT_FOUND: + elif req.status_code == HTTPStatus.NOT_FOUND: self.data[self._email] = [] # only goto next email if we had data so that diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 4376c7f128985..da916d71e23fc 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -6,7 +6,11 @@ import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) from homeassistant.const import ( CONF_DISKS, CONF_HOST, @@ -62,29 +66,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class HddTempSensor(SensorEntity): """Representation of a HDDTemp sensor.""" + _attr_device_class = SensorDeviceClass.TEMPERATURE + def __init__(self, name, disk, hddtemp): """Initialize a HDDTemp sensor.""" self.hddtemp = hddtemp self.disk = disk - self._name = f"{name} {disk}" - self._state = None + self._attr_name = f"{name} {disk}" self._details = None - self._unit = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit @property def extra_state_attributes(self): @@ -98,13 +87,13 @@ def update(self): if self.hddtemp.data and self.disk in self.hddtemp.data: self._details = self.hddtemp.data[self.disk].split("|") - self._state = self._details[2] + self._attr_native_value = self._details[2] if self._details is not None and self._details[3] == "F": - self._unit = TEMP_FAHRENHEIT + self._attr_native_unit_of_measurement = TEMP_FAHRENHEIT else: - self._unit = TEMP_CELSIUS + self._attr_native_unit_of_measurement = TEMP_CELSIUS else: - self._state = None + self._attr_native_value = None class HddTempData: diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 7182642904045..9dfd68d4a4f4f 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -1,8 +1,10 @@ """Support for HDMI CEC.""" -from collections import defaultdict -from functools import partial, reduce +from __future__ import annotations + +from functools import reduce import logging import multiprocessing +from typing import Any from pycec.cec import CecAdapter from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand @@ -40,10 +42,11 @@ STATE_PLAYING, STATE_UNAVAILABLE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType DOMAIN = "hdmi_cec" @@ -66,8 +69,6 @@ 5: ICON_AUDIO, } -CEC_DEVICES = defaultdict(list) - CMD_UP = "up" CMD_DOWN = "down" CMD_MUTE = "mute" @@ -134,7 +135,7 @@ SERVICE_STANDBY = "standby" # pylint: disable=unnecessary-lambda -DEVICE_SCHEMA = vol.Schema( +DEVICE_SCHEMA: vol.Schema = vol.Schema( { vol.All(cv.positive_int): vol.Any( lambda devices: DEVICE_SCHEMA(devices), cv.string @@ -187,7 +188,7 @@ def parse_mapping(mapping, parents=None): yield (val, pad_physical_address(cur)) -def setup(hass: HomeAssistant, base_config): # noqa: C901 +def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 """Set up the CEC capability.""" # Parse configuration into a dict of device name to physical address @@ -222,9 +223,12 @@ def _adapter_watchdog(now=None): hass.bus.fire(EVENT_HDMI_CEC_UNAVAILABLE) adapter.init() - hdmi_network.set_initialized_callback( - partial(event.async_call_later, hass, WATCHDOG_INTERVAL, _adapter_watchdog) - ) + @callback + def _async_initialized_callback(*_: Any): + """Add watchdog on initialization.""" + return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + + hdmi_network.set_initialized_callback(_async_initialized_callback) def _volume(call): """Increase/decrease volume and mute/unmute system.""" @@ -297,8 +301,7 @@ def _power_on(call): def _select_device(call): """Select the active device.""" - addr = call.data[ATTR_DEVICE] - if not addr: + if not (addr := call.data[ATTR_DEVICE]): _LOGGER.error("Device not found: %s", call.data[ATTR_DEVICE]) return if addr in device_aliases: @@ -372,13 +375,32 @@ def _start_cec(callback_event): class CecEntity(Entity): """Representation of a HDMI CEC device entity.""" + _attr_should_poll = False + def __init__(self, device, logical) -> None: """Initialize the device.""" self._device = device - self._icon = None - self._state = None + self._state: str | None = None self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) + self._set_attr_name() + if self._device.type in ICONS_BY_TYPE: + self._attr_icon = ICONS_BY_TYPE[self._device.type] + else: + self._attr_icon = ICON_UNKNOWN + + def _set_attr_name(self): + """Set name.""" + if ( + self._device.osd_name is not None + and self.vendor_name is not None + and self.vendor_name != "Unknown" + ): + self._attr_name = f"{self.vendor_name} {self._device.osd_name}" + elif self._device.osd_name is None: + self._attr_name = f"{self._device.type_name} {self._logical_address}" + else: + self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})" def _hdmi_cec_unavailable(self, callback_event): # Change state to unavailable. Without this, entity would remain in @@ -413,31 +435,6 @@ def _update(self, device=None): """Device status changed, schedule an update.""" self.schedule_update_ha_state(True) - @property - def should_poll(self): - """ - Return false. - - CecEntity.update() is called by the HDMI network when there is new data. - """ - return False - - @property - def name(self): - """Return the name of the device.""" - return ( - f"{self.vendor_name} {self._device.osd_name}" - if ( - self._device.osd_name is not None - and self.vendor_name is not None - and self.vendor_name != "Unknown" - ) - else "%s %d" % (self._device.type_name, self._logical_address) - if self._device.osd_name is None - else "%s %d (%s)" - % (self._device.type_name, self._logical_address, self._device.osd_name) - ) - @property def vendor_id(self): """Return the ID of the device's vendor.""" @@ -463,17 +460,6 @@ def type_id(self): """Return the type ID of device.""" return self._device.type - @property - def icon(self): - """Return the icon for device by its type.""" - return ( - self._icon - if self._icon is not None - else ICONS_BY_TYPE.get(self._device.type) - if self._device.type in ICONS_BY_TYPE - else ICON_UNKNOWN - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index c3cab6a8f981e..1a5b3a6fc515f 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -1,4 +1,6 @@ """Support for HDMI CEC devices as media players.""" +from __future__ import annotations + import logging from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand @@ -149,7 +151,7 @@ def volume_down(self): self.send_keypress(KEY_VOLUME_DOWN) @property - def state(self) -> str: + def state(self) -> str | None: """Cache state of device.""" return self._state diff --git a/homeassistant/components/hdmi_cec/services.yaml b/homeassistant/components/hdmi_cec/services.yaml index aa85ffb0214e0..943450a6796f7 100644 --- a/homeassistant/components/hdmi_cec/services.yaml +++ b/homeassistant/components/hdmi_cec/services.yaml @@ -1,45 +1,84 @@ power_on: + name: Power on description: Power on all devices which supports it. select_device: + name: Select device description: Select HDMI device. fields: device: + name: Device description: Address of device to select. Can be entity_id, physical address or alias from configuration. + required: true example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"' + selector: + text: send_command: + name: Send command description: Sends CEC command into HDMI CEC capable adapter. fields: att: + name: Att description: Optional parameters. example: [0, 2] + selector: + object: cmd: + name: Command description: 'Command itself. Could be decimal number or string with hexadeximal notation: "0x10".' example: 144 or "0x90" + selector: + text: dst: + name: Destination description: 'Destination for command. Could be decimal number or string with hexadeximal notation: "0x10".' example: 5 or "0x5" + selector: + text: raw: + name: Raw description: >- Raw CEC command in format "00:00:00:00" where first two digits are source and destination, second byte is command and optional other bytes are command parameters. If raw command specified, other params are ignored. example: '"10:36"' + selector: + text: src: + name: Source description: 'Source of command. Could be decimal number or string with hexadeximal notation: "0x10".' example: 12 or "0xc" + selector: + text: standby: + name: Standby description: Standby all devices which supports it. update: + name: Update description: Update devices state from network. volume: + name: Volume description: Increase or decrease volume of system. fields: down: + name: Down description: Decreases volume x levels. - example: 3 + selector: + number: + min: 1 + max: 100 mute: - description: Mutes audio system. Value should be on, off or toggle. - example: toggle + name: Mute + description: Mutes audio system. + selector: + select: + options: + - 'off' + - 'on' + - 'toggle' up: + name: Up description: Increases volume x levels. - example: 3 + selector: + number: + min: 1 + max: 100 diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index ea0cac76a991a..a268d7cfe7922 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -1,8 +1,10 @@ """Support for HDMI CEC devices as switches.""" +from __future__ import annotations + import logging from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY +from homeassistant.const import STATE_OFF, STATE_ON from . import ATTR_NEW, CecEntity @@ -55,13 +57,3 @@ def toggle(self, **kwargs): def is_on(self) -> bool: """Return True if entity is on.""" return self._state == STATE_ON - - @property - def is_standby(self): - """Return true if device is in standby.""" - return self._state == STATE_OFF or self._state == STATE_STANDBY - - @property - def state(self) -> str: - """Return the cached state of device.""" - return self._state diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 3a9bedbb376ac..bbe611c1db9de 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -8,12 +8,15 @@ from pyheos import Heos, HeosError, const as heos_const import voluptuous as vol -from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -23,12 +26,15 @@ COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER, + DATA_ENTITY_ID_MAP, + DATA_GROUP_MANAGER, DATA_SOURCE_MANAGER, DOMAIN, + SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED, ) -PLATFORMS = [MEDIA_PLAYER_DOMAIN] +PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = vol.Schema( vol.All( @@ -43,7 +49,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" if DOMAIN not in config: return True @@ -67,7 +73,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Initialize config entry which represents the HEOS controller.""" # For backwards compat if entry.unique_id is None: @@ -117,20 +123,27 @@ async def disconnect_controller(event): source_manager = SourceManager(favorites, inputs) source_manager.connect_update(hass, controller) + group_manager = GroupManager(hass, controller) + hass.data[DOMAIN] = { DATA_CONTROLLER_MANAGER: controller_manager, + DATA_GROUP_MANAGER: group_manager, DATA_SOURCE_MANAGER: source_manager, - MEDIA_PLAYER_DOMAIN: players, + Platform.MEDIA_PLAYER: players, + # Maps player_id to entity_id. Populated by the individual HeosMediaPlayer entities. + DATA_ENTITY_ID_MAP: {}, } services.register(hass, controller) hass.config_entries.async_setup_platforms(entry, PLATFORMS) + group_manager.connect_update() + entry.async_on_unload(group_manager.disconnect_update) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER] await controller_manager.disconnect() @@ -184,7 +197,7 @@ async def _controller_event(self, event, data): if event == heos_const.EVENT_PLAYERS_CHANGED: self.update_ids(data[heos_const.DATA_MAPPED_IDS]) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" @@ -196,7 +209,7 @@ async def _heos_event(self, event): except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) def update_ids(self, mapped_ids: dict[int, int]): """Update the IDs in the device and entity registry.""" @@ -214,7 +227,7 @@ def update_ids(self, mapped_ids: dict[int, int]): ) # update entity registry entity_id = self._entity_registry.async_get_entity_id( - MEDIA_PLAYER_DOMAIN, DOMAIN, str(old_id) + Platform.MEDIA_PLAYER, DOMAIN, str(old_id) ) if entity_id: self._entity_registry.async_update_entity( @@ -223,6 +236,148 @@ def update_ids(self, mapped_ids: dict[int, int]): _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) +class GroupManager: + """Class that manages HEOS groups.""" + + def __init__(self, hass, controller): + """Init group manager.""" + self._hass = hass + self._group_membership = {} + self._disconnect_player_added = None + self._initialized = False + self.controller = controller + + def _get_entity_id_to_player_id_map(self) -> dict: + """Return a dictionary which maps all HeosMediaPlayer entity_ids to player_ids.""" + return {v: k for k, v in self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()} + + async def async_get_group_membership(self): + """Return a dictionary which contains all group members for each player as entity_ids.""" + group_info_by_entity_id = { + player_entity_id: [] + for player_entity_id in self._get_entity_id_to_player_id_map() + } + + try: + groups = await self.controller.get_groups(refresh=True) + except HeosError as err: + _LOGGER.error("Unable to get HEOS group info: %s", err) + return group_info_by_entity_id + + player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP] + for group in groups.values(): + leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id) + member_entity_ids = [ + player_id_to_entity_id_map[member.player_id] + for member in group.members + if member.player_id in player_id_to_entity_id_map + ] + # Make sure the group leader is always the first element + group_info = [leader_entity_id, *member_entity_ids] + if leader_entity_id: + group_info_by_entity_id[leader_entity_id] = group_info + for member_entity_id in member_entity_ids: + group_info_by_entity_id[member_entity_id] = group_info + + return group_info_by_entity_id + + async def async_join_players( + self, leader_entity_id: str, member_entity_ids: list[str] + ) -> None: + """Create a group with `leader_entity_id` as group leader and `member_entity_ids` as member players.""" + entity_id_to_player_id_map = self._get_entity_id_to_player_id_map() + leader_id = entity_id_to_player_id_map.get(leader_entity_id) + if not leader_id: + raise HomeAssistantError( + f"The group leader {leader_entity_id} could not be resolved to a HEOS player." + ) + member_ids = [ + entity_id_to_player_id_map[member] + for member in member_entity_ids + if member in entity_id_to_player_id_map + ] + + try: + await self.controller.create_group(leader_id, member_ids) + except HeosError as err: + _LOGGER.error( + "Failed to group %s with %s: %s", + leader_entity_id, + member_entity_ids, + err, + ) + + async def async_unjoin_player(self, player_entity_id: str): + """Remove `player_entity_id` from any group.""" + player_id = self._get_entity_id_to_player_id_map().get(player_entity_id) + if not player_id: + raise HomeAssistantError( + f"The player {player_entity_id} could not be resolved to a HEOS player." + ) + + try: + await self.controller.create_group(player_id, []) + except HeosError as err: + _LOGGER.error( + "Failed to ungroup %s: %s", + player_entity_id, + err, + ) + + async def async_update_groups(self, event, data=None): + """Update the group membership from the controller.""" + if event in ( + heos_const.EVENT_GROUPS_CHANGED, + heos_const.EVENT_CONNECTED, + SIGNAL_HEOS_PLAYER_ADDED, + ): + groups = await self.async_get_group_membership() + if groups: + self._group_membership = groups + _LOGGER.debug("Groups updated due to change event") + # Let players know to update + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) + else: + _LOGGER.debug("Groups empty") + + def connect_update(self): + """Connect listener for when groups change and signal player update.""" + self.controller.dispatcher.connect( + heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups + ) + self.controller.dispatcher.connect( + heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups + ) + + # When adding a new HEOS player we need to update the groups. + async def _async_handle_player_added(): + # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been + # fully populated yet. This may only happen during early startup. + if ( + len(self._hass.data[DOMAIN][Platform.MEDIA_PLAYER]) + <= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]) + and not self._initialized + ): + self._initialized = True + await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) + + self._disconnect_player_added = async_dispatcher_connect( + self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added + ) + + @callback + def disconnect_update(self): + """Disconnect the listeners.""" + if self._disconnect_player_added: + self._disconnect_player_added() + self._disconnect_player_added = None + + @property + def group_membership(self): + """Provide access to group members for player entities.""" + return self._group_membership + + class SourceManager: """Class that manages sources for players.""" @@ -335,14 +490,13 @@ async def update_sources(event, data=None): heos_const.EVENT_USER_CHANGED, heos_const.EVENT_CONNECTED, ): - sources = await get_sources() # If throttled, it will return None - if sources: + if sources := await get_sources(): self.favorites, self.inputs = sources self.source_list = self._build_source_list() _LOGGER.debug("Sources updated due to changed event") # Let players know to update - hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) controller.dispatcher.connect( heos_const.SIGNAL_CONTROLLER_EVENT, update_sources diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index b84a3a23607b7..63a020c41d98f 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -1,4 +1,5 @@ """Config flow to configure Heos.""" +from typing import TYPE_CHECKING from urllib.parse import urlparse from pyheos import Heos, HeosError @@ -7,6 +8,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult from .const import DATA_DISCOVERED_HOSTS, DOMAIN @@ -21,11 +23,15 @@ class HeosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Heos device.""" # Store discovered host - hostname = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - friendly_name = f"{discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" + if TYPE_CHECKING: + assert discovery_info.ssdp_location + hostname = urlparse(discovery_info.ssdp_location).hostname + friendly_name = ( + f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" + ) self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname # Abort if other flows in progress or an entry already exists diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 503df40ccd498..636751d150b2b 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -5,9 +5,12 @@ COMMAND_RETRY_ATTEMPTS = 2 COMMAND_RETRY_DELAY = 1 DATA_CONTROLLER_MANAGER = "controller" +DATA_ENTITY_ID_MAP = "entity_id_map" +DATA_GROUP_MANAGER = "group_manager" DATA_SOURCE_MANAGER = "source_manager" DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" +SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added" SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 63b592f135994..27d172198e4a7 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -1,7 +1,6 @@ """Denon HEOS Media Player.""" from __future__ import annotations -from collections.abc import Sequence from functools import reduce, wraps import logging from operator import ior @@ -16,6 +15,7 @@ MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_URL, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_GROUPING, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -31,10 +31,21 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.util.dt import utcnow -from .const import DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED +from .const import ( + DATA_ENTITY_ID_MAP, + DATA_GROUP_MANAGER, + DATA_SOURCE_MANAGER, + DOMAIN as HEOS_DOMAIN, + SIGNAL_HEOS_PLAYER_ADDED, + SIGNAL_HEOS_UPDATED, +) BASE_SUPPORTED_FEATURES = ( SUPPORT_VOLUME_MUTE @@ -44,6 +55,7 @@ | SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA + | SUPPORT_GROUPING ) PLAY_STATE_TO_STATE = { @@ -98,6 +110,7 @@ def __init__(self, player): self._signals = [] self._supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None + self._group_manager = None async def _player_update(self, player_id, event): """Handle player attribute updated.""" @@ -121,16 +134,24 @@ async def async_added_to_hass(self): ) # Update state when heos changes self._signals.append( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_HEOS_UPDATED, self._heos_updated - ) + async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) ) + # Register this player's entity_id so it can be resolved by the group manager + self.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][ + self._player.player_id + ] = self.entity_id + async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) @log_command_error("clear playlist") async def async_clear_playlist(self): """Clear players playlist.""" await self._player.clear_queue() + @log_command_error("join_players") + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` as a player group with the current player.""" + await self._group_manager.async_join_players(self.entity_id, group_members) + @log_command_error("pause") async def async_media_pause(self): """Send pause command.""" @@ -239,9 +260,17 @@ async def async_update(self): current_support = [CONTROL_TO_SUPPORT[control] for control in controls] self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES) + if self._group_manager is None: + self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER] + if self._source_manager is None: self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] + @log_command_error("unjoin_player") + async def async_unjoin_player(self): + """Remove this player from any group.""" + await self._group_manager.async_unjoin_player(self.entity_id) + async def async_will_remove_from_hass(self): """Disconnect the device when removed.""" for signal_remove in self._signals: @@ -256,13 +285,13 @@ def available(self) -> bool: @property def device_info(self) -> DeviceInfo: """Get attributes about the device.""" - return { - "identifiers": {(HEOS_DOMAIN, self._player.player_id)}, - "name": self._player.name, - "model": self._player.model, - "manufacturer": "HEOS", - "sw_version": self._player.version, - } + return DeviceInfo( + identifiers={(HEOS_DOMAIN, self._player.player_id)}, + manufacturer="HEOS", + model=self._player.model, + name=self._player.name, + sw_version=self._player.version, + ) @property def extra_state_attributes(self) -> dict: @@ -275,6 +304,11 @@ def extra_state_attributes(self) -> dict: "media_type": self._player.now_playing_media.type, } + @property + def group_members(self) -> list[str]: + """List of players which are grouped together.""" + return self._group_manager.group_membership.get(self.entity_id, []) + @property def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" @@ -362,7 +396,7 @@ def source(self) -> str: return self._source_manager.get_current_source(self._player.now_playing_media) @property - def source_list(self) -> Sequence[str]: + def source_list(self) -> list[str]: """List of available input sources.""" return self._source_manager.source_list diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 0fe0518323f55..320ed297873c9 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,12 +1,22 @@ sign_in: + name: Sign in description: Sign the controller in to a HEOS account. fields: username: - description: The username or email of the HEOS account. [Required] + name: Username + description: The username or email of the HEOS account. + required: true example: "example@example.com" + selector: + text: password: - description: The password of the HEOS account. [Required] + name: Password + description: The password of the HEOS account. + required: true example: "password" + selector: + text: sign_out: + name: Sign out description: Sign the controller out of the HEOS account. diff --git a/homeassistant/components/heos/translations/he.json b/homeassistant/components/heos/translations/he.json new file mode 100644 index 0000000000000..2f2863169db14 --- /dev/null +++ b/homeassistant/components/heos/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + }, + "description": "\u05d0\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05e9\u05dd \u05d4\u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05d0\u05ea \u05db\u05ea\u05d5\u05d1\u05ea \u05d4-IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05df Heos (\u05e8\u05e6\u05d5\u05d9 \u05db\u05d6\u05d4 \u05d4\u05de\u05d7\u05d5\u05d1\u05e8 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d7\u05d5\u05d8 \u05dc\u05e8\u05e9\u05ea)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/hu.json b/homeassistant/components/heos/translations/hu.json index 2fbce1993cd03..8996c2a45300e 100644 --- a/homeassistant/components/heos/translations/hu.json +++ b/homeassistant/components/heos/translations/hu.json @@ -9,8 +9,10 @@ "step": { "user": { "data": { - "host": "Hoszt" - } + "host": "C\u00edm" + }, + "description": "K\u00e9rj\u00fck, adja meg egy Heos-eszk\u00f6z hosztnev\u00e9t vagy c\u00edm\u00e9t (lehet\u0151leg egy vezet\u00e9kkel a h\u00e1l\u00f3zathoz csatlakoztatott eszk\u00f6zt).", + "title": "Csatlakoz\u00e1s a Heos-hoz" } } } diff --git a/homeassistant/components/heos/translations/ja.json b/homeassistant/components/heos/translations/ja.json new file mode 100644 index 0000000000000..0464bf9897931 --- /dev/null +++ b/homeassistant/components/heos/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Heos\u30c7\u30d0\u30a4\u30b9(\u3067\u304d\u308c\u3070\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306b\u6709\u7dda\u3067\u63a5\u7d9a\u3055\u308c\u3066\u3044\u308b\u30c7\u30d0\u30a4\u30b9)\u306e\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Heos\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/tr.json b/homeassistant/components/heos/translations/tr.json index 4f1ad7759054c..7853bf8639e32 100644 --- a/homeassistant/components/heos/translations/tr.json +++ b/homeassistant/components/heos/translations/tr.json @@ -9,8 +9,10 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Sunucu" + }, + "description": "L\u00fctfen bir Heos cihaz\u0131n\u0131n ana bilgisayar ad\u0131n\u0131 veya IP adresini girin (tercihen a\u011fa kabloyla ba\u011fl\u0131 olan).", + "title": "Heos'a ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index c02456b2a3f66..e29c6682d8f59 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -5,13 +5,12 @@ import logging import herepy +from herepy.here_enum import RouteMode import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_MODE, CONF_API_KEY, CONF_MODE, @@ -22,12 +21,12 @@ EVENT_HOMEASSISTANT_START, TIME_MINUTES, ) -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers import location +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.typing import DiscoveryInfoType -import homeassistant.util.dt as dt +from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) @@ -200,14 +199,17 @@ def _are_valid_client_credentials(here_client: herepy.RoutingApi) -> bool: known_working_origin = [38.9, -77.04833] known_working_destination = [39.0, -77.1] try: - here_client.car_route( + here_client.public_transport_timetable( known_working_origin, known_working_destination, + True, [ - herepy.RouteMode[ROUTE_MODE_FASTEST], - herepy.RouteMode[TRAVEL_MODE_CAR], - herepy.RouteMode[TRAFFIC_MODE_DISABLED], + RouteMode[ROUTE_MODE_FASTEST], + RouteMode[TRAVEL_MODE_CAR], + RouteMode[TRAFFIC_MODE_ENABLED], ], + arrival=None, + departure="now", ) except herepy.InvalidCredentialsError: return False @@ -256,7 +258,7 @@ def delayed_sensor_update(event): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if self._here_data.traffic_mode and self._here_data.traffic_time is not None: return str(round(self._here_data.traffic_time / 60)) @@ -292,7 +294,7 @@ def extra_state_attributes( return res @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return self._unit_of_measurement @@ -313,65 +315,15 @@ async def async_update(self) -> None: """Update Sensor Information.""" # Convert device_trackers to HERE friendly location if self._origin_entity_id is not None: - self._here_data.origin = await self._get_location_from_entity( - self._origin_entity_id - ) + self._here_data.origin = find_coordinates(self.hass, self._origin_entity_id) if self._destination_entity_id is not None: - self._here_data.destination = await self._get_location_from_entity( - self._destination_entity_id + self._here_data.destination = find_coordinates( + self.hass, self._destination_entity_id ) await self.hass.async_add_executor_job(self._here_data.update) - async def _get_location_from_entity(self, entity_id: str) -> str | None: - """Get the location from the entity state or attributes.""" - entity = self.hass.states.get(entity_id) - - if entity is None: - _LOGGER.error("Unable to find entity %s", entity_id) - return None - - # Check if the entity has location attributes - if location.has_location(entity): - return self._get_location_from_attributes(entity) - - # Check if device is in a zone - zone_entity = self.hass.states.get(f"zone.{entity.state}") - if location.has_location(zone_entity): - _LOGGER.debug( - "%s is in %s, getting zone location", entity_id, zone_entity.entity_id - ) - return self._get_location_from_attributes(zone_entity) - - # Check if state is valid coordinate set - if self._entity_state_is_valid_coordinate_set(entity.state): - return entity.state - - _LOGGER.error( - "The state of %s is not a valid set of coordinates: %s", - entity_id, - entity.state, - ) - return None - - @staticmethod - def _entity_state_is_valid_coordinate_set(state: str) -> bool: - """Check that the given string is a valid set of coordinates.""" - schema = vol.Schema(cv.gps) - try: - coordinates = state.split(",") - schema(coordinates) - return True - except (vol.MultipleInvalid): - return False - - @staticmethod - def _get_location_from_attributes(entity: State) -> str: - """Get the lat/long string from an entities attributes.""" - attr = entity.attributes - return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" - class HERETravelTimeData: """HERETravelTime data object.""" @@ -416,11 +368,9 @@ def update(self) -> None: # Convert location to HERE friendly location destination = self.destination.split(",") origin = self.origin.split(",") - arrival = self.arrival - if arrival is not None: + if (arrival := self.arrival) is not None: arrival = convert_time_to_isodate(arrival) - departure = self.departure - if departure is not None: + if (departure := self.departure) is not None: departure = convert_time_to_isodate(departure) if departure is None and arrival is None: @@ -486,8 +436,7 @@ def _build_hass_attribution(source_attribution: dict) -> str | None: if suppliers is not None: supplier_titles = [] for supplier in suppliers: - title = supplier.get("title") - if title is not None: + if (title := supplier.get("title")) is not None: supplier_titles.append(title) joined_supplier_titles = ",".join(supplier_titles) attribution = f"With the support of {joined_supplier_titles}. All information is provided without warranty of any kind." diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 0d57278c826f5..90a8401a7c1f2 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -6,9 +6,8 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOTION, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import ( @@ -39,28 +38,28 @@ ATTR_DELAY = "delay" DEVICE_CLASS_MAP = { - "Motion": DEVICE_CLASS_MOTION, - "Line Crossing": DEVICE_CLASS_MOTION, - "Field Detection": DEVICE_CLASS_MOTION, + "Motion": BinarySensorDeviceClass.MOTION, + "Line Crossing": BinarySensorDeviceClass.MOTION, + "Field Detection": BinarySensorDeviceClass.MOTION, "Video Loss": None, - "Tamper Detection": DEVICE_CLASS_MOTION, + "Tamper Detection": BinarySensorDeviceClass.MOTION, "Shelter Alarm": None, "Disk Full": None, "Disk Error": None, - "Net Interface Broken": DEVICE_CLASS_CONNECTIVITY, - "IP Conflict": DEVICE_CLASS_CONNECTIVITY, + "Net Interface Broken": BinarySensorDeviceClass.CONNECTIVITY, + "IP Conflict": BinarySensorDeviceClass.CONNECTIVITY, "Illegal Access": None, "Video Mismatch": None, "Bad Video": None, - "PIR Alarm": DEVICE_CLASS_MOTION, - "Face Detection": DEVICE_CLASS_MOTION, - "Scene Change Detection": DEVICE_CLASS_MOTION, + "PIR Alarm": BinarySensorDeviceClass.MOTION, + "Face Detection": BinarySensorDeviceClass.MOTION, + "Scene Change Detection": BinarySensorDeviceClass.MOTION, "I/O": None, - "Unattended Baggage": DEVICE_CLASS_MOTION, - "Attended Baggage": DEVICE_CLASS_MOTION, + "Unattended Baggage": BinarySensorDeviceClass.MOTION, + "Attended Baggage": BinarySensorDeviceClass.MOTION, "Recording Failure": None, - "Exiting Region": DEVICE_CLASS_MOTION, - "Entering Region": DEVICE_CLASS_MOTION, + "Exiting Region": BinarySensorDeviceClass.MOTION, + "Entering Region": BinarySensorDeviceClass.MOTION, } CUSTOMIZE_SCHEMA = vol.Schema( diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index 9676870ecc48e..a8f8940114823 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -2,7 +2,7 @@ "domain": "hikvision", "name": "Hikvision", "documentation": "https://www.home-assistant.io/integrations/hikvision", - "requirements": ["pyhik==0.2.8"], + "requirements": ["pyhik==0.3.0"], "codeowners": ["@mezz64"], "iot_class": "local_push" } diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index 2f1f89cd26159..aa4a430e72e35 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -73,11 +73,6 @@ def name(self): """Return the name of the device if any.""" return self._name - @property - def state(self): - """Return the state of the device if any.""" - return self._state - @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py index 1134ac4181da4..bc38d1df53f60 100644 --- a/homeassistant/components/hisense_aehw4a1/__init__.py +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -8,14 +8,14 @@ from homeassistant import config_entries from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, Platform import homeassistant.helpers.config_validation as cv from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [CLIMATE_DOMAIN] +PLATFORMS = [Platform.CLIMATE] def coerce_ip(value): diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 23a3a0c1416d4..096b39bdbca3c 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -325,8 +325,7 @@ async def async_set_temperature(self, **kwargs): "AC at %s is off, could not set temperature", self._unique_id ) return - temp = kwargs.get(ATTR_TEMPERATURE) - if temp is not None: + if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: _LOGGER.debug("Setting temp of %s to %s", self._unique_id, temp) if self._preset_mode != PRESET_NONE: await self.async_set_preset_mode(PRESET_NONE) diff --git a/homeassistant/components/hisense_aehw4a1/translations/de.json b/homeassistant/components/hisense_aehw4a1/translations/de.json index 7c0bd96a9c9ce..03e15051eb06e 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/de.json +++ b/homeassistant/components/hisense_aehw4a1/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine Hisense AEH-W4A1-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/hisense_aehw4a1/translations/fr.json b/homeassistant/components/hisense_aehw4a1/translations/fr.json index 7fa1598fa76a8..72d3eec98dd2c 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/fr.json +++ b/homeassistant/components/hisense_aehw4a1/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique AEH-W4A1 trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de AEH-W4A1 est possible." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/hisense_aehw4a1/translations/he.json b/homeassistant/components/hisense_aehw4a1/translations/he.json new file mode 100644 index 0000000000000..380dbc5d7fcdc --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/it.json b/homeassistant/components/hisense_aehw4a1/translations/it.json index b6951f4d6478b..b8a12743cde59 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/it.json +++ b/homeassistant/components/hisense_aehw4a1/translations/it.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voui configurare Hisense AEH-W4A1" + "description": "Vuoi configurare Hisense AEH-W4A1" } } } diff --git a/homeassistant/components/hisense_aehw4a1/translations/ja.json b/homeassistant/components/hisense_aehw4a1/translations/ja.json new file mode 100644 index 0000000000000..75107c4c0fc43 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "Hisense AEH-W4A1\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/tr.json b/homeassistant/components/hisense_aehw4a1/translations/tr.json index a893a653a78c9..e2404aaa6865c 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/tr.json +++ b/homeassistant/components/hisense_aehw4a1/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index ac089bbb3b3a4..72bcba104a43b 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,6 +3,7 @@ from collections.abc import Iterable from datetime import datetime as dt, timedelta +from http import HTTPStatus import logging import time from typing import cast @@ -11,20 +12,18 @@ from sqlalchemy import not_, or_ import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.components.recorder import history -from homeassistant.components.recorder.models import States -from homeassistant.components.recorder.util import session_scope -from homeassistant.const import ( - CONF_DOMAINS, - CONF_ENTITIES, - CONF_EXCLUDE, - CONF_INCLUDE, - HTTP_BAD_REQUEST, +from homeassistant.components.recorder import history, models as history_models +from homeassistant.components.recorder.statistics import ( + list_statistic_ids, + statistics_during_period, ) +from homeassistant.components.recorder.util import session_scope +from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.deprecation import deprecated_function +from homeassistant.helpers.deprecation import deprecated_class, deprecated_function from homeassistant.helpers.entityfilter import ( CONF_ENTITY_GLOBS, INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -55,7 +54,7 @@ @deprecated_function("homeassistant.components.recorder.history.get_significant_states") def get_significant_states(hass, *args, **kwargs): - """Wrap _get_significant_states with a sql session.""" + """Wrap get_significant_states_with_session with an sql session.""" return history.get_significant_states(hass, *args, **kwargs) @@ -99,12 +98,83 @@ async def async_setup(hass, config): hass.http.register_view(HistoryPeriodView(filters, use_include_order)) hass.components.frontend.async_register_built_in_panel( - "history", "history", "hass:poll-box" + "history", "history", "hass:chart-box" ) + hass.components.websocket_api.async_register_command( + ws_get_statistics_during_period + ) + hass.components.websocket_api.async_register_command(ws_get_list_statistic_ids) return True +@deprecated_class("homeassistant.components.recorder.models.LazyState") +class LazyState(history_models.LazyState): + """A lazy version of core State.""" + + +@websocket_api.websocket_command( + { + vol.Required("type"): "history/statistics_during_period", + vol.Required("start_time"): str, + vol.Optional("end_time"): str, + vol.Optional("statistic_ids"): [str], + vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), + } +) +@websocket_api.async_response +async def ws_get_statistics_during_period( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Handle statistics websocket command.""" + start_time_str = msg["start_time"] + end_time_str = msg.get("end_time") + + if start_time := dt_util.parse_datetime(start_time_str): + start_time = dt_util.as_utc(start_time) + else: + connection.send_error(msg["id"], "invalid_start_time", "Invalid start_time") + return + + if end_time_str: + if end_time := dt_util.parse_datetime(end_time_str): + end_time = dt_util.as_utc(end_time) + else: + connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time") + return + else: + end_time = None + + statistics = await hass.async_add_executor_job( + statistics_during_period, + hass, + start_time, + end_time, + msg.get("statistic_ids"), + msg.get("period"), + ) + connection.send_result(msg["id"], statistics) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "history/list_statistic_ids", + vol.Optional("statistic_type"): vol.Any("sum", "mean"), + } +) +@websocket_api.async_response +async def ws_get_list_statistic_ids( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Fetch a list of available statistic_id.""" + statistic_ids = await hass.async_add_executor_job( + list_statistic_ids, + hass, + msg.get("statistic_type"), + ) + connection.send_result(msg["id"], statistic_ids) + + class HistoryPeriodView(HomeAssistantView): """Handle history period requests.""" @@ -122,11 +192,8 @@ async def get( ) -> web.Response: """Return history over a period of time.""" datetime_ = None - if datetime: - datetime_ = dt_util.parse_datetime(datetime) - - if datetime_ is None: - return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) + if datetime and (datetime_ := dt_util.parse_datetime(datetime)) is None: + return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) now = dt_util.utcnow() @@ -139,13 +206,11 @@ async def get( if start_time > now: return self.json([]) - end_time_str = request.query.get("end_time") - if end_time_str: - end_time = dt_util.parse_datetime(end_time_str) - if end_time: + if end_time_str := request.query.get("end_time"): + if end_time := dt_util.parse_datetime(end_time_str): end_time = dt_util.as_utc(end_time) else: - return self.json_message("Invalid end_time", HTTP_BAD_REQUEST) + return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: end_time = start_time + one_day entity_ids_str = request.query.get("filter_entity_id") @@ -196,18 +261,16 @@ def _sorted_significant_states_json( timer_start = time.perf_counter() with session_scope(hass=hass) as session: - result = ( - history._get_significant_states( # pylint: disable=protected-access - hass, - session, - start_time, - end_time, - entity_ids, - self.filters, - include_start_time_state, - significant_changes_only, - minimal_response, - ) + result = history.get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + self.filters, + include_start_time_state, + significant_changes_only, + minimal_response, ) result = list(result.values()) @@ -234,13 +297,11 @@ def _sorted_significant_states_json( def sqlalchemy_filter_from_include_exclude_conf(conf): """Build a sql filter from config.""" filters = Filters() - exclude = conf.get(CONF_EXCLUDE) - if exclude: + if exclude := conf.get(CONF_EXCLUDE): filters.excluded_entities = exclude.get(CONF_ENTITIES, []) filters.excluded_domains = exclude.get(CONF_DOMAINS, []) filters.excluded_entity_globs = exclude.get(CONF_ENTITY_GLOBS, []) - include = conf.get(CONF_INCLUDE) - if include: + if include := conf.get(CONF_INCLUDE): filters.included_entities = include.get(CONF_ENTITIES, []) filters.included_domains = include.get(CONF_DOMAINS, []) filters.included_entity_globs = include.get(CONF_ENTITY_GLOBS, []) @@ -297,17 +358,17 @@ def entity_filter(self): """Generate the entity filter query.""" includes = [] if self.included_domains: - includes.append(States.domain.in_(self.included_domains)) + includes.append(history_models.States.domain.in_(self.included_domains)) if self.included_entities: - includes.append(States.entity_id.in_(self.included_entities)) + includes.append(history_models.States.entity_id.in_(self.included_entities)) for glob in self.included_entity_globs: includes.append(_glob_to_like(glob)) excludes = [] if self.excluded_domains: - excludes.append(States.domain.in_(self.excluded_domains)) + excludes.append(history_models.States.domain.in_(self.excluded_domains)) if self.excluded_entities: - excludes.append(States.entity_id.in_(self.excluded_entities)) + excludes.append(history_models.States.entity_id.in_(self.excluded_entities)) for glob in self.excluded_entity_globs: excludes.append(_glob_to_like(glob)) @@ -317,7 +378,7 @@ def entity_filter(self): if includes and not excludes: return or_(*includes) - if not excludes and includes: + if not includes and excludes: return not_(or_(*excludes)) return or_(*includes) & not_(or_(*excludes)) @@ -325,7 +386,7 @@ def entity_filter(self): def _glob_to_like(glob_str): """Translate glob to sql.""" - return States.entity_id.like(glob_str.translate(GLOB_TO_SQL_CHARS)) + return history_models.States.entity_id.like(glob_str.translate(GLOB_TO_SQL_CHARS)) def _entities_may_have_state_changes_after( diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 69f42da5e3652..0db311b035441 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -86,7 +86,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensor_type = config.get(CONF_TYPE) name = config.get(CONF_NAME) - for template in [start, end]: + for template in (start, end): if template is not None: template.hass = hass @@ -153,7 +153,7 @@ def name(self): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.value is None or self.count is None: return None @@ -168,7 +168,7 @@ def state(self): return self.count @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/history_stats/services.yaml b/homeassistant/components/history_stats/services.yaml index 38758a35df018..f254295ea2025 100644 --- a/homeassistant/components/history_stats/services.yaml +++ b/homeassistant/components/history_stats/services.yaml @@ -1,2 +1,3 @@ reload: + name: Reload description: Reload all history_stats entities. diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index cbd6b7eeff87e..ac362f173e465 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -1,5 +1,6 @@ """Support for the Hitron CODA-4582U, provided by Rogers.""" from collections import namedtuple +from http import HTTPStatus import logging import requests @@ -7,23 +8,17 @@ from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_TYPE, - CONF_USERNAME, - HTTP_OK, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEFAULT_TYPE = "rogers" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, @@ -88,7 +83,7 @@ def _login(self): except requests.exceptions.Timeout: _LOGGER.error("Connection to the router timed out at URL %s", self._url) return False - if res.status_code != HTTP_OK: + if res.status_code != HTTPStatus.OK: _LOGGER.error("Connection failed with http code %s", res.status_code) return False try: @@ -113,7 +108,7 @@ def _update_info(self): except requests.exceptions.Timeout: _LOGGER.error("Connection to the router timed out at URL %s", self._url) return False - if res.status_code != HTTP_OK: + if res.status_code != HTTPStatus.OK: _LOGGER.error("Connection failed with http code %s", res.status_code) return False try: diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py new file mode 100644 index 0000000000000..a17d71f51ab94 --- /dev/null +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -0,0 +1,100 @@ +"""Support for the Hive alarm.""" +from datetime import timedelta + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.helpers.entity import DeviceInfo + +from . import HiveEntity +from .const import DOMAIN + +ICON = "mdi:security" +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) +HIVETOHA = { + "home": STATE_ALARM_DISARMED, + "asleep": STATE_ALARM_ARMED_NIGHT, + "away": STATE_ALARM_ARMED_AWAY, +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" + + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("alarm_control_panel") + if devices: + async_add_entities( + [HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True + ) + + +class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): + """Representation of a Hive alarm.""" + + _attr_icon = ICON + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this AdGuard Home instance.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + model=self.device["deviceData"]["model"], + manufacturer=self.device["deviceData"]["manufacturer"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) + + @property + def name(self): + """Return the name of the alarm.""" + return self.device["haName"] + + @property + def available(self): + """Return if the device is available.""" + return self.device["deviceData"]["online"] + + @property + def state(self): + """Return state of alarm.""" + if self.device["status"]["state"]: + return STATE_ALARM_TRIGGERED + return HIVETOHA[self.device["status"]["mode"]] + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_ARM_AWAY + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self.hive.alarm.setMode(self.device, "home") + + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + await self.hive.alarm.setMode(self.device, "asleep") + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self.hive.alarm.setMode(self.device, "away") + + async def async_update(self): + """Update all Node data from Hive.""" + await self.hive.session.updateData(self.device) + self.device = await self.hive.alarm.getAlarm(self.device) diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index d5f1ca53afde6..5a346c2cc01f6 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -2,24 +2,21 @@ from datetime import timedelta from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_SOUND, + BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.helpers.entity import DeviceInfo from . import HiveEntity from .const import ATTR_MODE, DOMAIN DEVICETYPE = { - "contactsensor": DEVICE_CLASS_OPENING, - "motionsensor": DEVICE_CLASS_MOTION, - "Connectivity": DEVICE_CLASS_CONNECTIVITY, - "SMOKE_CO": DEVICE_CLASS_SMOKE, - "DOG_BARK": DEVICE_CLASS_SOUND, - "GLASS_BREAK": DEVICE_CLASS_SOUND, + "contactsensor": BinarySensorDeviceClass.OPENING, + "motionsensor": BinarySensorDeviceClass.MOTION, + "Connectivity": BinarySensorDeviceClass.CONNECTIVITY, + "SMOKE_CO": BinarySensorDeviceClass.SMOKE, + "DOG_BARK": BinarySensorDeviceClass.SOUND, + "GLASS_BREAK": BinarySensorDeviceClass.SOUND, } PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) @@ -46,16 +43,16 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information.""" - return { - "identifiers": {(DOMAIN, self.device["device_id"])}, - "name": self.device["device_name"], - "model": self.device["deviceData"]["model"], - "manufacturer": self.device["deviceData"]["manufacturer"], - "sw_version": self.device["deviceData"]["version"], - "via_device": (DOMAIN, self.device["parentDevice"]), - } + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + manufacturer=self.device["deviceData"]["manufacturer"], + model=self.device["deviceData"]["model"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) @property def device_class(self): diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 7639a07c82adf..0b038bbde0d5b 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -19,6 +19,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from . import HiveEntity, refresh_system from .const import ( @@ -117,16 +118,16 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information.""" - return { - "identifiers": {(DOMAIN, self.device["device_id"])}, - "name": self.device["device_name"], - "model": self.device["deviceData"]["model"], - "manufacturer": self.device["deviceData"]["manufacturer"], - "sw_version": self.device["deviceData"]["version"], - "via_device": (DOMAIN, self.device["parentDevice"]), - } + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + manufacturer=self.device["deviceData"]["manufacturer"], + model=self.device["deviceData"]["model"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) @property def supported_features(self): @@ -194,7 +195,7 @@ def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" if self.device["status"]["boost"] == "ON": return PRESET_BOOST - return None + return PRESET_NONE @property def preset_modes(self): diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py index 9e1d7fc1f8034..82c07761eef5f 100644 --- a/homeassistant/components/hive/const.py +++ b/homeassistant/components/hive/const.py @@ -1,4 +1,6 @@ """Constants for Hive.""" +from homeassistant.const import Platform + ATTR_MODE = "mode" ATTR_TIME_PERIOD = "time_period" ATTR_ONOFF = "on_off" @@ -6,14 +8,23 @@ CONFIG_ENTRY_VERSION = 1 DEFAULT_NAME = "Hive" DOMAIN = "hive" -PLATFORMS = ["binary_sensor", "climate", "light", "sensor", "switch", "water_heater"] +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + Platform.WATER_HEATER, +] PLATFORM_LOOKUP = { - "binary_sensor": "binary_sensor", - "climate": "climate", - "light": "light", - "sensor": "sensor", - "switch": "switch", - "water_heater": "water_heater", + Platform.ALARM_CONTROL_PANEL: "alarm_control_panel", + Platform.BINARY_SENSOR: "binary_sensor", + Platform.CLIMATE: "climate", + Platform.LIGHT: "light", + Platform.SENSOR: "sensor", + Platform.SWITCH: "switch", + Platform.WATER_HEATER: "water_heater", } SERVICE_BOOST_HOT_WATER = "boost_hot_water" SERVICE_BOOST_HEATING_ON = "boost_heating_on" diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 46e8c5b579069..f9d235d625fa0 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -10,6 +10,7 @@ SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.helpers.entity import DeviceInfo import homeassistant.util.color as color_util from . import HiveEntity, refresh_system @@ -40,16 +41,16 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information.""" - return { - "identifiers": {(DOMAIN, self.device["device_id"])}, - "name": self.device["device_name"], - "model": self.device["deviceData"]["model"], - "manufacturer": self.device["deviceData"]["manufacturer"], - "sw_version": self.device["deviceData"]["version"], - "via_device": (DOMAIN, self.device["parentDevice"]), - } + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + manufacturer=self.device["deviceData"]["manufacturer"], + model=self.device["deviceData"]["model"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) @property def name(self): diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 518f328623160..764c83cfff095 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -1,8 +1,9 @@ -"""Support for the Hive sesnors.""" +"""Support for the Hive sensors.""" from datetime import timedelta -from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.helpers.entity import DeviceInfo from . import HiveEntity from .const import DOMAIN @@ -10,7 +11,7 @@ PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) DEVICETYPE = { - "Battery": {"unit": " % ", "type": DEVICE_CLASS_BATTERY}, + "Battery": {"unit": " % ", "type": SensorDeviceClass.BATTERY}, } @@ -35,16 +36,16 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information.""" - return { - "identifiers": {(DOMAIN, self.device["device_id"])}, - "name": self.device["device_name"], - "model": self.device["deviceData"]["model"], - "manufacturer": self.device["deviceData"]["manufacturer"], - "sw_version": self.device["deviceData"]["version"], - "via_device": (DOMAIN, self.device["parentDevice"]), - } + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + manufacturer=self.device["deviceData"]["manufacturer"], + model=self.device["deviceData"]["model"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) @property def available(self): @@ -57,7 +58,7 @@ def device_class(self): return DEVICETYPE[self.device["hiveType"]].get("type") @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEVICETYPE[self.device["hiveType"]].get("unit") @@ -67,7 +68,7 @@ def name(self): return self.device["haName"] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.device["status"]["state"] diff --git a/homeassistant/components/hive/services.yaml b/homeassistant/components/hive/services.yaml index de1439eead456..d0de9645c6a15 100644 --- a/homeassistant/components/hive/services.yaml +++ b/homeassistant/components/hive/services.yaml @@ -1,35 +1,36 @@ boost_heating: name: Boost Heating (To be deprecated) description: To be deprecated please use boost_heating_on. + target: + entity: + integration: hive + domain: climate fields: - entity_id: - name: Entity ID - description: Select entity_id to boost. - required: true - example: climate.heating time_period: name: Time Period description: Set the time period for the boost. required: true example: 01:30:00 + selector: + time: temperature: name: Temperature description: Set the target temperature for the boost period. - required: true - example: 20.5 + default: 25.0 + selector: + number: + min: 7 + max: 35 + step: 0.5 + unit_of_measurement: ° boost_heating_on: name: Boost Heating On description: Set the boost mode ON defining the period of time and the desired target temperature for the boost. + target: + entity: + integration: hive + domain: climate fields: - entity_id: - name: Entity ID - description: Select entity_id to boost. - required: true - example: climate.heating - selector: - entity: - integration: hive - domain: climate time_period: name: Time Period description: Set the time period for the boost. @@ -40,15 +41,13 @@ boost_heating_on: temperature: name: Temperature description: Set the target temperature for the boost period. - required: true - example: 20.5 + default: 25.0 selector: number: min: 7 max: 35 step: 0.5 - unit_of_measurement: degrees - mode: slider + unit_of_measurement: ° boost_heating_off: name: Boost Heating Off description: Set the boost mode OFF. @@ -57,7 +56,6 @@ boost_heating_off: name: Entity ID description: Select entity_id to turn boost off. required: true - example: climate.heating selector: entity: integration: hive @@ -70,7 +68,6 @@ boost_hot_water: name: Entity ID description: Select entity_id to boost. required: true - example: water_heater.hot_water selector: entity: integration: hive @@ -86,7 +83,6 @@ boost_hot_water: name: Mode description: Set the boost function on or off. required: true - example: "on" selector: select: options: diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 7ad81a25f0eb6..adfcfa442ee49 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -1,7 +1,10 @@ """Support for the Hive switches.""" +from __future__ import annotations + from datetime import timedelta from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.entity import DeviceInfo from . import HiveEntity, refresh_system from .const import ATTR_MODE, DOMAIN @@ -31,17 +34,18 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device information.""" if self.device["hiveType"] == "activeplug": - return { - "identifiers": {(DOMAIN, self.device["device_id"])}, - "name": self.device["device_name"], - "model": self.device["deviceData"]["model"], - "manufacturer": self.device["deviceData"]["manufacturer"], - "sw_version": self.device["deviceData"]["version"], - "via_device": (DOMAIN, self.device["parentDevice"]), - } + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + manufacturer=self.device["deviceData"]["manufacturer"], + model=self.device["deviceData"]["model"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) + return None @property def name(self): diff --git a/homeassistant/components/hive/translations/ca.json b/homeassistant/components/hive/translations/ca.json index eacccda82e7ba..edebafba57908 100644 --- a/homeassistant/components/hive/translations/ca.json +++ b/homeassistant/components/hive/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown_entry": "No s'ha pogut trobar l'entrada existent." }, diff --git a/homeassistant/components/hive/translations/he.json b/homeassistant/components/hive/translations/he.json new file mode 100644 index 0000000000000..dcc967c6986ff --- /dev/null +++ b/homeassistant/components/hive/translations/he.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "invalid_password": "\u05d4\u05db\u05e0\u05d9\u05e1\u05d4 \u05dc-Hive \u05e0\u05db\u05e9\u05dc\u05d4. \u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d2\u05d5\u05d9\u05d4. \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05e0\u05d9\u05ea.", + "invalid_username": "\u05d4\u05db\u05e0\u05d9\u05e1\u05d4 \u05dc\u05db\u05d5\u05d5\u05e8\u05ea \u05e0\u05db\u05e9\u05dc\u05d4. \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d3\u05d5\u05d0\u05e8 \u05d4\u05d0\u05dc\u05e7\u05d8\u05e8\u05d5\u05e0\u05d9 \u05e9\u05dc\u05da \u05d0\u05d9\u05e0\u05d4 \u05de\u05d6\u05d5\u05d4\u05d4.", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/translations/hu.json b/homeassistant/components/hive/translations/hu.json index 80c6a7e40f11c..9b0d3c21590ce 100644 --- a/homeassistant/components/hive/translations/hu.json +++ b/homeassistant/components/hive/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", "unknown_entry": "Nem tal\u00e1lhat\u00f3 megl\u00e9v\u0151 bejegyz\u00e9s." }, "error": { @@ -17,7 +17,7 @@ "data": { "2fa": "K\u00e9tfaktoros k\u00f3d" }, - "description": "Add meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", + "description": "Adja meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", "title": "Hive k\u00e9tfaktoros hiteles\u00edt\u00e9s." }, "reauth": { @@ -25,15 +25,16 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg \u00fajra a Hive bejelentkez\u00e9si adatait.", + "description": "Adja meg \u00fajra a Hive bejelentkez\u00e9si adatait.", "title": "Hive Bejelentkez\u00e9s" }, "user": { "data": { "password": "Jelsz\u00f3", + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg a Hive bejelentkez\u00e9si adatait \u00e9s konfigur\u00e1ci\u00f3j\u00e1t.", + "description": "Adja meg a Hive bejelentkez\u00e9si adatait \u00e9s konfigur\u00e1ci\u00f3j\u00e1t.", "title": "Hive Bejelentkez\u00e9s" } } @@ -41,6 +42,10 @@ "options": { "step": { "user": { + "data": { + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "Friss\u00edtse a vizsg\u00e1lati intervallumot az adatok gyakrabban t\u00f6rt\u00e9n\u0151 lek\u00e9rdez\u00e9s\u00e9hez.", "title": "Hive be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/hive/translations/it.json b/homeassistant/components/hive/translations/it.json index fd79ca35b7978..38edac70cb1d8 100644 --- a/homeassistant/components/hive/translations/it.json +++ b/homeassistant/components/hive/translations/it.json @@ -34,7 +34,7 @@ "scan_interval": "Intervallo di scansione (secondi)", "username": "Nome utente" }, - "description": "Immettere le informazioni di accesso e la configurazione di Hive.", + "description": "Inserisci le informazioni di accesso e la configurazione di Hive.", "title": "Accesso Hive" } } diff --git a/homeassistant/components/hive/translations/ja.json b/homeassistant/components/hive/translations/ja.json new file mode 100644 index 0000000000000..55b18b134270e --- /dev/null +++ b/homeassistant/components/hive/translations/ja.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "unknown_entry": "\u65e2\u5b58\u306e\u30a8\u30f3\u30c8\u30ea\u30fc\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_code": "Hive\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u4e8c\u8981\u7d20\u8a8d\u8a3c\u30b3\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002", + "invalid_password": "Hive\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u306e\u3067\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "invalid_username": "Hive\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u3042\u306a\u305f\u306e\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u304c\u8a8d\u8b58\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "no_internet_available": "Hive\u306b\u63a5\u7d9a\u3059\u308b\u306b\u306f\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u63a5\u7d9a\u304c\u5fc5\u8981\u3067\u3059\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "2fa": { + "data": { + "2fa": "2\u8981\u7d20\u30b3\u30fc\u30c9" + }, + "description": "Hive\u8a8d\u8a3c\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\n\u5225\u306e\u30b3\u30fc\u30c9\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u306b\u306f\u3001\u30b3\u30fc\u30c9 0000 \u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Hive 2\u8981\u7d20\u8a8d\u8a3c\u3002" + }, + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Hive\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u518d\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Hive\u30ed\u30b0\u30a4\u30f3" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Hive\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3068\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Hive\u30ed\u30b0\u30a4\u30f3" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)" + }, + "description": "\u30b9\u30ad\u30e3\u30f3\u9593\u9694\u3092\u66f4\u65b0\u3057\u3066\u3001\u30c7\u30fc\u30bf\u3092\u3088\u308a\u983b\u7e41\u306b\u30dd\u30fc\u30ea\u30f3\u30b0\u3057\u307e\u3059\u3002", + "title": "Hive\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/translations/tr.json b/homeassistant/components/hive/translations/tr.json new file mode 100644 index 0000000000000..afc0ee66f036f --- /dev/null +++ b/homeassistant/components/hive/translations/tr.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown_entry": "Mevcut giri\u015f bulunamad\u0131." + }, + "error": { + "invalid_code": "Hive'da oturum a\u00e7\u0131lamad\u0131. \u0130ki fakt\u00f6rl\u00fc kimlik do\u011frulama kodunuz yanl\u0131\u015ft\u0131.", + "invalid_password": "Hive'da oturum a\u00e7\u0131lamad\u0131. Yanl\u0131\u015f \u015fifre. L\u00fctfen tekrar deneyin.", + "invalid_username": "Hive'da oturum a\u00e7\u0131lamad\u0131. E-posta adresiniz tan\u0131nm\u0131yor.", + "no_internet_available": "Hive'a ba\u011flanmak i\u00e7in internet ba\u011flant\u0131s\u0131 gereklidir.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "2fa": { + "data": { + "2fa": "\u0130ki ad\u0131ml\u0131 kimlik do\u011frulama kodu" + }, + "description": "Hive kimlik do\u011frulama kodunuzu girin. \n\n Ba\u015fka bir kod istemek i\u00e7in l\u00fctfen 0000 kodunu girin.", + "title": "Hive \u0130ki Fakt\u00f6rl\u00fc Kimlik Do\u011frulama." + }, + "reauth": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Hive giri\u015f bilgilerinizi tekrar girin.", + "title": "Hive Giri\u015f yap" + }, + "user": { + "data": { + "password": "Parola", + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Hive oturum a\u00e7ma bilgilerinizi ve yap\u0131land\u0131rman\u0131z\u0131 girin.", + "title": "Hive Giri\u015f yap" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)" + }, + "description": "Verileri daha s\u0131k kontrol etmek i\u00e7in tarama aral\u0131\u011f\u0131n\u0131 g\u00fcncelleyin.", + "title": "Hive i\u00e7in Se\u00e7enekler" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/translations/zh-Hans.json b/homeassistant/components/hive/translations/zh-Hans.json new file mode 100644 index 0000000000000..780a47cb95811 --- /dev/null +++ b/homeassistant/components/hive/translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_password": "\u65e0\u6cd5\u767b\u5f55 Hive\uff0c\u5bc6\u7801\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002" + }, + "step": { + "reauth": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index b9377a378c3dd..096fc468ecbfe 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -13,6 +13,7 @@ ) from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from . import HiveEntity, refresh_system from .const import ( @@ -78,16 +79,16 @@ def unique_id(self): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information.""" - return { - "identifiers": {(DOMAIN, self.device["device_id"])}, - "name": self.device["device_name"], - "model": self.device["deviceData"]["model"], - "manufacturer": self.device["deviceData"]["manufacturer"], - "sw_version": self.device["deviceData"]["version"], - "via_device": (DOMAIN, self.device["parentDevice"]), - } + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + manufacturer=self.device["deviceData"]["manufacturer"], + model=self.device["deviceData"]["model"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) @property def supported_features(self): diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index e36af7676ed06..9fae14f8d1a84 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES, Platform from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["switch"] +PLATFORMS = [Platform.SWITCH] DATA_DEVICE_REGISTER = "hlk_sw16_device_register" DATA_DEVICE_LISTENER = "hlk_sw16_device_listener" diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 6f4e6ce708cbd..ca65647a4483a 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -15,7 +15,7 @@ DEFAULT_RECONNECT_INTERVAL, DOMAIN, ) -from .errors import AlreadyConfigured, CannotConnect +from .errors import CannotConnect DATA_SCHEMA = vol.Schema( { @@ -40,13 +40,6 @@ async def connect_client(hass, user_input): async def validate_input(hass: HomeAssistant, user_input): """Validate the user input allows us to connect.""" - for entry in hass.config_entries.async_entries(DOMAIN): - if ( - entry.data[CONF_HOST] == user_input[CONF_HOST] - and entry.data[CONF_PORT] == user_input[CONF_PORT] - ): - raise AlreadyConfigured - try: client = await connect_client(hass, user_input) except asyncio.TimeoutError as err: @@ -81,12 +74,13 @@ async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await validate_input(self.hass, user_input) address = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" return self.async_create_entry(title=address, data=user_input) - except AlreadyConfigured: - errors["base"] = "already_configured" except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/hlk_sw16/errors.py b/homeassistant/components/hlk_sw16/errors.py index 5b29587deba64..87453737531e4 100644 --- a/homeassistant/components/hlk_sw16/errors.py +++ b/homeassistant/components/hlk_sw16/errors.py @@ -6,9 +6,5 @@ class SW16Exception(HomeAssistantError): """Base class for HLK-SW16 exceptions.""" -class AlreadyConfigured(SW16Exception): - """HLK-SW16 is already configured.""" - - class CannotConnect(SW16Exception): """Unable to connect to the HLK-SW16.""" diff --git a/homeassistant/components/hlk_sw16/translations/bg.json b/homeassistant/components/hlk_sw16/translations/bg.json new file mode 100644 index 0000000000000..d3c0c1a8e779d --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/he.json b/homeassistant/components/hlk_sw16/translations/he.json new file mode 100644 index 0000000000000..479d2f2f5e809 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/hu.json b/homeassistant/components/hlk_sw16/translations/hu.json index 0abcc301f0c85..9590d3c12bed6 100644 --- a/homeassistant/components/hlk_sw16/translations/hu.json +++ b/homeassistant/components/hlk_sw16/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/hlk_sw16/translations/ja.json b/homeassistant/components/hlk_sw16/translations/ja.json new file mode 100644 index 0000000000000..a9d2ddfd3ac5a --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/tr.json b/homeassistant/components/hlk_sw16/translations/tr.json index 40c9c39b96772..fb81a4118239d 100644 --- a/homeassistant/components/hlk_sw16/translations/tr.json +++ b/homeassistant/components/hlk_sw16/translations/tr.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Sunucu", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index f8a9157dca267..2644889343816 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -7,9 +7,10 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api, config_flow @@ -31,10 +32,10 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["binary_sensor", "light", "sensor", "switch"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index da5f1df20c666..380688ba6ffa4 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -7,12 +7,12 @@ from homeconnect.api import HomeConnectError from homeassistant import config_entries, core +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, CONF_DEVICE, CONF_ENTITIES, - DEVICE_CLASS_TIMESTAMP, PERCENTAGE, TIME_SECONDS, ) @@ -46,7 +46,7 @@ def __init__( hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry, implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, - ): + ) -> None: """Initialize Home Connect Auth.""" self.hass = hass self.config_entry = config_entry @@ -159,7 +159,7 @@ def get_program_sensors(self): device. """ sensors = { - "Remaining Program Time": (None, None, DEVICE_CLASS_TIMESTAMP, 1), + "Remaining Program Time": (None, None, SensorDeviceClass.TIMESTAMP, 1), "Duration": (TIME_SECONDS, "mdi:update", None, 1), "Program Progress": (PERCENTAGE, "mdi:progress-clock", None, 1), } diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 12f86059023cc..b27988f997db4 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -4,7 +4,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .api import HomeConnectDevice from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES @@ -51,14 +51,14 @@ def unique_id(self): return f"{self.device.appliance.haId}-{self.desc}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return info about the device.""" - return { - "identifiers": {(DOMAIN, self.device.appliance.haId)}, - "name": self.device.appliance.name, - "manufacturer": self.device.appliance.brand, - "model": self.device.appliance.vib, - } + return DeviceInfo( + identifiers={(DOMAIN, self.device.appliance.haId)}, + manufacturer=self.device.appliance.brand, + model=self.device.appliance.vib, + name=self.device.appliance.name, + ) @callback def async_entity_update(self): diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 463de6cda5145..8c68113a05557 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -3,8 +3,8 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_ENTITIES, DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import CONF_ENTITIES import homeassistant.util.dt as dt_util from .const import ATTR_VALUE, BSH_OPERATION_STATE, DOMAIN @@ -42,7 +42,7 @@ def __init__(self, device, desc, key, unit, icon, device_class, sign=1): self._sign = sign @property - def state(self): + def native_value(self): """Return true if the binary sensor is on.""" return self._state @@ -57,22 +57,20 @@ async def async_update(self): if self._key not in status: self._state = None else: - if self.device_class == DEVICE_CLASS_TIMESTAMP: + if self.device_class == SensorDeviceClass.TIMESTAMP: if ATTR_VALUE not in status[self._key]: self._state = None elif ( self._state is not None and self._sign == 1 - and dt_util.parse_datetime(self._state) < dt_util.utcnow() + and self._state < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. self._state = None else: seconds = self._sign * float(status[self._key][ATTR_VALUE]) - self._state = ( - dt_util.utcnow() + timedelta(seconds=seconds) - ).isoformat() + self._state = dt_util.utcnow() + timedelta(seconds=seconds) else: self._state = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: @@ -83,7 +81,7 @@ async def async_update(self): _LOGGER.debug("Updated, new state: %s", self._state) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/home_connect/translations/bg.json b/homeassistant/components/home_connect/translations/bg.json new file mode 100644 index 0000000000000..ac264191dcde2 --- /dev/null +++ b/homeassistant/components/home_connect/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430." + }, + "create_entry": { + "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u0435\u043d" + }, + "step": { + "pick_implementation": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_connect/translations/fr.json b/homeassistant/components/home_connect/translations/fr.json index 42a0c34fe8182..5eba6fa03c1a4 100644 --- a/homeassistant/components/home_connect/translations/fr.json +++ b/homeassistant/components/home_connect/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "missing_configuration": "Le composant Home Connect n'est pas configur\u00e9. Veuillez suivre la documentation.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )" }, "create_entry": { - "default": "Authentification r\u00e9ussie avec Home Connect." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } } diff --git a/homeassistant/components/home_connect/translations/he.json b/homeassistant/components/home_connect/translations/he.json new file mode 100644 index 0000000000000..6051eb96eb237 --- /dev/null +++ b/homeassistant/components/home_connect/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", + "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})" + }, + "create_entry": { + "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" + }, + "step": { + "pick_implementation": { + "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d0\u05d9\u05de\u05d5\u05ea" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_connect/translations/hu.json b/homeassistant/components/home_connect/translations/hu.json index aa43f65b520e9..ca5f3e1e9aece 100644 --- a/homeassistant/components/home_connect/translations/hu.json +++ b/homeassistant/components/home_connect/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz." }, "create_entry": { diff --git a/homeassistant/components/home_connect/translations/ja.json b/homeassistant/components/home_connect/translations/ja.json new file mode 100644 index 0000000000000..66b53ce718bee --- /dev/null +++ b/homeassistant/components/home_connect/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_connect/translations/tr.json b/homeassistant/components/home_connect/translations/tr.json new file mode 100644 index 0000000000000..58624199557c7 --- /dev/null +++ b/homeassistant/components/home_connect/translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", + "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})" + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, + "step": { + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 176dc2fbd02e8..78e31c83caa1b 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -16,6 +16,7 @@ dispatcher, ) from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import config_flow, helpers @@ -45,12 +46,12 @@ ) # The Legrand Home+ Control platform is currently limited to "switch" entities -PLATFORMS = ["switch"] +PLATFORMS = [Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Legrand Home+ Control component from configuration.yaml.""" hass.data[DOMAIN] = {} @@ -66,22 +67,20 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Legrand Home+ Control from a config entry.""" - hass_entry_data = hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) + hass_entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) # Retrieve the registered implementation implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, config_entry + hass, entry ) ) # Using an aiohttp-based API lib, so rely on async framework # Add the API object to the domain's data in HA - api = hass_entry_data[API] = HomePlusControlAsyncApi( - hass, config_entry, implementation - ) + api = hass_entry_data[API] = HomePlusControlAsyncApi(hass, entry, implementation) # Set of entity unique identifiers of this integration uids = hass_entry_data[ENTITY_UIDS] = set() @@ -135,17 +134,17 @@ async def async_update_data(): name="home_plus_control_module", update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=60), + update_interval=timedelta(seconds=300), ) hass_entry_data[DATA_COORDINATOR] = coordinator async def start_platforms(): """Continue setting up the platforms.""" await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_setup(config_entry, platform) + *( + hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) # Only refresh the coordinator after all platforms are loaded. await coordinator.async_refresh() diff --git a/homeassistant/components/home_plus_control/helpers.py b/homeassistant/components/home_plus_control/helpers.py index 95d538def01b8..f5687a23c66a2 100644 --- a/homeassistant/components/home_plus_control/helpers.py +++ b/homeassistant/components/home_plus_control/helpers.py @@ -20,14 +20,14 @@ class HomePlusControlOAuth2Implementation( subscription_key (str): Subscription key obtained from the API provider. authorize_url (str): Authorization URL initiate authentication flow. token_url (str): URL to retrieve access/refresh tokens. - name (str): Name of the implementation (appears in the HomeAssitant GUI). + name (str): Name of the implementation (appears in the HomeAssistant GUI). """ def __init__( self, hass: HomeAssistant, config_data: dict, - ): + ) -> None: """HomePlusControlOAuth2Implementation Constructor. Initialize the authentication implementation for the Legrand Home+ Control API. diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index c991c9e0279af..9e860b397fbff 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -1,5 +1,4 @@ { - "title": "Legrand Home+ Control", "config": { "step": { "pick_implementation": { diff --git a/homeassistant/components/home_plus_control/switch.py b/homeassistant/components/home_plus_control/switch.py index d4167ae1f9ead..73fd515e65b24 100644 --- a/homeassistant/components/home_plus_control/switch.py +++ b/homeassistant/components/home_plus_control/switch.py @@ -1,13 +1,10 @@ """Legrand Home+ Control Switch Entity Module that uses the HomeAssistant DataUpdateCoordinator.""" from functools import partial -from homeassistant.components.switch import ( - DEVICE_CLASS_OUTLET, - DEVICE_CLASS_SWITCH, - SwitchEntity, -) +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import callback from homeassistant.helpers import dispatcher +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DISPATCHER_REMOVERS, DOMAIN, HW_TYPE, SIGNAL_ADD_ENTITIES @@ -79,25 +76,25 @@ def unique_id(self): return self.idx @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device information.""" - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Unique identifiers within the domain (DOMAIN, self.unique_id) }, - "name": self.name, - "manufacturer": "Legrand", - "model": HW_TYPE.get(self.module.hw_type), - "sw_version": self.module.fw, - } + manufacturer="Legrand", + model=HW_TYPE.get(self.module.hw_type), + name=self.name, + sw_version=self.module.fw, + ) @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" if self.module.device == "plug": - return DEVICE_CLASS_OUTLET - return DEVICE_CLASS_SWITCH + return SwitchDeviceClass.OUTLET + return SwitchDeviceClass.SWITCH @property def available(self) -> bool: diff --git a/homeassistant/components/home_plus_control/translations/ca.json b/homeassistant/components/home_plus_control/translations/ca.json index 90e23fcd7ab08..6e6dc1e057733 100644 --- a/homeassistant/components/home_plus_control/translations/ca.json +++ b/homeassistant/components/home_plus_control/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/home_plus_control/translations/de.json b/homeassistant/components/home_plus_control/translations/de.json index 8e7d9e9bc240d..8cb47ae3fec45 100644 --- a/homeassistant/components/home_plus_control/translations/de.json +++ b/homeassistant/components/home_plus_control/translations/de.json @@ -17,5 +17,5 @@ } } }, - "title": "" + "title": "Legrand Home+ Steuerung" } \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/en_GB.json b/homeassistant/components/home_plus_control/translations/en_GB.json new file mode 100644 index 0000000000000..ddf7ee6d5dd7a --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/fr.json b/homeassistant/components/home_plus_control/translations/fr.json index c39d4a2867eaf..489e04993245b 100644 --- a/homeassistant/components/home_plus_control/translations/fr.json +++ b/homeassistant/components/home_plus_control/translations/fr.json @@ -1,19 +1,19 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "authorize_url_timeout": "[%key::common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "Le composant n'est pas configur\u00e9. Merci de suivre la documentation.", - "no_url_available": "Aucune URL disponible. Pour plus d'information sur cette erreur, [v\u00e9rifier la section d'aide]({docs_url})", - "single_instance_allowed": "[%key::common::config_flow::abort::single_instance_allowed%]" + "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", + "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "create_entry": { "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisir une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } }, diff --git a/homeassistant/components/home_plus_control/translations/he.json b/homeassistant/components/home_plus_control/translations/he.json new file mode 100644 index 0000000000000..2800ddd7e624c --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", + "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", + "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "create_entry": { + "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" + }, + "step": { + "pick_implementation": { + "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d0\u05d9\u05de\u05d5\u05ea" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/hu.json b/homeassistant/components/home_plus_control/translations/hu.json index 7bc04beb0578a..09625a222f2ec 100644 --- a/homeassistant/components/home_plus_control/translations/hu.json +++ b/homeassistant/components/home_plus_control/translations/hu.json @@ -2,9 +2,9 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/home_plus_control/translations/ja.json b/homeassistant/components/home_plus_control/translations/ja.json new file mode 100644 index 0000000000000..df5165fc3fa12 --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + }, + "title": "Legrand Home+ Control" +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/tr.json b/homeassistant/components/home_plus_control/translations/tr.json new file mode 100644 index 0000000000000..0138716d5489c --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", + "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, + "step": { + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + } + } + }, + "title": "Legrand Home+ Kontrol" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 44f0843871c42..d75358fe62e36 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -14,17 +14,19 @@ RESTART_EXIT_CODE, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) import homeassistant.core as ha from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser -from homeassistant.helpers import config_validation as cv, recorder +from homeassistant.helpers import config_validation as cv, recorder, restore_state from homeassistant.helpers.service import ( async_extract_config_entry_ids, async_extract_referenced_entity_ids, ) +from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -50,12 +52,16 @@ SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) -async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C901 +async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" + async def async_save_persistent_states(service): + """Handle calls to homeassistant.save_persistent_states.""" + await restore_state.RestoreStateData.async_save_persistent_states(hass) + async def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" - referenced = await async_extract_referenced_entity_ids(hass, service) + referenced = async_extract_referenced_entity_ids(hass, service) all_referenced = referenced.referenced | referenced.indirectly_referenced # Generic turn on/off method requires entity id @@ -114,6 +120,10 @@ async def async_handle_turn_service(service): if tasks: await asyncio.gather(*tasks) + hass.services.async_register( + ha.DOMAIN, SERVICE_SAVE_PERSISTENT_STATES, async_save_persistent_states + ) + service_schema = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}, extra=vol.ALLOW_EXTRA) hass.services.async_register( @@ -128,9 +138,8 @@ async def async_handle_turn_service(service): async def async_handle_core_service(call): """Service handler for handling core services.""" - if ( - call.service in SHUTDOWN_SERVICES - and await recorder.async_migration_in_progress(hass) + if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( + hass ): _LOGGER.error( "The system cannot %s while a database upgrade is in progress", @@ -248,10 +257,10 @@ async def async_handle_reload_config_entry(call): if not reload_entries: raise ValueError("There were no matching config entries to reload") await asyncio.gather( - *[ + *( hass.config_entries.async_reload(config_entry_id) for config_entry_id in reload_entries - ] + ) ) hass.helpers.service.async_register_admin_service( diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 13a4ef66383ba..cd5da46a03a41 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -1,9 +1,8 @@ """Allow users to set and activate scenes.""" from __future__ import annotations -from collections import namedtuple import logging -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol @@ -115,10 +114,19 @@ def _ensure_no_intersection(value): SERVICE_APPLY = "apply" SERVICE_CREATE = "create" -SCENECONFIG = namedtuple("SceneConfig", [CONF_ID, CONF_NAME, CONF_ICON, STATES]) + _LOGGER = logging.getLogger(__name__) +class SceneConfig(NamedTuple): + """Object for storing scene config.""" + + id: str + name: str + icon: str + states: dict + + @callback def scenes_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: """Return all scenes that reference the entity.""" @@ -142,9 +150,7 @@ def entities_in_scene(hass: HomeAssistant, entity_id: str) -> list[str]: platform = hass.data[DATA_PLATFORM] - entity = platform.entities.get(entity_id) - - if entity is None: + if (entity := platform.entities.get(entity_id)) is None: return [] return list(entity.scene_config.states) @@ -225,8 +231,7 @@ async def create_service(call): entities = call.data[CONF_ENTITIES] for entity_id in snapshot: - state = hass.states.get(entity_id) - if state is None: + if (state := hass.states.get(entity_id)) is None: _LOGGER.warning( "Entity %s does not exist and therefore cannot be snapshotted", entity_id, @@ -238,10 +243,9 @@ async def create_service(call): _LOGGER.warning("Empty scenes are not allowed") return - scene_config = SCENECONFIG(None, call.data[CONF_SCENE_ID], None, entities) + scene_config = SceneConfig(None, call.data[CONF_SCENE_ID], None, entities) entity_id = f"{SCENE_DOMAIN}.{scene_config.name}" - old = platform.entities.get(entity_id) - if old is not None: + if (old := platform.entities.get(entity_id)) is not None: if not old.from_service: _LOGGER.warning("The scene %s already exists", entity_id) return @@ -255,16 +259,14 @@ async def create_service(call): def _process_scenes_config(hass, async_add_entities, config): """Process multiple scenes and add them.""" - scene_config = config[STATES] - # Check empty list - if not scene_config: + if not (scene_config := config[STATES]): return async_add_entities( HomeAssistantScene( hass, - SCENECONFIG( + SceneConfig( scene.get(CONF_ID), scene[CONF_NAME], scene.get(CONF_ICON), @@ -303,8 +305,7 @@ def unique_id(self): def extra_state_attributes(self): """Return the scene state attributes.""" attributes = {ATTR_ENTITY_ID: list(self.scene_config.states)} - unique_id = self.unique_id - if unique_id is not None: + if (unique_id := self.unique_id) is not None: attributes[CONF_ID] = unique_id return attributes diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 251ee171b6af0..da52ff50d2f5a 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -74,3 +74,9 @@ reload_config_entry: example: 8955375327824e14ba89e4b29cc3ec9a selector: text: + +save_persistent_states: + name: Save Persistent States + description: + Save the persistent states (for entities derived from RestoreEntity) immediately. + Maintain the normal periodic saving interval. diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 7da4a5a9d8a27..09be9283b5c5d 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -4,6 +4,7 @@ "arch": "CPU Architecture", "dev": "Development", "docker": "Docker", + "user": "User", "hassio": "Supervisor", "installation_type": "Installation Type", "os_name": "Operating System Family", @@ -14,4 +15,4 @@ "virtualenv": "Virtual Environment" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index ff3562a24f926..f13278ddfebbc 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -22,6 +22,7 @@ async def system_health_info(hass): "dev": info.get("dev"), "hassio": info.get("hassio"), "docker": info.get("docker"), + "user": info.get("user"), "virtualenv": info.get("virtualenv"), "python_version": info.get("python_version"), "os_name": info.get("os_name"), diff --git a/homeassistant/components/homeassistant/translations/bg.json b/homeassistant/components/homeassistant/translations/bg.json new file mode 100644 index 0000000000000..dab7fd6426a3b --- /dev/null +++ b/homeassistant/components/homeassistant/translations/bg.json @@ -0,0 +1,15 @@ +{ + "system_health": { + "info": { + "arch": "\u0410\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u0430 \u043d\u0430 CPU", + "docker": "Docker", + "hassio": "Supervisor", + "installation_type": "\u0422\u0438\u043f \u0438\u043d\u0441\u0442\u0430\u043b\u0430\u0446\u0438\u044f", + "os_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u0430\u0442\u0430 \u0441\u0438\u0441\u0442\u0435\u043c\u0430", + "python_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 Python", + "timezone": "\u0427\u0430\u0441\u043e\u0432\u0430 \u0437\u043e\u043d\u0430", + "user": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b", + "version": "\u0412\u0435\u0440\u0441\u0438\u044f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ca.json b/homeassistant/components/homeassistant/translations/ca.json index 97e3d088af459..e9c91d9df202a 100644 --- a/homeassistant/components/homeassistant/translations/ca.json +++ b/homeassistant/components/homeassistant/translations/ca.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Arquitectura de la CPU", - "chassis": "Xass\u00eds", "dev": "Desenvolupador", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Tipus d'instal\u00b7laci\u00f3", "os_name": "Fam\u00edlia del sistema operatiu", "os_version": "Versi\u00f3 del sistema operatiu", "python_version": "Versi\u00f3 de Python", - "supervisor": "Supervisor", "timezone": "Zona hor\u00e0ria", + "user": "Usuari", "version": "Versi\u00f3", "virtualenv": "Entorn virtual" } diff --git a/homeassistant/components/homeassistant/translations/cs.json b/homeassistant/components/homeassistant/translations/cs.json index 3b6414b58ad52..fbd96241e36c2 100644 --- a/homeassistant/components/homeassistant/translations/cs.json +++ b/homeassistant/components/homeassistant/translations/cs.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Architektura procesoru", - "chassis": "\u0160asi", "dev": "V\u00fdvoj", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Typ instalace", "os_name": "Rodina opera\u010dn\u00edch syst\u00e9m\u016f", "os_version": "Verze opera\u010dn\u00edho syst\u00e9mu", "python_version": "Verze Pythonu", - "supervisor": "Supervisor", "timezone": "\u010casov\u00e9 p\u00e1smo", + "user": "U\u017eivatel", "version": "Verze", "virtualenv": "Virtu\u00e1ln\u00ed prost\u0159ed\u00ed" } diff --git a/homeassistant/components/homeassistant/translations/de.json b/homeassistant/components/homeassistant/translations/de.json index e24568ff2125c..54909cb3c24aa 100644 --- a/homeassistant/components/homeassistant/translations/de.json +++ b/homeassistant/components/homeassistant/translations/de.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU-Architektur", - "chassis": "Chassis", "dev": "Entwicklung", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Installationstyp", "os_name": "Betriebssystemfamilie", "os_version": "Betriebssystem-Version", "python_version": "Python-Version", - "supervisor": "Supervisor", "timezone": "Zeitzone", + "user": "Benutzer", "version": "Version", "virtualenv": "Virtuelle Umgebung" } diff --git a/homeassistant/components/homeassistant/translations/en.json b/homeassistant/components/homeassistant/translations/en.json index 22538ad653665..977bc203fea3d 100644 --- a/homeassistant/components/homeassistant/translations/en.json +++ b/homeassistant/components/homeassistant/translations/en.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU Architecture", - "chassis": "Chassis", "dev": "Development", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Installation Type", "os_name": "Operating System Family", "os_version": "Operating System Version", "python_version": "Python Version", - "supervisor": "Supervisor", "timezone": "Timezone", + "user": "User", "version": "Version", "virtualenv": "Virtual Environment" } diff --git a/homeassistant/components/homeassistant/translations/es.json b/homeassistant/components/homeassistant/translations/es.json index 1829d16d5109f..0a9342afa698a 100644 --- a/homeassistant/components/homeassistant/translations/es.json +++ b/homeassistant/components/homeassistant/translations/es.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Arquitectura de CPU", - "chassis": "Chasis", "dev": "Desarrollo", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "SO Home Assistant", "installation_type": "Tipo de instalaci\u00f3n", "os_name": "Nombre del Sistema Operativo", "os_version": "Versi\u00f3n del Sistema Operativo", "python_version": "Versi\u00f3n de Python", - "supervisor": "Supervisor", "timezone": "Zona horaria", + "user": "Usuario", "version": "Versi\u00f3n", "virtualenv": "Entorno virtual" } diff --git a/homeassistant/components/homeassistant/translations/et.json b/homeassistant/components/homeassistant/translations/et.json index 22e3ab1e00da3..529b84120d7bb 100644 --- a/homeassistant/components/homeassistant/translations/et.json +++ b/homeassistant/components/homeassistant/translations/et.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Protsessori arhitektuur", - "chassis": "Korpus", "dev": "Arendus", "docker": "Docker", - "docker_version": "Docker", "hassio": "Haldur", - "host_os": "Home Assistant OS", "installation_type": "Paigalduse t\u00fc\u00fcp", "os_name": "Operatsioonis\u00fcsteemi j\u00e4rk", "os_version": "Operatsioonis\u00fcsteemi versioon", "python_version": "Pythoni versioon", - "supervisor": "Haldur", "timezone": "Ajav\u00f6\u00f6nd", + "user": "Kasutaja", "version": "Versioon", "virtualenv": "Virtuaalne keskkond" } diff --git a/homeassistant/components/homeassistant/translations/fr.json b/homeassistant/components/homeassistant/translations/fr.json index 8d76ff76b79c5..ae9dfb0a7daa3 100644 --- a/homeassistant/components/homeassistant/translations/fr.json +++ b/homeassistant/components/homeassistant/translations/fr.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Architecture du processeur", - "chassis": "Ch\u00e2ssis", "dev": "D\u00e9veloppement", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Type d'installation", "os_name": "Famille du syst\u00e8me d'exploitation", "os_version": "Version du syst\u00e8me d'exploitation", "python_version": "Version de Python", - "supervisor": "Supervisor", "timezone": "Fuseau horaire", + "user": "Utilisateur", "version": "Version", "virtualenv": "Environnement virtuel" } diff --git a/homeassistant/components/homeassistant/translations/he.json b/homeassistant/components/homeassistant/translations/he.json index f45b17b1a1326..20de5a2d1b7dc 100644 --- a/homeassistant/components/homeassistant/translations/he.json +++ b/homeassistant/components/homeassistant/translations/he.json @@ -1,7 +1,15 @@ { "system_health": { "info": { - "os_name": "\u05de\u05e9\u05e4\u05d7\u05ea \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4" + "docker": "Docker", + "hassio": "\u05de\u05e4\u05e7\u05d7", + "installation_type": "\u05e1\u05d5\u05d2 \u05d4\u05ea\u05e7\u05e0\u05d4", + "os_name": "\u05de\u05e9\u05e4\u05d7\u05ea \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4", + "os_version": "\u05d2\u05d9\u05e8\u05e1\u05ea \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4", + "python_version": "\u05d2\u05e8\u05e1\u05ea \u05e4\u05d9\u05d9\u05ea\u05d5\u05df", + "timezone": "\u05d0\u05d6\u05d5\u05e8 \u05d6\u05de\u05df", + "user": "\u05de\u05e9\u05ea\u05de\u05e9", + "version": "\u05d2\u05d9\u05e8\u05e1\u05d4" } } } \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/hu.json b/homeassistant/components/homeassistant/translations/hu.json index f6bfe03321e5d..b4da84596bf21 100644 --- a/homeassistant/components/homeassistant/translations/hu.json +++ b/homeassistant/components/homeassistant/translations/hu.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Processzor architekt\u00fara", - "chassis": "Kivitel", "dev": "Fejleszt\u00e9s", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Telep\u00edt\u00e9s t\u00edpusa", "os_name": "Oper\u00e1ci\u00f3s rendszer csal\u00e1d", "os_version": "Oper\u00e1ci\u00f3s rendszer verzi\u00f3ja", "python_version": "Python verzi\u00f3", - "supervisor": "Supervisor", "timezone": "Id\u0151z\u00f3na", + "user": "Felhaszn\u00e1l\u00f3", "version": "Verzi\u00f3", "virtualenv": "Virtu\u00e1lis k\u00f6rnyezet" } diff --git a/homeassistant/components/homeassistant/translations/id.json b/homeassistant/components/homeassistant/translations/id.json index 2ee86bba8157c..f795a47ee2017 100644 --- a/homeassistant/components/homeassistant/translations/id.json +++ b/homeassistant/components/homeassistant/translations/id.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Arsitektur CPU", - "chassis": "Kerangka", "dev": "Pengembangan", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Jenis Instalasi", "os_name": "Keluarga Sistem Operasi", "os_version": "Versi Sistem Operasi", "python_version": "Versi Python", - "supervisor": "Supervisor", "timezone": "Zona Waktu", + "user": "Pengguna", "version": "Versi", "virtualenv": "Lingkungan Virtual" } diff --git a/homeassistant/components/homeassistant/translations/it.json b/homeassistant/components/homeassistant/translations/it.json index 66b8f8a1d14c8..b85f30726209d 100644 --- a/homeassistant/components/homeassistant/translations/it.json +++ b/homeassistant/components/homeassistant/translations/it.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Architettura della CPU", - "chassis": "Telaio", "dev": "Sviluppo", "docker": "Docker", - "docker_version": "Docker", - "hassio": "Supervisor", - "host_os": "Sistema Operativo di Home Assistant", + "hassio": "Supervisore", "installation_type": "Tipo di installazione", - "os_name": "Famiglia del Sistema Operativo", - "os_version": "Versione del Sistema Operativo", + "os_name": "Famiglia del sistema operativo", + "os_version": "Versione del sistema operativo", "python_version": "Versione Python", - "supervisor": "Supervisor", "timezone": "Fuso orario", + "user": "Utente", "version": "Versione", "virtualenv": "Ambiente virtuale" } diff --git a/homeassistant/components/homeassistant/translations/ja.json b/homeassistant/components/homeassistant/translations/ja.json new file mode 100644 index 0000000000000..14b1deb55c88a --- /dev/null +++ b/homeassistant/components/homeassistant/translations/ja.json @@ -0,0 +1,18 @@ +{ + "system_health": { + "info": { + "arch": "CPU\u30a2\u30fc\u30ad\u30c6\u30af\u30c1\u30e3", + "dev": "\u30c7\u30a3\u30d9\u30ed\u30c3\u30d7\u30e1\u30f3\u30c8", + "docker": "Docker", + "hassio": "Supervisor", + "installation_type": "\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u7a2e\u5225", + "os_name": "\u30aa\u30da\u30ec\u30fc\u30c6\u30a3\u30f3\u30b0\u30b7\u30b9\u30c6\u30e0 \uff8c\uff67\uff90\uff98\uff70", + "os_version": "\u30aa\u30da\u30ec\u30fc\u30c6\u30a3\u30f3\u30b0\u30b7\u30b9\u30c6\u30e0\u306e\uff8a\uff9e\uff70\uff7c\uff9e\uff6e\uff9d", + "python_version": "Python\u30d0\u30fc\u30b8\u30e7\u30f3", + "timezone": "\u30bf\u30a4\u30e0\u30be\u30fc\u30f3", + "user": "\u30e6\u30fc\u30b6\u30fc", + "version": "\u30d0\u30fc\u30b8\u30e7\u30f3", + "virtualenv": "\u4eee\u60f3\u74b0\u5883" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ka.json b/homeassistant/components/homeassistant/translations/ka.json index 4b5dec2fd3008..27f744335e6cd 100644 --- a/homeassistant/components/homeassistant/translations/ka.json +++ b/homeassistant/components/homeassistant/translations/ka.json @@ -2,17 +2,13 @@ "system_health": { "info": { "arch": "\u10de\u10e0\u10dd\u10ea\u10d4\u10e1\u10dd\u10e0\u10d8\u10e1 \u10d0\u10e0\u10e5\u10d8\u10e2\u10d4\u10e5\u10e2\u10e3\u10e0\u10d0", - "chassis": "\u10e8\u10d0\u10e1\u10d8", "dev": "\u10e8\u10d4\u10db\u10e3\u10e8\u10d0\u10d5\u10d4\u10d1\u10d0", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant \u10dd\u10de\u10d4\u10e0\u10d0\u10ea\u10d8\u10e3\u10da\u10d8", "installation_type": "\u10d8\u10dc\u10e1\u10e2\u10d0\u10da\u10d0\u10ea\u10d8\u10d8\u10e1 \u10e2\u10d8\u10de\u10d8", "os_name": "\u10dd\u10de\u10d4\u10e0\u10d0\u10ea\u10d8\u10e3\u10da\u10d8 \u10e1\u10d8\u10e1\u10e2\u10d4\u10db\u10d8\u10e1 \u10dd\u10ef\u10d0\u10ee\u10d8", "os_version": "\u10dd\u10de\u10d4\u10e0\u10d0\u10ea\u10d8\u10e3\u10da\u10d8 \u10e1\u10d8\u10e1\u10e2\u10d4\u10db\u10d8\u10e1 \u10d5\u10d4\u10e0\u10e1\u10d8\u10d0", "python_version": "Python-\u10d8\u10e1 \u10d5\u10d4\u10e0\u10e1\u10d8\u10d0", - "supervisor": "Supervisor", "timezone": "\u1c93\u10e0\u10dd\u10d8\u10e1 \u10e1\u10d0\u10e0\u10e2\u10e7\u10d4\u10da\u10d8", "version": "\u10d5\u10d4\u10e0\u10e1\u10d8\u10d0", "virtualenv": "\u10d5\u10d8\u10e0\u10e2\u10e3\u10d0\u10da\u10e3\u10e0\u10d8 \u10d2\u10d0\u10e0\u10d4\u10db\u10dd" diff --git a/homeassistant/components/homeassistant/translations/ko.json b/homeassistant/components/homeassistant/translations/ko.json index 801d63fd44992..1a9b1aef5c84f 100644 --- a/homeassistant/components/homeassistant/translations/ko.json +++ b/homeassistant/components/homeassistant/translations/ko.json @@ -2,17 +2,13 @@ "system_health": { "info": { "arch": "CPU \uc544\ud0a4\ud14d\ucc98", - "chassis": "\uc100\uc2dc", "dev": "\uac1c\ubc1c\uc790 \ubaa8\ub4dc", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "\uc124\uce58 \uc720\ud615", "os_name": "\uc6b4\uc601 \uccb4\uc81c \uc81c\ud488\uad70", "os_version": "\uc6b4\uc601 \uccb4\uc81c \ubc84\uc804", "python_version": "Python \ubc84\uc804", - "supervisor": "Supervisor", "timezone": "\uc2dc\uac04\ub300", "version": "\ubc84\uc804", "virtualenv": "\uac00\uc0c1 \ud658\uacbd" diff --git a/homeassistant/components/homeassistant/translations/lb.json b/homeassistant/components/homeassistant/translations/lb.json index 07cfe8c4c83b5..51e0800c65411 100644 --- a/homeassistant/components/homeassistant/translations/lb.json +++ b/homeassistant/components/homeassistant/translations/lb.json @@ -2,17 +2,13 @@ "system_health": { "info": { "arch": "CPU Architektur", - "chassis": "Chassis", "dev": "Entw\u00e9cklung", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor API", - "host_os": "Home Assistant OS", "installation_type": "Typ vun Installatioun", "os_name": "Betribssystem Famille", "os_version": "Betribssystem Versioun", "python_version": "Python Versioun", - "supervisor": "Supervisor", "timezone": "Z\u00e4itzon", "version": "Versioun", "virtualenv": "Virtuellen Environnement" diff --git a/homeassistant/components/homeassistant/translations/lt.json b/homeassistant/components/homeassistant/translations/lt.json new file mode 100644 index 0000000000000..b1fd35bf9db2c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/lt.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "user": "Vartotojas" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/nl.json b/homeassistant/components/homeassistant/translations/nl.json index 6dba5eec8b94b..1037d161c2bb8 100644 --- a/homeassistant/components/homeassistant/translations/nl.json +++ b/homeassistant/components/homeassistant/translations/nl.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU-architectuur", - "chassis": "Chassis", "dev": "Ontwikkeling", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "Type installatie", "os_name": "Besturingssysteem", "os_version": "Versie van het besturingssysteem", "python_version": "Python-versie", - "supervisor": "Supervisor", "timezone": "Tijdzone", + "user": "Gebruiker", "version": "Versie", "virtualenv": "Virtuele omgeving" } diff --git a/homeassistant/components/homeassistant/translations/no.json b/homeassistant/components/homeassistant/translations/no.json index 3cf39e2cf7def..675c02a6b66c3 100644 --- a/homeassistant/components/homeassistant/translations/no.json +++ b/homeassistant/components/homeassistant/translations/no.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU-arkitektur", - "chassis": "Kabinett", "dev": "Utvikling", "docker": "", - "docker_version": "", "hassio": "Supervisor", - "host_os": "", "installation_type": "Installasjonstype", "os_name": "Familie for operativsystem", "os_version": "Operativsystemversjon", "python_version": "Python versjon", - "supervisor": "", "timezone": "Tidssone", + "user": "Bruker", "version": "Versjon", "virtualenv": "Virtuelt milj\u00f8" } diff --git a/homeassistant/components/homeassistant/translations/pl.json b/homeassistant/components/homeassistant/translations/pl.json index 44d35cb582ca5..9f85cc4ff1588 100644 --- a/homeassistant/components/homeassistant/translations/pl.json +++ b/homeassistant/components/homeassistant/translations/pl.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "Architektura procesora", - "chassis": "Wersja komputera", "dev": "Wersja deweloperska", "docker": "Docker", - "docker_version": "Wersja Dockera", "hassio": "Supervisor", - "host_os": "System operacyjny HA", "installation_type": "Typ instalacji", "os_name": "Rodzina systemu operacyjnego", "os_version": "Wersja systemu operacyjnego", "python_version": "Wersja Pythona", - "supervisor": "Supervisor", "timezone": "Strefa czasowa", + "user": "U\u017cytkownik", "version": "Wersja", "virtualenv": "\u015arodowisko wirtualne" } diff --git a/homeassistant/components/homeassistant/translations/pt.json b/homeassistant/components/homeassistant/translations/pt.json index c16c2c3baa4f3..13fd384d6a25c 100644 --- a/homeassistant/components/homeassistant/translations/pt.json +++ b/homeassistant/components/homeassistant/translations/pt.json @@ -2,17 +2,13 @@ "system_health": { "info": { "arch": "Arquitetura do Processador", - "chassis": "Chassis", "dev": "Desenvolvimento", "docker": "", - "docker_version": "", "hassio": "Supervisor", - "host_os": "Sistema Operativo do Home Assistant", "installation_type": "Tipo de Instala\u00e7\u00e3o", "os_name": "Nome do Sistema Operativo", "os_version": "Vers\u00e3o do Sistema Operativo", "python_version": "Vers\u00e3o Python", - "supervisor": "Supervisor", "timezone": "Fuso hor\u00e1rio", "version": "Vers\u00e3o", "virtualenv": "Ambiente Virtual" diff --git a/homeassistant/components/homeassistant/translations/ru.json b/homeassistant/components/homeassistant/translations/ru.json index 651400c5fe5c5..f8932f1ea7d40 100644 --- a/homeassistant/components/homeassistant/translations/ru.json +++ b/homeassistant/components/homeassistant/translations/ru.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "\u0410\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u0430 \u0426\u041f", - "chassis": "\u0428\u0430\u0441\u0441\u0438", "dev": "\u0421\u0440\u0435\u0434\u0430 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "\u0422\u0438\u043f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438", "os_name": "\u0421\u0435\u043c\u0435\u0439\u0441\u0442\u0432\u043e \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c", "os_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b", "python_version": "\u0412\u0435\u0440\u0441\u0438\u044f Python", - "supervisor": "Supervisor", "timezone": "\u0427\u0430\u0441\u043e\u0432\u043e\u0439 \u043f\u043e\u044f\u0441", + "user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", "version": "\u0412\u0435\u0440\u0441\u0438\u044f", "virtualenv": "\u0412\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u043e\u0435 \u043e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435" } diff --git a/homeassistant/components/homeassistant/translations/sl.json b/homeassistant/components/homeassistant/translations/sl.json index 64e972f5a4452..ff641d767922b 100644 --- a/homeassistant/components/homeassistant/translations/sl.json +++ b/homeassistant/components/homeassistant/translations/sl.json @@ -4,7 +4,6 @@ "arch": "Arhitektura CPU", "dev": "Razvoj", "docker": "Docker", - "docker_version": "Docker", "hassio": "Nadzornik", "installation_type": "Vrsta namestitve", "os_version": "Razli\u010dica operacijskega sistema", diff --git a/homeassistant/components/homeassistant/translations/tr.json b/homeassistant/components/homeassistant/translations/tr.json index c2b7ca1b10cfb..cffbee33e747f 100644 --- a/homeassistant/components/homeassistant/translations/tr.json +++ b/homeassistant/components/homeassistant/translations/tr.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU Mimarisi", - "chassis": "Ana G\u00f6vde", "dev": "Geli\u015ftirme", - "docker": "Konteyner", - "docker_version": "Konteyner", + "docker": "Docker", "hassio": "S\u00fcperviz\u00f6r", - "host_os": "Home Assistant OS", "installation_type": "Kurulum T\u00fcr\u00fc", "os_name": "\u0130\u015fletim Sistemi Ailesi", "os_version": "\u0130\u015fletim Sistemi S\u00fcr\u00fcm\u00fc", "python_version": "Python S\u00fcr\u00fcm\u00fc", - "supervisor": "S\u00fcperviz\u00f6r", "timezone": "Saat dilimi", + "user": "Kullan\u0131c\u0131", "version": "S\u00fcr\u00fcm", "virtualenv": "Sanal Ortam" } diff --git a/homeassistant/components/homeassistant/translations/uk.json b/homeassistant/components/homeassistant/translations/uk.json index 19e07c8f82252..b35506b61381f 100644 --- a/homeassistant/components/homeassistant/translations/uk.json +++ b/homeassistant/components/homeassistant/translations/uk.json @@ -2,17 +2,13 @@ "system_health": { "info": { "arch": "\u0410\u0440\u0445\u0456\u0442\u0435\u043a\u0442\u0443\u0440\u0430 \u0426\u041f", - "chassis": "\u0428\u0430\u0441\u0456", "dev": "\u0421\u0435\u0440\u0435\u0434\u043e\u0432\u0438\u0449\u0435 \u0440\u043e\u0437\u0440\u043e\u0431\u043a\u0438", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "\u0422\u0438\u043f \u0456\u043d\u0441\u0442\u0430\u043b\u044f\u0446\u0456\u0457", "os_name": "\u0421\u0456\u043c\u0435\u0439\u0441\u0442\u0432\u043e \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439\u043d\u0438\u0445 \u0441\u0438\u0441\u0442\u0435\u043c", "os_version": "\u0412\u0435\u0440\u0441\u0456\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439\u043d\u043e\u0457 \u0441\u0438\u0441\u0442\u0435\u043c\u0438", "python_version": "\u0412\u0435\u0440\u0441\u0456\u044f Python", - "supervisor": "Supervisor", "timezone": "\u0427\u0430\u0441\u043e\u0432\u0438\u0439 \u043f\u043e\u044f\u0441", "version": "\u0412\u0435\u0440\u0441\u0456\u044f", "virtualenv": "\u0412\u0456\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u0435 \u043e\u0442\u043e\u0447\u0435\u043d\u043d\u044f" diff --git a/homeassistant/components/homeassistant/translations/zh-Hans.json b/homeassistant/components/homeassistant/translations/zh-Hans.json index 6d6e1f2eed8d2..e640d502e0c84 100644 --- a/homeassistant/components/homeassistant/translations/zh-Hans.json +++ b/homeassistant/components/homeassistant/translations/zh-Hans.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU \u67b6\u6784", - "chassis": "\u673a\u7bb1", "dev": "\u5f00\u53d1\u7248", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "\u5b89\u88c5\u7c7b\u578b", "os_name": "\u64cd\u4f5c\u7cfb\u7edf\u7cfb\u5217", "os_version": "\u64cd\u4f5c\u7cfb\u7edf\u7248\u672c", "python_version": "Python \u7248\u672c", - "supervisor": "Supervisor", "timezone": "\u65f6\u533a", + "user": "\u7528\u6237", "version": "\u7248\u672c", "virtualenv": "\u865a\u62df\u73af\u5883" } diff --git a/homeassistant/components/homeassistant/translations/zh-Hant.json b/homeassistant/components/homeassistant/translations/zh-Hant.json index eba7a8034db9d..21897b0456007 100644 --- a/homeassistant/components/homeassistant/translations/zh-Hant.json +++ b/homeassistant/components/homeassistant/translations/zh-Hant.json @@ -2,18 +2,15 @@ "system_health": { "info": { "arch": "CPU \u67b6\u69cb", - "chassis": "Chassis", "dev": "\u958b\u767c\u7248", "docker": "Docker", - "docker_version": "Docker", "hassio": "Supervisor", - "host_os": "Home Assistant OS", "installation_type": "\u5b89\u88dd\u985e\u578b", "os_name": "\u4f5c\u696d\u7cfb\u7d71\u5bb6\u65cf", "os_version": "\u4f5c\u696d\u7cfb\u7d71\u7248\u672c", "python_version": "Python \u7248\u672c", - "supervisor": "Supervisor", "timezone": "\u6642\u5340", + "user": "\u4f7f\u7528\u8005", "version": "\u7248\u672c", "virtualenv": "\u865b\u64ec\u74b0\u5883" } diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 2e78a93315dae..b0d817478dcd6 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,16 +1,23 @@ """Offer event listening automation rules.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM -from homeassistant.core import HassJob, callback +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, template - -# mypy: allow-untyped-defs +from homeassistant.helpers.typing import ConfigType CONF_EVENT_TYPE = "event_type" CONF_EVENT_CONTEXT = "context" -TRIGGER_SCHEMA = vol.Schema( +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "event", vol.Required(CONF_EVENT_TYPE): vol.All(cv.ensure_list, [cv.template]), @@ -20,7 +27,7 @@ ) -def _schema_value(value): +def _schema_value(value: Any) -> Any: if isinstance(value, list): return vol.In(value) @@ -28,13 +35,16 @@ def _schema_value(value): async def async_attach_trigger( - hass, config, action, automation_info, *, platform_type="event" -): + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, + *, + platform_type: str = "event", +) -> CALLBACK_TYPE: """Listen for events based on configuration.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None - variables = None - if automation_info: - variables = automation_info.get("variables") + trigger_data = automation_info["trigger_data"] + variables = automation_info["variables"] template.attach(hass, config[CONF_EVENT_TYPE]) event_types = template.render_complex( @@ -76,7 +86,7 @@ async def async_attach_trigger( job = HassJob(action) @callback - def handle_event(event): + def handle_event(event: Event) -> None: """Listen for events and calls the action when data matches.""" try: # Check that the event data and context match the configured @@ -93,10 +103,10 @@ def handle_event(event): job, { "trigger": { + **trigger_data, "platform": platform_type, "event": event, "description": f"event '{event.event_type}'", - "id": trigger_id, } }, event.context, @@ -107,7 +117,7 @@ def handle_event(event): ] @callback - def remove_listen_events(): + def remove_listen_events() -> None: """Remove event listeners.""" for remove in removes: remove() diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 2f3ae8e6ad238..6f2ec75e313d7 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -3,13 +3,14 @@ from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HassJob, callback +from homeassistant.helpers import config_validation as cv # mypy: allow-untyped-defs EVENT_START = "start" EVENT_SHUTDOWN = "shutdown" -TRIGGER_SCHEMA = vol.Schema( +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "homeassistant", vol.Required(CONF_EVENT): vol.Any(EVENT_START, EVENT_SHUTDOWN), @@ -19,7 +20,7 @@ async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None + trigger_data = automation_info["trigger_data"] event = config.get(CONF_EVENT) job = HassJob(action) @@ -32,10 +33,10 @@ def hass_shutdown(event): job, { "trigger": { + **trigger_data, "platform": "homeassistant", "event": event, "description": "Home Assistant stopping", - "id": trigger_id, } }, event.context, @@ -50,10 +51,10 @@ def hass_shutdown(event): job, { "trigger": { + **trigger_data, "platform": "homeassistant", "event": event, "description": "Home Assistant starting", - "id": trigger_id, } }, ) diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 05eed9ee27bc3..823bb608b4dc9 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -13,12 +13,18 @@ CONF_PLATFORM, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, callback -from homeassistant.helpers import condition, config_validation as cv, template +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, + template, +) from homeassistant.helpers.event import ( async_track_same_state, async_track_state_change_event, ) +from homeassistant.helpers.typing import ConfigType # mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs @@ -43,11 +49,11 @@ def validate_above_below(value): return value -TRIGGER_SCHEMA = vol.All( - vol.Schema( +_TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "numeric_state", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, vol.Optional(CONF_BELOW): cv.NUMERIC_STATE_THRESHOLD_SCHEMA, vol.Optional(CONF_ABOVE): cv.NUMERIC_STATE_THRESHOLD_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -62,6 +68,18 @@ def validate_above_below(value): _LOGGER = logging.getLogger(__name__) +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + config = _TRIGGER_SCHEMA(config) + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID]) + ) + return config + + async def async_attach_trigger( hass, config, action, automation_info, *, platform_type="numeric_state" ) -> CALLBACK_TYPE: @@ -78,10 +96,8 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) - trigger_id = automation_info.get("trigger_id") if automation_info else None - _variables = {} - if automation_info: - _variables = automation_info.get("variables") or {} + trigger_data = automation_info["trigger_data"] + _variables = automation_info["variables"] or {} if value_template is not None: value_template.hass = hass @@ -132,6 +148,7 @@ def call_action(): job, { "trigger": { + **trigger_data, "platform": platform_type, "entity_id": entity_id, "below": below, @@ -140,7 +157,6 @@ def call_action(): "to_state": to_s, "for": time_delta if not time_delta else period[entity_id], "description": f"numeric state of {entity_id}", - "id": trigger_id, } }, to_s.context, diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 69cddbfe12640..e16416c2f13cf 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -3,20 +3,24 @@ from datetime import timedelta import logging -from typing import Any import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + template, +) from homeassistant.helpers.event import ( Event, async_track_same_state, async_track_state_change_event, process_state_match, ) +from homeassistant.helpers.typing import ConfigType # mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs @@ -27,42 +31,51 @@ CONF_FROM = "from" CONF_TO = "to" -BASE_SCHEMA = { - vol.Required(CONF_PLATFORM): "state", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_FOR): cv.positive_time_period_template, - vol.Optional(CONF_ATTRIBUTE): cv.match_all, -} +BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): "state", + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, + vol.Optional(CONF_FOR): cv.positive_time_period_template, + vol.Optional(CONF_ATTRIBUTE): cv.match_all, + } +) -TRIGGER_STATE_SCHEMA = vol.Schema( +TRIGGER_STATE_SCHEMA = BASE_SCHEMA.extend( { - **BASE_SCHEMA, # These are str on purpose. Want to catch YAML conversions - vol.Optional(CONF_FROM): vol.Any(str, [str]), - vol.Optional(CONF_TO): vol.Any(str, [str]), + vol.Optional(CONF_FROM): vol.Any(str, [str], None), + vol.Optional(CONF_TO): vol.Any(str, [str], None), } ) -TRIGGER_ATTRIBUTE_SCHEMA = vol.Schema( +TRIGGER_ATTRIBUTE_SCHEMA = BASE_SCHEMA.extend( { - **BASE_SCHEMA, vol.Optional(CONF_FROM): cv.match_all, vol.Optional(CONF_TO): cv.match_all, } ) -def TRIGGER_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name - """Validate trigger.""" - if not isinstance(value, dict): +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + if not isinstance(config, dict): raise vol.Invalid("Expected a dictionary") # We use this approach instead of vol.Any because # this gives better error messages. - if CONF_ATTRIBUTE in value: - return TRIGGER_ATTRIBUTE_SCHEMA(value) + if CONF_ATTRIBUTE in config: + config = TRIGGER_ATTRIBUTE_SCHEMA(config) + else: + config = TRIGGER_STATE_SCHEMA(config) + + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID]) + ) - return TRIGGER_STATE_SCHEMA(value) + return config async def async_attach_trigger( @@ -74,12 +87,16 @@ async def async_attach_trigger( platform_type: str = "state", ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - entity_id = config.get(CONF_ENTITY_ID) - from_state = config.get(CONF_FROM, MATCH_ALL) - to_state = config.get(CONF_TO, MATCH_ALL) + entity_ids = config[CONF_ENTITY_ID] + if (from_state := config.get(CONF_FROM)) is None: + from_state = MATCH_ALL + if (to_state := config.get(CONF_TO)) is None: + to_state = MATCH_ALL time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) - match_all = from_state == MATCH_ALL and to_state == MATCH_ALL + # If neither CONF_FROM or CONF_TO are specified, + # fire on all changes to the state or an attribute + match_all = CONF_FROM not in config and CONF_TO not in config unsub_track_same = {} period: dict[str, timedelta] = {} match_from_state = process_state_match(from_state) @@ -87,10 +104,8 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) - trigger_id = automation_info.get("trigger_id") if automation_info else None - _variables = {} - if automation_info: - _variables = automation_info.get("variables") or {} + trigger_data = automation_info["trigger_data"] + _variables = automation_info["variables"] or {} @callback def state_automation_listener(event: Event): @@ -134,6 +149,7 @@ def call_action(): job, { "trigger": { + **trigger_data, "platform": platform_type, "entity_id": entity, "from_state": from_s, @@ -141,7 +157,6 @@ def call_action(): "for": time_delta if not time_delta else period[entity], "attribute": attribute, "description": f"state of {entity}", - "id": trigger_id, } }, event.context, @@ -171,10 +186,11 @@ def call_action(): ) return - def _check_same_state(_, _2, new_st: State): + def _check_same_state(_, _2, new_st: State | None) -> bool: if new_st is None: return False + cur_value: str | None if attribute is None: cur_value = new_st.state else: @@ -193,7 +209,7 @@ def _check_same_state(_, _2, new_st: State): entity_ids=entity, ) - unsub = async_track_state_change_event(hass, entity_id, state_automation_listener) + unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback def async_remove(): diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 6668672732ec7..49a42d3843d24 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -25,11 +25,11 @@ _TIME_TRIGGER_SCHEMA = vol.Any( cv.time, - vol.All(str, cv.entity_domain(("input_datetime", "sensor"))), + vol.All(str, cv.entity_domain(["input_datetime", "sensor"])), msg="Expected HH:MM, HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'", ) -TRIGGER_SCHEMA = vol.Schema( +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]), @@ -39,7 +39,7 @@ async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None + trigger_data = automation_info["trigger_data"] entities = {} removes = [] job = HassJob(action) @@ -51,11 +51,11 @@ def time_automation_listener(description, now, *, entity_id=None): job, { "trigger": { + **trigger_data, "platform": "time", "now": now, "description": description, "entity_id": entity_id, - "id": trigger_id, } }, ) @@ -69,8 +69,7 @@ def update_entity_trigger_event(event): def update_entity_trigger(entity_id, new_state=None): """Update the entity trigger for the entity_id.""" # If a listener was already set up for entity, remove it. - remove = entities.pop(entity_id, None) - if remove: + if remove := entities.pop(entity_id, None): remove() remove = None @@ -79,13 +78,11 @@ def update_entity_trigger(entity_id, new_state=None): # Check state of entity. If valid, set up a listener. if new_state.domain == "input_datetime": - has_date = new_state.attributes["has_date"] - if has_date: + if has_date := new_state.attributes["has_date"]: year = new_state.attributes["year"] month = new_state.attributes["month"] day = new_state.attributes["day"] - has_time = new_state.attributes["has_time"] - if has_time: + if has_time := new_state.attributes["has_time"]: hour = new_state.attributes["hour"] minute = new_state.attributes["minute"] second = new_state.attributes["second"] @@ -131,7 +128,7 @@ def update_entity_trigger(entity_id, new_state=None): elif ( new_state.domain == "sensor" and new_state.attributes.get(ATTR_DEVICE_CLASS) - == sensor.DEVICE_CLASS_TIMESTAMP + == sensor.SensorDeviceClass.TIMESTAMP and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): trigger_dt = dt_util.parse_datetime(new_state.state) diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 859f76b773bbb..000d73b6cd191 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -43,7 +43,7 @@ def __call__(self, value): TRIGGER_SCHEMA = vol.All( - vol.Schema( + cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "time_pattern", CONF_HOURS: TimePattern(maximum=23), @@ -57,7 +57,7 @@ def __call__(self, value): async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None + trigger_data = automation_info["trigger_data"] hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) @@ -76,10 +76,10 @@ def time_automation_listener(now): job, { "trigger": { + **trigger_data, "platform": "time_pattern", "now": now, "description": "time pattern", - "id": trigger_id, } }, ) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 87742104b860e..503a76418a9f5 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,4 +1,6 @@ """Support for Apple HomeKit.""" +from __future__ import annotations + import asyncio import ipaddress import logging @@ -8,39 +10,42 @@ from pyhap.const import STANDALONE_AID import voluptuous as vol -from homeassistant.components import zeroconf +from homeassistant.components import device_automation, network, zeroconf from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, ) from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.network.const import MDNS_TARGET_IP +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_DEVICES, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration -from homeassistant.util import get_local_ip from . import ( # noqa: F401 type_cameras, @@ -59,14 +64,10 @@ from .accessories import HomeBridge, HomeDriver, get_accessory from .aidmanager import AccessoryAidStorage from .const import ( - ATTR_INTERGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, + ATTR_INTEGRATION, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CONF_ADVERTISE_IP, - CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_ENTRY_INDEX, CONF_EXCLUDE_ACCESSORY_MODE, @@ -77,14 +78,10 @@ CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, - CONF_SAFE_MODE, - CONF_ZEROCONF_DEFAULT_INTERFACE, CONFIG_OPTIONS, - DEFAULT_AUTO_START, DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, DEFAULT_PORT, - DEFAULT_SAFE_MODE, DOMAIN, HOMEKIT, HOMEKIT_MODE_ACCESSORY, @@ -92,17 +89,20 @@ HOMEKIT_PAIRING_QR, HOMEKIT_PAIRING_QR_SECRET, MANUFACTURER, + PERSIST_LOCK, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) +from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, - dismiss_setup_message, + async_dismiss_setup_message, + async_port_is_available, + async_show_setup_message, get_persist_fullpath_for_entry_id, - port_is_available, remove_state_files_for_entry_id, - show_setup_message, state_needs_accessory_mode, validate_entity_config, ) @@ -119,6 +119,10 @@ PORT_CLEANUP_CHECK_INTERVAL_SECS = 1 +_HOMEKIT_CONFIG_UPDATE_TIME = ( + 5 # number of seconds to wait for homekit to see the c# change +) + def _has_all_unique_names_and_ports(bridges): """Validate that each homekit bridge configured has a unique name.""" @@ -130,9 +134,6 @@ def _has_all_unique_names_and_ports(bridges): BRIDGE_SCHEMA = vol.All( - cv.deprecated(CONF_ZEROCONF_DEFAULT_INTERFACE), - cv.deprecated(CONF_SAFE_MODE), - cv.deprecated(CONF_AUTO_START), vol.Schema( { vol.Optional(CONF_HOMEKIT_MODE, default=DEFAULT_HOMEKIT_MODE): vol.In( @@ -144,11 +145,9 @@ def _has_all_unique_names_and_ports(bridges): vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, - vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean, vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, - vol.Optional(CONF_ZEROCONF_DEFAULT_INTERFACE): cv.boolean, + vol.Optional(CONF_DEVICES): cv.ensure_list, }, extra=vol.ALLOW_EXTRA, ), @@ -165,6 +164,21 @@ def _has_all_unique_names_and_ports(bridges): ) +UNPAIR_SERVICE_SCHEMA = vol.All( + vol.Schema(cv.ENTITY_SERVICE_FIELDS), + cv.has_at_least_one_key(ATTR_DEVICE_ID), +) + + +def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: + """All active HomeKit instances.""" + return [ + data[HOMEKIT] + for data in hass.data[DOMAIN].values() + if isinstance(data, dict) and HOMEKIT in data + ] + + def _async_get_entries_by_name(current_entries): """Return a dict of the entries by name.""" @@ -173,9 +187,9 @@ def _async_get_entries_by_name(current_entries): return {entry.data.get(CONF_NAME, BRIDGE_NAME): entry for entry in current_entries} -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomeKit from yaml.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, {})[PERSIST_LOCK] = asyncio.Lock() _async_register_events_and_services(hass) @@ -222,8 +236,9 @@ def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf): data = conf.copy() options = {} for key in CONFIG_OPTIONS: - options[key] = data[key] - del data[key] + if key in data: + options[key] = data[key] + del data[key] hass.config_entries.async_update_entry(entry, data=data, options=options) return True @@ -231,7 +246,7 @@ def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf): return False -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HomeKit from a config entry.""" _async_import_options_from_data_if_missing(hass, entry) @@ -243,7 +258,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): _LOGGER.debug("Begin setup HomeKit for %s", name) # ip_address and advertise_ip are yaml only - ip_address = conf.get(CONF_IP_ADDRESS) + ip_address = conf.get( + CONF_IP_ADDRESS, await network.async_get_source_ip(hass, MDNS_TARGET_IP) + ) advertise_ip = conf.get(CONF_ADVERTISE_IP) # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after @@ -258,8 +275,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ) homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() - auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) + devices = options.get(CONF_DEVICES, []) homekit = HomeKit( hass, @@ -273,6 +290,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): advertise_ip, entry.entry_id, entry.title, + devices=devices, ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -284,7 +302,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if hass.state == CoreState.running: await homekit.async_start() - elif auto_start: + else: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start) return True @@ -297,9 +315,9 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - dismiss_setup_message(hass, entry.entry_id) + async_dismiss_setup_message(hass, entry.entry_id) homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] if homekit.status == STATUS_RUNNING: @@ -307,7 +325,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): logged_shutdown_wait = False for _ in range(0, SHUTDOWN_TIMEOUT): - if await hass.async_add_executor_job(port_is_available, entry.data[CONF_PORT]): + if async_port_is_available(entry.data[CONF_PORT]): break if not logged_shutdown_wait: @@ -348,12 +366,9 @@ def _async_register_events_and_services(hass: HomeAssistant): """Register events and services for HomeKit.""" hass.http.register_view(HomeKitPairingQRView) - def handle_homekit_reset_accessory(service): - """Handle start HomeKit service call.""" - for entry_id in hass.data[DOMAIN]: - if HOMEKIT not in hass.data[DOMAIN][entry_id]: - continue - homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + async def async_handle_homekit_reset_accessory(service): + """Handle reset accessory HomeKit service call.""" + for homekit in _async_all_homekit_instances(hass): if homekit.status != STATUS_RUNNING: _LOGGER.warning( "HomeKit is not running. Either it is waiting to be " @@ -362,22 +377,51 @@ def handle_homekit_reset_accessory(service): continue entity_ids = service.data.get("entity_id") - homekit.reset_accessories(entity_ids) + await homekit.async_reset_accessories(entity_ids) hass.services.async_register( DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY, - handle_homekit_reset_accessory, + async_handle_homekit_reset_accessory, schema=RESET_ACCESSORY_SERVICE_SCHEMA, ) + async def async_handle_homekit_unpair(service): + """Handle unpair HomeKit service call.""" + referenced = async_extract_referenced_entity_ids(hass, service) + dev_reg = device_registry.async_get(hass) + for device_id in referenced.referenced_devices: + if not (dev_reg_ent := dev_reg.async_get(device_id)): + raise HomeAssistantError(f"No device found for device id: {device_id}") + macs = [ + cval + for ctype, cval in dev_reg_ent.connections + if ctype == device_registry.CONNECTION_NETWORK_MAC + ] + matching_instances = [ + homekit + for homekit in _async_all_homekit_instances(hass) + if homekit.driver + and device_registry.format_mac(homekit.driver.state.mac) in macs + ] + if not matching_instances: + raise HomeAssistantError( + f"No homekit accessory found for device id: {device_id}" + ) + for homekit in matching_instances: + homekit.async_unpair() + + hass.services.async_register( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + async_handle_homekit_unpair, + schema=UNPAIR_SERVICE_SCHEMA, + ) + async def async_handle_homekit_service_start(service): """Handle start HomeKit service call.""" tasks = [] - for entry_id in hass.data[DOMAIN]: - if HOMEKIT not in hass.data[DOMAIN][entry_id]: - continue - homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + for homekit in _async_all_homekit_instances(hass): if homekit.status == STATUS_RUNNING: _LOGGER.debug("HomeKit is already running") continue @@ -437,6 +481,7 @@ def __init__( advertise_ip=None, entry_id=None, entry_title=None, + devices=None, ): """Initialize a HomeKit object.""" self.hass = hass @@ -450,15 +495,15 @@ def __init__( self._entry_id = entry_id self._entry_title = entry_title self._homekit_mode = homekit_mode + self._devices = devices or [] self.aid_storage = None self.status = STATUS_READY self.bridge = None self.driver = None - def setup(self, zeroconf_instance): + def setup(self, async_zeroconf_instance, uuid): """Set up bridge and accessory driver.""" - ip_addr = self._ip_address or get_local_ip() persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) self.driver = HomeDriver( @@ -467,63 +512,81 @@ def setup(self, zeroconf_instance): self._name, self._entry_title, loop=self.hass.loop, - address=ip_addr, + address=self._ip_address, port=self._port, persist_file=persist_file, advertised_address=self._advertise_ip, - zeroconf_instance=zeroconf_instance, + async_zeroconf_instance=async_zeroconf_instance, + zeroconf_server=f"{uuid}-hap.local.", ) # If we do not load the mac address will be wrong # as pyhap uses a random one until state is restored if os.path.exists(persist_file): self.driver.load() - self.driver.state.config_version += 1 - if self.driver.state.config_version > 65535: - self.driver.state.config_version = 1 - - self.driver.persist() - def reset_accessories(self, entity_ids): + async def async_reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" if not self.bridge: - self.driver.config_changed() + await self.async_reset_accessories_in_accessory_mode(entity_ids) return + await self.async_reset_accessories_in_bridge_mode(entity_ids) - removed = [] + async def async_reset_accessories_in_accessory_mode(self, entity_ids): + """Reset accessories in accessory mode.""" + acc = self.driver.accessory + if acc.entity_id not in entity_ids: + return + await acc.stop() + if not (state := self.hass.states.get(acc.entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) + return + if new_acc := self._async_create_single_accessory([state]): + self.driver.accessory = new_acc + self.hass.async_add_job(new_acc.run) + await self.async_config_changed() + + async def async_reset_accessories_in_bridge_mode(self, entity_ids): + """Reset accessories in bridge mode.""" + new = [] for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( "HomeKit Bridge %s will reset accessory with linked entity_id %s", self._name, entity_id, ) + acc = await self.async_remove_bridge_accessory(aid) + if state := self.hass.states.get(acc.entity_id): + new.append(state) + else: + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) - acc = self.remove_bridge_accessory(aid) - removed.append(acc) - - if not removed: + if not new: # No matched accessories, probably on another bridge return - self.driver.config_changed() + await self.async_config_changed() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + for state in new: + acc = self.add_bridge_accessory(state) + if acc: + self.hass.async_add_job(acc.run) + await self.async_config_changed() - for acc in removed: - self.bridge.add_accessory(acc) - self.driver.config_changed() + async def async_config_changed(self): + """Call config changed which writes out the new config to disk.""" + await self.hass.async_add_executor_job(self.driver.config_changed) def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" - # The bridge itself counts as an accessory - if len(self.bridge.accessories) + 1 >= MAX_DEVICES: - _LOGGER.warning( - "Cannot add %s as this would exceed the %d device limit. Consider using the filter option", - state.entity_id, - MAX_DEVICES, - ) + if self._would_exceed_max_devices(state.entity_id): return if state_needs_accessory_mode(state): @@ -539,7 +602,7 @@ def add_bridge_accessory(self, state): ) aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) - conf = self._config.pop(state.entity_id, {}) + conf = self._config.get(state.entity_id, {}).copy() # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent # the rest of the accessories from being created @@ -547,16 +610,53 @@ def add_bridge_accessory(self, state): acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) + return acc except Exception: # pylint: disable=broad-except _LOGGER.exception( "Failed to create a HomeKit accessory for %s", state.entity_id ) + return None + + def _would_exceed_max_devices(self, name): + """Check if adding another devices would reach the limit and log.""" + # The bridge itself counts as an accessory + if len(self.bridge.accessories) + 1 >= MAX_DEVICES: + _LOGGER.warning( + "Cannot add %s as this would exceed the %d device limit. Consider using the filter option", + name, + MAX_DEVICES, + ) + return True + return False + + def add_bridge_triggers_accessory(self, device, device_triggers): + """Add device automation triggers to the bridge.""" + if self._would_exceed_max_devices(device.name): + return + + aid = self.aid_storage.get_or_allocate_aid(device.id, device.id) + # If an accessory cannot be created or added due to an exception + # of any kind (usually in pyhap) it should not prevent + # the rest of the accessories from being created + config = {} + self._fill_config_from_device_registry_entry(device, config) + self.bridge.add_accessory( + DeviceTriggerAccessory( + self.hass, + self.driver, + device.name, + None, + aid, + config, + device_id=device.id, + device_triggers=device_triggers, + ) + ) - def remove_bridge_accessory(self, aid): + async def async_remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" - acc = None - if aid in self.bridge.accessories: - acc = self.bridge.accessories.pop(aid) + if acc := self.bridge.accessories.pop(aid, None): + await acc.stop() return acc async def async_configure_accessories(self): @@ -565,11 +665,11 @@ async def async_configure_accessories(self): ent_reg = entity_registry.async_get(self.hass) device_lookup = ent_reg.async_get_device_class_lookup( { - (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING), - (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION), - (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY), - (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY), - (SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY), + (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.BATTERY_CHARGING), + (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION), + (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.OCCUPANCY), + (SENSOR_DOMAIN, SensorDeviceClass.BATTERY), + (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY), } ) @@ -579,8 +679,7 @@ async def async_configure_accessories(self): if not self._filter(entity_id): continue - ent_reg_ent = ent_reg.async_get(entity_id) - if ent_reg_ent: + if ent_reg_ent := ent_reg.async_get(entity_id): await self._async_set_device_info_attributes( ent_reg_ent, dev_reg, entity_id ) @@ -595,20 +694,28 @@ async def async_start(self, *args): if self.status != STATUS_READY: return self.status = STATUS_WAIT - zc_instance = await zeroconf.async_get_instance(self.hass) - await self.hass.async_add_executor_job(self.setup, zc_instance) + async_zc_instance = await zeroconf.async_get_async_instance(self.hass) + uuid = await self.hass.helpers.instance_id.async_get() + await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) await self.aid_storage.async_initialize() - await self._async_create_accessories() + if not await self._async_create_accessories(): + return self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() + async with self.hass.data[DOMAIN][PERSIST_LOCK]: + await self.hass.async_add_executor_job(self.driver.persist) self.status = STATUS_RUNNING if self.driver.state.paired: return + self._async_show_setup_message() - show_setup_message( + @callback + def _async_show_setup_message(self): + """Show the pairing setup message.""" + async_show_setup_message( self.hass, self._entry_id, accessory_friendly_name(self._entry_title, self.driver.accessory), @@ -616,6 +723,16 @@ async def async_start(self, *args): self.driver.accessory.xhm_uri(), ) + @callback + def async_unpair(self): + """Remove all pairings for an accessory so it can be repaired.""" + state = self.driver.state + for client_uuid in list(state.paired_clients): + state.remove_paired_client(client_uuid) + self.driver.async_persist() + self.driver.async_update_advertisement() + self._async_show_setup_message() + @callback def _async_register_bridge(self): """Register the bridge as a device so homekit_controller and exclude it from discovery.""" @@ -645,7 +762,7 @@ def _async_register_bridge(self): manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), model=f"HomeKit {hk_mode_name}", - entry_type="service", + entry_type=device_registry.DeviceEntryType.SERVICE, ) @callback @@ -662,21 +779,69 @@ def _async_purge_old_bridges(self, dev_reg, identifier, connection): for device_id in devices_to_purge: dev_reg.async_remove_device(device_id) + @callback + def _async_create_single_accessory(self, entity_states): + """Create a single HomeKit accessory (accessory mode).""" + if not entity_states: + _LOGGER.error( + "HomeKit %s cannot startup: entity not available: %s", + self._name, + self._filter.config, + ) + return None + state = entity_states[0] + conf = self._config.get(state.entity_id, {}).copy() + acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + if acc is None: + _LOGGER.error( + "HomeKit %s cannot startup: entity not supported: %s", + self._name, + self._filter.config, + ) + return acc + + async def _async_create_bridge_accessory(self, entity_states): + """Create a HomeKit bridge with accessories. (bridge mode).""" + self.bridge = HomeBridge(self.hass, self.driver, self._name) + for state in entity_states: + self.add_bridge_accessory(state) + dev_reg = device_registry.async_get(self.hass) + if self._devices: + valid_device_ids = [] + for device_id in self._devices: + if not dev_reg.async_get(device_id): + _LOGGER.warning( + "HomeKit %s cannot add device %s because it is missing from the device registry", + self._name, + device_id, + ) + else: + valid_device_ids.append(device_id) + for device_id, device_triggers in ( + await device_automation.async_get_device_automations( + self.hass, + device_automation.DeviceAutomationType.TRIGGER, + valid_device_ids, + ) + ).items(): + self.add_bridge_triggers_accessory( + dev_reg.async_get(device_id), device_triggers + ) + return self.bridge + async def _async_create_accessories(self): """Create the accessories.""" entity_states = await self.async_configure_accessories() if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: - state = entity_states[0] - conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + acc = self._async_create_single_accessory(entity_states) else: - self.bridge = HomeBridge(self.hass, self.driver, self._name) - for state in entity_states: - self.add_bridge_accessory(state) - acc = self.bridge + acc = await self._async_create_bridge_accessory(entity_states) + if acc is None: + return False # No need to load/persist as we do it in setup self.driver.accessory = acc + return True async def async_stop(self, *args): """Stop the accessory driver.""" @@ -685,11 +850,6 @@ async def async_stop(self, *args): self.status = STATUS_STOPPED _LOGGER.debug("Driver stop for %s", self._name) await self.driver.async_stop() - if self.bridge: - for acc in self.bridge.accessories.values(): - acc.async_stop() - else: - self.driver.accessory.async_stop() @callback def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): @@ -697,15 +857,15 @@ def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): ent_reg_ent is None or ent_reg_ent.device_id is None or ent_reg_ent.device_id not in device_lookup - or ent_reg_ent.device_class - in (DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_BATTERY) + or (ent_reg_ent.device_class or ent_reg_ent.original_device_class) + in (BinarySensorDeviceClass.BATTERY_CHARGING, SensorDeviceClass.BATTERY) ): return if ATTR_BATTERY_CHARGING not in state.attributes: battery_charging_binary_sensor_entity_id = device_lookup[ ent_reg_ent.device_id - ].get((BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING)) + ].get((BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.BATTERY_CHARGING)) if battery_charging_binary_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_BATTERY_CHARGING_SENSOR, @@ -714,7 +874,7 @@ def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): if ATTR_BATTERY_LEVEL not in state.attributes: battery_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( - (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY) + (SENSOR_DOMAIN, SensorDeviceClass.BATTERY) ) if battery_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( @@ -723,7 +883,7 @@ def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): if state.entity_id.startswith(f"{CAMERA_DOMAIN}."): motion_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( - (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION) + (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION) ) if motion_binary_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( @@ -731,7 +891,7 @@ def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): motion_binary_sensor_entity_id, ) doorbell_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( - (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY) + (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.OCCUPANCY) ) if doorbell_binary_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( @@ -742,7 +902,7 @@ def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): if state.entity_id.startswith(f"{HUMIDIFIER_DOMAIN}."): current_humidity_sensor_entity_id = device_lookup[ ent_reg_ent.device_id - ].get((SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY)) + ].get((SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY)) if current_humidity_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_HUMIDITY_SENSOR, @@ -753,23 +913,29 @@ async def _async_set_device_info_attributes(self, ent_reg_ent, dev_reg, entity_i """Set attributes that will be used for homekit device info.""" ent_cfg = self._config.setdefault(entity_id, {}) if ent_reg_ent.device_id: - dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id) - if dev_reg_ent is not None: - # Handle missing devices - if dev_reg_ent.manufacturer: - ent_cfg[ATTR_MANUFACTURER] = dev_reg_ent.manufacturer - if dev_reg_ent.model: - ent_cfg[ATTR_MODEL] = dev_reg_ent.model - if dev_reg_ent.sw_version: - ent_cfg[ATTR_SOFTWARE_VERSION] = dev_reg_ent.sw_version + if dev_reg_ent := dev_reg.async_get(ent_reg_ent.device_id): + self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg) if ATTR_MANUFACTURER not in ent_cfg: try: integration = await async_get_integration( self.hass, ent_reg_ent.platform ) - ent_cfg[ATTR_INTERGRATION] = integration.name + ent_cfg[ATTR_INTEGRATION] = integration.name except IntegrationNotFound: - ent_cfg[ATTR_INTERGRATION] = ent_reg_ent.platform + ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform + + def _fill_config_from_device_registry_entry(self, device_entry, config): + """Populate a config dict from the registry.""" + if device_entry.manufacturer: + config[ATTR_MANUFACTURER] = device_entry.manufacturer + if device_entry.model: + config[ATTR_MODEL] = device_entry.model + if device_entry.sw_version: + config[ATTR_SW_VERSION] = device_entry.sw_version + if device_entry.config_entries: + first_entry = list(device_entry.config_entries)[0] + if entry := self.hass.config_entries.async_get_entry(first_entry): + config[ATTR_INTEGRATION] = entry.domain class HomeKitPairingQRView(HomeAssistantView): @@ -781,6 +947,7 @@ class HomeKitPairingQRView(HomeAssistantView): async def get(self, request): """Retrieve the pairing QRCode image.""" + # pylint: disable=no-self-use if not request.query_string: raise Unauthorized() entry_id, secret = request.query_string.split("-") diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 3aeaa31faed05..23f546910f1a3 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -4,30 +4,25 @@ from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER +from pyhap.util import callback as pyhap_callback from homeassistant.components import cover -from homeassistant.components.cover import ( - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_GATE, - DEVICE_CLASS_WINDOW, -) -from homeassistant.components.media_player import DEVICE_CLASS_TV +from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.remote import SUPPORT_ACTIVITY +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_SERVICE, ATTR_SUPPORTED_FEATURES, + ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, STATE_ON, @@ -42,10 +37,7 @@ from .const import ( ATTR_DISPLAY_NAME, - ATTR_INTERGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, + ATTR_INTEGRATION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, @@ -57,13 +49,19 @@ CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, - DEVICE_CLASS_PM25, + DOMAIN, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, HK_NOT_CHARGING, MANUFACTURER, + MAX_MANUFACTURER_LENGTH, + MAX_MODEL_LENGTH, + MAX_NAME_LENGTH, + MAX_SERIAL_LENGTH, + MAX_VERSION_LENGTH, SERV_BATTERY_SERVICE, + SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -73,10 +71,10 @@ ) from .util import ( accessory_friendly_name, + async_dismiss_setup_message, + async_show_setup_message, convert_to_float, - dismiss_setup_message, format_sw_version, - show_setup_message, validate_media_player_features, ) @@ -118,12 +116,17 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 elif state.domain == "cover": device_class = state.attributes.get(ATTR_DEVICE_CLASS) - if device_class in (DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE) and features & ( - cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE + if ( + device_class + in ( + cover.CoverDeviceClass.GARAGE, + cover.CoverDeviceClass.GATE, + ) + and features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE) ): a_type = "GarageDoorOpener" elif ( - device_class == DEVICE_CLASS_WINDOW + device_class == cover.CoverDeviceClass.WINDOW and features & cover.SUPPORT_SET_POSITION ): a_type = "Window" @@ -131,6 +134,11 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 a_type = "WindowCovering" elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): a_type = "WindowCoveringBasic" + elif features & cover.SUPPORT_SET_TILT_POSITION: + # WindowCovering and WindowCoveringBasic both support tilt + # only WindowCovering can handle the covers that are missing + # SUPPORT_SET_POSITION, SUPPORT_OPEN, and SUPPORT_CLOSE + a_type = "WindowCovering" elif state.domain == "fan": a_type = "Fan" @@ -148,7 +156,7 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST, []) - if device_class == DEVICE_CLASS_TV: + if device_class == MediaPlayerDeviceClass.TV: a_type = "TelevisionMediaPlayer" elif validate_media_player_features(state, feature_list): a_type = "MediaPlayer" @@ -157,20 +165,23 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 device_class = state.attributes.get(ATTR_DEVICE_CLASS) unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if device_class == DEVICE_CLASS_TEMPERATURE or unit in ( + if device_class == SensorDeviceClass.TEMPERATURE or unit in ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ): a_type = "TemperatureSensor" - elif device_class == DEVICE_CLASS_HUMIDITY and unit == PERCENTAGE: + elif device_class == SensorDeviceClass.HUMIDITY and unit == PERCENTAGE: a_type = "HumiditySensor" - elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id: + elif ( + device_class == SensorDeviceClass.PM25 + or SensorDeviceClass.PM25 in state.entity_id + ): a_type = "AirQualitySensor" - elif device_class == DEVICE_CLASS_CO: + elif device_class == SensorDeviceClass.CO: a_type = "CarbonMonoxideSensor" - elif device_class == DEVICE_CLASS_CO2 or "co2" in state.entity_id: + elif device_class == SensorDeviceClass.CO2 or "co2" in state.entity_id: a_type = "CarbonDioxideSensor" - elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ("lm", LIGHT_LUX): + elif device_class == SensorDeviceClass.ILLUMINANCE or unit in ("lm", LIGHT_LUX): a_type = "LightSensor" elif state.domain == "switch": @@ -183,9 +194,20 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 elif state.domain == "remote" and features & SUPPORT_ACTIVITY: a_type = "ActivityRemote" - elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): + elif state.domain in ( + "automation", + "button", + "input_boolean", + "input_button", + "remote", + "scene", + "script", + ): a_type = "Switch" + elif state.domain in ("input_select", "select"): + a_type = "SelectSwitch" + elif state.domain == "water_heater": a_type = "WaterHeater" @@ -212,39 +234,58 @@ def __init__( config, *args, category=CATEGORY_OTHER, + device_id=None, **kwargs, ): """Initialize a Accessory object.""" - super().__init__(driver=driver, display_name=name, aid=aid, *args, **kwargs) + super().__init__( + driver=driver, display_name=name[:MAX_NAME_LENGTH], aid=aid, *args, **kwargs + ) self.config = config or {} - domain = split_entity_id(entity_id)[0].replace("_", " ") + if device_id: + self.device_id = device_id + serial_number = device_id + domain = None + else: + self.device_id = None + serial_number = entity_id + domain = split_entity_id(entity_id)[0].replace("_", " ") - if ATTR_MANUFACTURER in self.config: + if self.config.get(ATTR_MANUFACTURER) is not None: manufacturer = self.config[ATTR_MANUFACTURER] - elif ATTR_INTERGRATION in self.config: - manufacturer = self.config[ATTR_INTERGRATION].replace("_", " ").title() - else: + elif self.config.get(ATTR_INTEGRATION) is not None: + manufacturer = self.config[ATTR_INTEGRATION].replace("_", " ").title() + elif domain: manufacturer = f"{MANUFACTURER} {domain}".title() - if ATTR_MODEL in self.config: - model = self.config[ATTR_MODEL] else: + manufacturer = MANUFACTURER + if self.config.get(ATTR_MODEL) is not None: + model = self.config[ATTR_MODEL] + elif domain: model = domain.title() - if ATTR_SOFTWARE_VERSION in self.config: - sw_version = format_sw_version(self.config[ATTR_SOFTWARE_VERSION]) else: + model = MANUFACTURER + sw_version = None + if self.config.get(ATTR_SW_VERSION) is not None: + sw_version = format_sw_version(self.config[ATTR_SW_VERSION]) + if sw_version is None: sw_version = __version__ self.set_info_service( - manufacturer=manufacturer, - model=model, - serial_number=entity_id, - firmware_revision=sw_version, + manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH], + model=model[:MAX_MODEL_LENGTH], + serial_number=serial_number[:MAX_SERIAL_LENGTH], + firmware_revision=sw_version[:MAX_VERSION_LENGTH], ) self.category = category self.entity_id = entity_id self.hass = hass self._subscriptions = [] + + if device_id: + return + self._char_battery = None self._char_charging = None self._char_low_battery = None @@ -379,8 +420,7 @@ def async_update_state_callback(self, new_state): @ha_callback def async_update_linked_battery_callback(self, event): """Handle linked battery sensor state change listener callback.""" - new_state = event.data.get("new_state") - if new_state is None: + if (new_state := event.data.get("new_state")) is None: return if self.linked_battery_charging_sensor: battery_charging_state = None @@ -391,8 +431,7 @@ def async_update_linked_battery_callback(self, event): @ha_callback def async_update_linked_battery_charging_callback(self, event): """Handle linked battery charging sensor state change listener callback.""" - new_state = event.data.get("new_state") - if new_state is None: + if (new_state := event.data.get("new_state")) is None: return self.async_update_battery(None, new_state.state == STATE_ON) @@ -455,7 +494,17 @@ def async_call_service(self, domain, service, service_data, value=None): ) @ha_callback - def async_stop(self): + def async_reset(self): + """Reset and recreate an accessory.""" + self.hass.async_create_task( + self.hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: self.entity_id}, + ) + ) + + async def stop(self): """Cancel any subscriptions when the bridge is stopped.""" while self._subscriptions: self._subscriptions.pop(0)() @@ -480,8 +529,7 @@ def setup_message(self): async def async_get_snapshot(self, info): """Get snapshot from accessory if supported.""" - acc = self.accessories.get(info["aid"]) - if acc is None: + if (acc := self.accessories.get(info["aid"])) is None: raise ValueError("Requested snapshot for missing accessory") if not hasattr(acc, "async_get_snapshot"): raise ValueError( @@ -502,13 +550,15 @@ def __init__(self, hass, entry_id, bridge_name, entry_title, **kwargs): self._bridge_name = bridge_name self._entry_title = entry_title - def pair(self, client_uuid, client_public): + @pyhap_callback + def pair(self, client_uuid, client_public, client_permissions): """Override super function to dismiss setup message if paired.""" - success = super().pair(client_uuid, client_public) + success = super().pair(client_uuid, client_public, client_permissions) if success: - dismiss_setup_message(self.hass, self._entry_id) + async_dismiss_setup_message(self.hass, self._entry_id) return success + @pyhap_callback def unpair(self, client_uuid): """Override super function to show setup message if unpaired.""" super().unpair(client_uuid) @@ -516,7 +566,7 @@ def unpair(self, client_uuid): if self.state.paired: return - show_setup_message( + async_show_setup_message( self.hass, self._entry_id, accessory_friendly_name(self._entry_title, self.accessory), diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 220014d7d4ed0..c7ddc29a788f6 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -65,7 +65,7 @@ class AccessoryAidStorage: persist over reboots. """ - def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Create a new entity map store.""" self.hass = hass self.allocations = {} @@ -82,8 +82,7 @@ async def async_initialize(self): aidstore = get_aid_storage_filename_for_entry_id(self._entry) self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore) - raw_storage = await self.store.async_load() - if not raw_storage: + if not (raw_storage := await self.store.async_load()): # There is no data about aid allocations yet return @@ -92,14 +91,13 @@ async def async_initialize(self): def get_or_allocate_aid_for_entity_id(self, entity_id: str): """Generate a stable aid for an entity id.""" - entity = self._entity_registry.async_get(entity_id) - if not entity: - return self._get_or_allocate_aid(None, entity_id) + if not (entity := self._entity_registry.async_get(entity_id)): + return self.get_or_allocate_aid(None, entity_id) sys_unique_id = get_system_unique_id(entity) - return self._get_or_allocate_aid(sys_unique_id, entity_id) + return self.get_or_allocate_aid(sys_unique_id, entity_id) - def _get_or_allocate_aid(self, unique_id: str, entity_id: str): + def get_or_allocate_aid(self, unique_id: str, entity_id: str): """Allocate (and return) a new aid for an accessory.""" if unique_id and unique_id in self.allocations: return self.allocations[unique_id] diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 6e1cd9bcaedd1..8d2f17a38782c 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,4 +1,7 @@ """Config flow for HomeKit integration.""" +from __future__ import annotations + +import asyncio import random import re import string @@ -6,19 +9,23 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import device_automation from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_FRIENDLY_NAME, + CONF_DEVICES, CONF_DOMAINS, CONF_ENTITIES, CONF_ENTITY_ID, CONF_NAME, CONF_PORT, ) -from homeassistant.core import callback, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -26,15 +33,15 @@ CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES, ) +from homeassistant.loader import async_get_integration from .const import ( - CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, + CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, - DEFAULT_AUTO_START, DEFAULT_CONFIG_FLOW_PORT, DEFAULT_HOMEKIT_MODE, DOMAIN, @@ -46,6 +53,7 @@ ) from .util import async_find_next_available_port, state_needs_accessory_mode +CONF_CAMERA_AUDIO = "camera_audio" CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" @@ -54,7 +62,12 @@ INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] -DOMAINS_NEED_ACCESSORY_MODE = [CAMERA_DOMAIN, MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] +DOMAINS_NEED_ACCESSORY_MODE = [ + CAMERA_DOMAIN, + LOCK_DOMAIN, + MEDIA_PLAYER_DOMAIN, + REMOTE_DOMAIN, +] NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." @@ -63,6 +76,7 @@ "alarm_control_panel", "automation", "binary_sensor", + "button", CAMERA_DOMAIN, "climate", "cover", @@ -71,6 +85,8 @@ "fan", "humidifier", "input_boolean", + "input_button", + "input_select", "light", "lock", MEDIA_PLAYER_DOMAIN, @@ -78,6 +94,7 @@ REMOTE_DOMAIN, "scene", "script", + "select", "sensor", "switch", "vacuum", @@ -108,12 +125,27 @@ } +async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: + """Create a mapping of types of devices/entities HomeKit can support.""" + integrations = await asyncio.gather( + *(async_get_integration(hass, domain) for domain in SUPPORTED_DOMAINS), + return_exceptions=True, + ) + name_to_type_map = { + domain: domain + if isinstance(integrations[idx], Exception) + else integrations[idx].name + for idx, domain in enumerate(SUPPORTED_DOMAINS) + } + return name_to_type_map + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HomeKit.""" VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize config flow.""" self.hk_data = {} @@ -127,13 +159,14 @@ async def async_step_user(self, user_input=None): self.hk_data[CONF_HOMEKIT_MODE] = HOMEKIT_MODE_BRIDGE default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS + name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( step_id="user", data_schema=vol.Schema( { vol.Required( CONF_INCLUDE_DOMAINS, default=default_domains - ): cv.multi_select(SUPPORTED_DOMAINS), + ): cv.multi_select(name_to_type_map), } ), ) @@ -141,9 +174,7 @@ async def async_step_user(self, user_input=None): async def async_step_pairing(self, user_input=None): """Pairing instructions.""" if user_input is not None: - port = await async_find_next_available_port( - self.hass, DEFAULT_CONFIG_FLOW_PORT - ) + port = async_find_next_available_port(self.hass, DEFAULT_CONFIG_FLOW_PORT) await self._async_add_entries_for_accessory_mode_entities(port) self.hk_data[CONF_PORT] = port include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] @@ -174,7 +205,7 @@ async def _async_add_entries_for_accessory_mode_entities(self, last_assigned_por for entity_id in accessory_mode_entity_ids: if entity_id in exiting_entity_ids_accessory_mode: continue - port = await async_find_next_available_port(self.hass, next_port_to_check) + port = async_find_next_available_port(self.hass, next_port_to_check) next_port_to_check = port + 1 self.hass.async_create_task( self.hass.config_entries.flow.async_init( @@ -263,7 +294,7 @@ def async_get_options_flow(config_entry): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for homekit.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry self.hk_options = {} @@ -280,30 +311,40 @@ async def async_step_yaml(self, user_input=None): async def async_step_advanced(self, user_input=None): """Choose advanced options.""" - if not self.show_advanced_options or user_input is not None: + if ( + not self.show_advanced_options + or user_input is not None + or self.hk_options[CONF_HOMEKIT_MODE] != HOMEKIT_MODE_BRIDGE + ): if user_input: self.hk_options.update(user_input) - self.hk_options[CONF_AUTO_START] = self.hk_options.get( - CONF_AUTO_START, DEFAULT_AUTO_START - ) - for key in (CONF_DOMAINS, CONF_ENTITIES): if key in self.hk_options: del self.hk_options[key] + if ( + self.show_advanced_options + and self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE + ): + self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] + return self.async_create_entry(title="", data=self.hk_options) + all_supported_devices = await _async_get_supported_devices(self.hass) + # Strip out devices that no longer exist to prevent error in the UI + devices = [ + device_id + for device_id in self.hk_options.get(CONF_DEVICES, []) + if device_id in all_supported_devices + ] return self.async_show_form( step_id="advanced", data_schema=vol.Schema( { - vol.Optional( - CONF_AUTO_START, - default=self.hk_options.get( - CONF_AUTO_START, DEFAULT_AUTO_START - ), - ): bool + vol.Optional(CONF_DEVICES, default=devices): cv.multi_select( + all_supported_devices + ) } ), ) @@ -322,14 +363,24 @@ async def async_step_cameras(self, user_input=None): and CONF_VIDEO_CODEC in entity_config[entity_id] ): del entity_config[entity_id][CONF_VIDEO_CODEC] + if entity_id in user_input[CONF_CAMERA_AUDIO]: + entity_config.setdefault(entity_id, {})[CONF_SUPPORT_AUDIO] = True + elif ( + entity_id in entity_config + and CONF_SUPPORT_AUDIO in entity_config[entity_id] + ): + del entity_config[entity_id][CONF_SUPPORT_AUDIO] return await self.async_step_advanced() + cameras_with_audio = [] cameras_with_copy = [] entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: hk_entity_config = entity_config.get(entity, {}) if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: cameras_with_copy.append(entity) + if hk_entity_config.get(CONF_SUPPORT_AUDIO): + cameras_with_audio.append(entity) data_schema = vol.Schema( { @@ -337,6 +388,10 @@ async def async_step_cameras(self, user_input=None): CONF_CAMERA_COPY, default=cameras_with_copy, ): cv.multi_select(self.included_cameras), + vol.Optional( + CONF_CAMERA_AUDIO, + default=cameras_with_audio, + ): cv.multi_select(self.included_cameras), } ) return self.async_show_form(step_id="cameras", data_schema=data_schema) @@ -386,7 +441,6 @@ async def async_step_include_exclude(self, user_input=None): self.included_cameras = set() self.hk_options[CONF_FILTER] = entity_filter - if self.included_cameras: return await self.async_step_cameras() @@ -399,13 +453,11 @@ async def async_step_include_exclude(self, user_input=None): ) data_schema = {} + entity_schema = vol.In entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY: - entity_schema = vol.In - else: - if entities: - include_exclude_mode = MODE_INCLUDE - else: + if self.hk_options[CONF_HOMEKIT_MODE] != HOMEKIT_MODE_ACCESSORY: + include_exclude_mode = MODE_INCLUDE + if not entities: include_exclude_mode = MODE_EXCLUDE entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, []) data_schema[ @@ -413,9 +465,13 @@ async def async_step_include_exclude(self, user_input=None): ] = vol.In(INCLUDE_EXCLUDE_MODES) entity_schema = cv.multi_select - data_schema[vol.Optional(CONF_ENTITIES, default=entities)] = entity_schema( - all_supported_entities - ) + # Strip out entities that no longer exist to prevent error in the UI + valid_entities = [ + entity_id for entity_id in entities if entity_id in all_supported_entities + ] + data_schema[ + vol.Optional(CONF_ENTITIES, default=valid_entities) + ] = entity_schema(all_supported_entities) return self.async_show_form( step_id="include_exclude", data_schema=vol.Schema(data_schema) @@ -437,6 +493,7 @@ async def async_step_init(self, user_input=None): include_entities = entity_filter.get(CONF_INCLUDE_ENTITIES) if include_entities: domains.extend(_domains_set_from_entities(include_entities)) + name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( step_id="init", @@ -448,12 +505,25 @@ async def async_step_init(self, user_input=None): vol.Required( CONF_DOMAINS, default=domains, - ): cv.multi_select(SUPPORTED_DOMAINS), + ): cv.multi_select(name_to_type_map), } ), ) +async def _async_get_supported_devices(hass): + """Return all supported devices.""" + results = await device_automation.async_get_device_automations( + hass, device_automation.DeviceAutomationType.TRIGGER + ) + dev_reg = device_registry.async_get(hass) + unsorted = { + device_id: dev_reg.async_get(device_id).name or device_id + for device_id in results + } + return dict(sorted(unsorted.items(), key=lambda item: item[1])) + + def _async_get_matching_entities(hass, domains=None): """Fetch all entities or entities in the given domains.""" return { diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 073650aba4001..1dd40d0fa31b4 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,5 +1,7 @@ """Constants used be the HomeKit component.""" +from homeassistant.const import CONF_DEVICES + # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 DEVICE_PRECISION_LEEWAY = 6 @@ -10,6 +12,7 @@ HOMEKIT = "homekit" SHUTDOWN_TIMEOUT = 30 CONF_ENTRY_INDEX = "index" +PERSIST_LOCK = "persist_lock" # ### Codecs #### VIDEO_CODEC_COPY = "copy" @@ -21,10 +24,7 @@ # #### Attributes #### ATTR_DISPLAY_NAME = "display_name" ATTR_VALUE = "value" -ATTR_INTERGRATION = "platform" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_INTEGRATION = "platform" ATTR_KEY_NAME = "key_name" # Current attribute used by homekit_controller ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" @@ -35,7 +35,6 @@ CONF_AUDIO_CODEC = "audio_codec" CONF_AUDIO_MAP = "audio_map" CONF_AUDIO_PACKET_SIZE = "audio_packet_size" -CONF_AUTO_START = "auto_start" CONF_ENTITY_CONFIG = "entity_config" CONF_FEATURE = "feature" CONF_FEATURE_LIST = "feature_list" @@ -51,8 +50,6 @@ CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" CONF_MAX_WIDTH = "max_width" -CONF_SAFE_MODE = "safe_mode" -CONF_ZEROCONF_DEFAULT_INTERFACE = "zeroconf_default_interface" CONF_STREAM_ADDRESS = "stream_address" CONF_STREAM_SOURCE = "stream_source" CONF_SUPPORT_AUDIO = "support_audio" @@ -66,7 +63,6 @@ DEFAULT_AUDIO_CODEC = AUDIO_CODEC_OPUS DEFAULT_AUDIO_MAP = "0:a:0" DEFAULT_AUDIO_PACKET_SIZE = 188 -DEFAULT_AUTO_START = True DEFAULT_EXCLUDE_ACCESSORY_MODE = False DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_MAX_FPS = 30 @@ -74,7 +70,6 @@ DEFAULT_MAX_WIDTH = 1920 DEFAULT_PORT = 21063 DEFAULT_CONFIG_FLOW_PORT = 21064 -DEFAULT_SAFE_MODE = False DEFAULT_VIDEO_CODEC = VIDEO_CODEC_LIBX264 DEFAULT_VIDEO_MAP = "0:v:0" DEFAULT_VIDEO_PACKET_SIZE = 1316 @@ -99,6 +94,7 @@ # #### HomeKit Component Services #### SERVICE_HOMEKIT_START = "start" SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" +SERVICE_HOMEKIT_UNPAIR = "unpair" # #### String Constants #### BRIDGE_MODEL = "Bridge" @@ -138,6 +134,7 @@ SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" +SERV_SERVICE_LABEL = "ServiceLabel" SERV_SMOKE_SENSOR = "SmokeSensor" SERV_SPEAKER = "Speaker" SERV_STATELESS_PROGRAMMABLE_SWITCH = "StatelessProgrammableSwitch" @@ -207,6 +204,8 @@ CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" CHAR_SERIAL_NUMBER = "SerialNumber" +CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex" +CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace" CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode" CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" @@ -235,18 +234,6 @@ PROP_CELSIUS = {"minValue": -273, "maxValue": 999} PROP_VALID_VALUES = "ValidValues" -# #### Device Classes #### -DEVICE_CLASS_DOOR = "door" -DEVICE_CLASS_GARAGE_DOOR = "garage_door" -DEVICE_CLASS_GAS = "gas" -DEVICE_CLASS_MOISTURE = "moisture" -DEVICE_CLASS_MOTION = "motion" -DEVICE_CLASS_OCCUPANCY = "occupancy" -DEVICE_CLASS_OPENING = "opening" -DEVICE_CLASS_PM25 = "pm25" -DEVICE_CLASS_SMOKE = "smoke" -DEVICE_CLASS_WINDOW = "window" - # #### Thresholds #### THRESHOLD_CO = 25 THRESHOLD_CO2 = 1000 @@ -290,8 +277,14 @@ # ### Config Options ### CONFIG_OPTIONS = [ CONF_FILTER, - CONF_AUTO_START, - CONF_SAFE_MODE, CONF_ENTITY_CONFIG, CONF_HOMEKIT_MODE, + CONF_DEVICES, ] + +# ### Maximum Lengths ### +MAX_NAME_LENGTH = 64 +MAX_SERIAL_LENGTH = 64 +MAX_MODEL_LENGTH = 64 +MAX_VERSION_LENGTH = 64 +MAX_MANUFACTURER_LENGTH = 64 diff --git a/homeassistant/components/homekit/img_util.py b/homeassistant/components/homekit/img_util.py deleted file mode 100644 index 860d798f1137b..0000000000000 --- a/homeassistant/components/homekit/img_util.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Image processing for HomeKit component.""" - -import logging - -SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] - -_LOGGER = logging.getLogger(__name__) - - -def scale_jpeg_camera_image(cam_image, width, height): - """Scale a camera image as close as possible to one of the supported scaling factors.""" - turbo_jpeg = TurboJPEGSingleton.instance() - if not turbo_jpeg: - return cam_image.content - - (current_width, current_height, _, _) = turbo_jpeg.decode_header(cam_image.content) - - if current_width <= width or current_height <= height: - return cam_image.content - - ratio = width / current_width - - scaling_factor = SUPPORTED_SCALING_FACTORS[-1] - for supported_sf in SUPPORTED_SCALING_FACTORS: - if ratio >= (supported_sf[0] / supported_sf[1]): - scaling_factor = supported_sf - break - - return turbo_jpeg.scale_with_quality( - cam_image.content, - scaling_factor=scaling_factor, - quality=75, - ) - - -class TurboJPEGSingleton: - """ - Load TurboJPEG only once. - - Ensures we do not log load failures each snapshot - since camera image fetches happen every few - seconds. - """ - - __instance = None - - @staticmethod - def instance(): - """Singleton for TurboJPEG.""" - if TurboJPEGSingleton.__instance is None: - TurboJPEGSingleton() - return TurboJPEGSingleton.__instance - - def __init__(self): - """Try to create TurboJPEG only once.""" - try: - # TurboJPEG checks for libturbojpeg - # when its created, but it imports - # numpy which may or may not work so - # we have to guard the import here. - from turbojpeg import TurboJPEG # pylint: disable=import-outside-toplevel - - TurboJPEGSingleton.__instance = TurboJPEG() - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error loading libturbojpeg; Cameras may impact HomeKit performance" - ) - TurboJPEGSingleton.__instance = False diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 0a23d52f17a38..d23aa11b4eae2 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,13 +3,12 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.4.1", + "HAP-python==4.3.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", - "base36==0.1.1", - "PyTurboJPEG==1.4.0" + "base36==0.1.1" ], - "dependencies": ["http", "camera", "ffmpeg"], + "dependencies": ["http", "camera", "ffmpeg", "network"], "after_dependencies": ["zeroconf"], "codeowners": ["@bdraco"], "zeroconf": ["_homekit._tcp.local."], diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index a6b09a80e7ffe..68e7804697b6e 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -1,13 +1,22 @@ # Describes the format for available HomeKit services start: + name: Start description: Starts the HomeKit driver reload: + name: Reload description: Reload homekit and re-process YAML configuration reset_accessory: + name: Reset accessory description: Reset a HomeKit accessory target: entity: {} +unpair: + name: Unpair an accessory or bridge + description: Forcefully remove all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost. + target: + device: + integration: homekit diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 56bc5438eac51..ede11aef19c33 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -23,16 +23,18 @@ }, "cameras": { "data": { - "camera_copy": "Cameras that support native H.264 streams" + "camera_copy": "Cameras that support native H.264 streams", + "camera_audio": "Cameras that support audio" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Select camera video codec." + "title": "Camera Configuration" }, "advanced": { "data": { + "devices": "Devices (Triggers)", "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", + "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", "title": "Advanced Configuration" } } @@ -43,7 +45,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/translations/bg.json b/homeassistant/components/homekit/translations/bg.json new file mode 100644 index 0000000000000..4e5677f124a3f --- /dev/null +++ b/homeassistant/components/homekit/translations/bg.json @@ -0,0 +1,16 @@ +{ + "options": { + "step": { + "include_exclude": { + "data": { + "mode": "\u0420\u0435\u0436\u0438\u043c" + } + }, + "init": { + "data": { + "mode": "\u0420\u0435\u0436\u0438\u043c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json index 8093cb1792f27..63f34999a4de7 100644 --- a/homeassistant/components/homekit/translations/ca.json +++ b/homeassistant/components/homekit/translations/ca.json @@ -4,31 +4,15 @@ "port_name_in_use": "Ja hi ha un enlla\u00e7 o accessori configurat amb aquest nom o port." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entitat" - }, - "description": "Escull l'entitat que vulguis incloure. En mode accessori, nom\u00e9s s'inclou una sola entitat.", - "title": "Selecciona l'entitat a incloure" - }, - "bridge_mode": { - "data": { - "include_domains": "Dominis a incloure" - }, - "description": "Escull els dominis que vulguis incloure. S'inclouran totes les entitats del domini que siguin compatibles.", - "title": "Selecciona els dominis a incloure" - }, "pairing": { "description": "Per completar la vinculaci\u00f3, segueix les instruccions a \"Configuraci\u00f3 de l'enlla\u00e7 HomeKit\" sota \"Notificacions\".", "title": "Vinculaci\u00f3 HomeKit" }, "user": { "data": { - "auto_start": "Autoarrencada (desactiva-ho si fas servir Z-Wave o algun altre sistema d'inici lent)", - "include_domains": "Dominis a incloure", - "mode": "Mode" + "include_domains": "Dominis a incloure" }, - "description": "Selecciona els dominis a incloure. S'inclouran totes les entitats del domini compatibles. Es crear\u00e0 una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV i c\u00e0mera.", + "description": "Selecciona els dominis a incloure. S'inclouran totes les entitats del domini compatibles. Es crear\u00e0 una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV, control remot basat per activitat i c\u00e0mera.", "title": "Selecciona els dominis a incloure" } } @@ -38,17 +22,18 @@ "advanced": { "data": { "auto_start": "Inici autom\u00e0tic (desactiva-ho si crides el servei homekit.start manualment)", - "safe_mode": "Mode segur (habilita-ho nom\u00e9s si falla la vinculaci\u00f3)" + "devices": "Dispositius (disparadors)" }, - "description": "Aquests par\u00e0metres nom\u00e9s s'han d'ajustar si HomeKit no \u00e9s funcional.", + "description": "Els interruptors programables es creen per cada dispositiu seleccionat. HomeKit pot ser programat per a que executi una automatitzaci\u00f3 o escena quan un dispositiu es dispari.", "title": "Configuraci\u00f3 avan\u00e7ada" }, "cameras": { "data": { + "camera_audio": "C\u00e0meres que admeten \u00e0udio", "camera_copy": "C\u00e0meres que admeten fluxos H.264 natius" }, "description": "Comprova les c\u00e0meres que suporten fluxos nadius H.264. Si alguna c\u00e0mera not proporciona una sortida H.264, el sistema transcodificar\u00e0 el v\u00eddeo a H.264 per a HomeKit. La transcodificaci\u00f3 necessita una CPU potent i probablement no funcioni en ordinadors petits (SBC).", - "title": "Selecci\u00f3 del c\u00f2dec de v\u00eddeo de c\u00e0mera" + "title": "Configuraci\u00f3 de c\u00e0mera" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/cs.json b/homeassistant/components/homekit/translations/cs.json index cdfaed9183c1f..60b365ff6842c 100644 --- a/homeassistant/components/homekit/translations/cs.json +++ b/homeassistant/components/homekit/translations/cs.json @@ -4,18 +4,12 @@ "port_name_in_use": "P\u0159\u00edslu\u0161enstv\u00ed nebo p\u0159emost\u011bn\u00ed se stejn\u00fdm n\u00e1zvem nebo portem je ji\u017e nastaveno." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entita" - } - }, "pairing": { "title": "P\u00e1rov\u00e1n\u00ed s HomeKit" }, "user": { "data": { - "include_domains": "Dom\u00e9ny, kter\u00e9 maj\u00ed b\u00fdt zahrnuty", - "mode": "Re\u017eim" + "include_domains": "Dom\u00e9ny, kter\u00e9 maj\u00ed b\u00fdt zahrnuty" }, "title": "Vyberte dom\u00e9ny, kter\u00e9 chcete zahrnout" } @@ -24,9 +18,6 @@ "options": { "step": { "advanced": { - "data": { - "safe_mode": "Nouzov\u00fd re\u017eim (povolit pouze v p\u0159\u00edpad\u011b, \u017ee p\u00e1rov\u00e1n\u00ed sel\u017ee)" - }, "description": "Tato nastaven\u00ed je t\u0159eba upravit pouze v p\u0159\u00edpad\u011b, \u017ee HomeKit nen\u00ed funk\u010dn\u00ed.", "title": "Pokro\u010dil\u00e9 nastaven\u00ed" }, diff --git a/homeassistant/components/homekit/translations/de.json b/homeassistant/components/homekit/translations/de.json index b1f5b23c2646e..a0c407c454e0a 100644 --- a/homeassistant/components/homekit/translations/de.json +++ b/homeassistant/components/homekit/translations/de.json @@ -4,32 +4,16 @@ "port_name_in_use": "Eine HomeKit Bridge mit demselben Namen oder Port ist bereits vorhanden." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entit\u00e4t" - }, - "description": "W\u00e4hle die Entit\u00e4t aus, die aufgenommen werden soll. Im Zubeh\u00f6rmodus ist nur eine einzelne Entit\u00e4t enthalten.", - "title": "W\u00e4hle die Entit\u00e4t aus, die aufgenommen werden soll" - }, - "bridge_mode": { - "data": { - "include_domains": "Einzubeziehende Domains" - }, - "description": "W\u00e4hle die Domains aus, die aufgenommen werden sollen. Alle unterst\u00fctzten Ger\u00e4te innerhalb der Domain werden aufgenommen.", - "title": "W\u00e4hle die Domains aus, die aufgenommen werden sollen" - }, "pairing": { - "description": "Um die Kopplung abzuschlie\u00dfen, folgen Sie den Anweisungen in \"Benachrichtigungen\" unter \"HomeKit-Kopplung\".", + "description": "Um die Kopplung abzuschlie\u00dfen, folge den Anweisungen in \"Benachrichtigungen\" unter \"HomeKit-Kopplung\".", "title": "HomeKit verbinden" }, "user": { "data": { - "auto_start": "Autostart (deaktivieren, wenn Z-Wave oder ein anderes verz\u00f6gertes Startsystem verwendet wird)", - "include_domains": "Einzubeziehende Domains", - "mode": "Modus" + "include_domains": "Einzubeziehende Domains" }, - "description": "W\u00e4hlen Sie die Domains aus, die aufgenommen werden sollen. Alle unterst\u00fctzten Entit\u00e4ten in der Domain werden aufgenommen. F\u00fcr jeden TV-Mediaplayer und jede Kamera wird eine separate HomeKit-Instanz im Zubeh\u00f6rmodus erstellt.", - "title": "HomeKit aktivieren" + "description": "W\u00e4hle die Domains aus, die aufgenommen werden sollen. Alle unterst\u00fctzten Entit\u00e4ten in der Domain werden aufgenommen. F\u00fcr jeden TV-Mediaplayer und jede Kamera wird eine separate HomeKit-Instanz im Zubeh\u00f6rmodus erstellt.", + "title": "W\u00e4hle die zu einzubeziehenden Dom\u00e4nen aus." } } }, @@ -38,24 +22,25 @@ "advanced": { "data": { "auto_start": "Autostart (deaktivieren, wenn du den homekit.start-Dienst manuell aufrufst)", - "safe_mode": "Abgesicherter Modus (nur aktivieren, wenn das Pairing fehlschl\u00e4gt)" + "devices": "Ger\u00e4te (Trigger)" }, - "description": "Diese Einstellungen m\u00fcssen nur angepasst werden, wenn HomeKit nicht funktioniert.", + "description": "F\u00fcr jedes ausgew\u00e4hlte Ger\u00e4t werden programmierbare Schalter erstellt. Wenn ein Ger\u00e4teausl\u00f6ser ausgel\u00f6st wird, kann HomeKit so konfiguriert werden, dass eine Automatisierung oder Szene ausgef\u00fchrt wird.", "title": "Erweiterte Konfiguration" }, "cameras": { "data": { + "camera_audio": "Kameras, die Audio unterst\u00fctzen", "camera_copy": "Kameras, die native H.264-Streams unterst\u00fctzen" }, "description": "Pr\u00fcfe alle Kameras, die native H.264-Streams unterst\u00fctzen. Wenn die Kamera keinen H.264-Stream ausgibt, transkodiert das System das Video in H.264 f\u00fcr HomeKit. Die Transkodierung erfordert eine leistungsstarke CPU und wird wahrscheinlich nicht auf Einplatinencomputern funktionieren.", - "title": "W\u00e4hlen Sie den Kamera-Video-Codec." + "title": "W\u00e4hle den Kamera-Video-Codec." }, "include_exclude": { "data": { "entities": "Entit\u00e4ten", "mode": "Modus" }, - "description": "W\u00e4hlen Sie die einzubeziehenden Entit\u00e4ten aus. Im Zubeh\u00f6rmodus wird nur eine einzelne Entit\u00e4t eingeschlossen. Im Bridge-Include-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, sofern nicht bestimmte Entit\u00e4ten ausgew\u00e4hlt sind. Im Bridge-Exclude-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, au\u00dfer den ausgeschlossenen Entit\u00e4ten. F\u00fcr eine optimale Leistung wird f\u00fcr jeden TV-Media-Player, jede aktivit\u00e4tsbasierte Fernbedienung, jedes Schloss und jede Kamera ein separates HomeKit-Zubeh\u00f6r erstellt.", + "description": "W\u00e4hle die einzubeziehenden Entit\u00e4ten aus. Im Zubeh\u00f6rmodus wird nur eine einzelne Entit\u00e4t eingeschlossen. Im Bridge-Include-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, sofern nicht bestimmte Entit\u00e4ten ausgew\u00e4hlt sind. Im Bridge-Exclude-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, au\u00dfer den ausgeschlossenen Entit\u00e4ten. F\u00fcr eine optimale Leistung wird f\u00fcr jeden TV-Media-Player, jede aktivit\u00e4tsbasierte Fernbedienung, jedes Schloss und jede Kamera ein separates HomeKit-Zubeh\u00f6r erstellt.", "title": "W\u00e4hle die Entit\u00e4ten aus, die aufgenommen werden sollen" }, "init": { @@ -63,8 +48,8 @@ "include_domains": "Einzubeziehende Domains", "mode": "Modus" }, - "description": "HomeKit kann so konfiguriert werden, dass eine Br\u00fccke oder ein einzelnes Zubeh\u00f6r verf\u00fcgbar gemacht wird. Im Zubeh\u00f6rmodus kann nur eine einzelne Entit\u00e4t verwendet werden. F\u00fcr Media Player mit der TV-Ger\u00e4teklasse ist ein Zubeh\u00f6rmodus erforderlich, damit sie ordnungsgem\u00e4\u00df funktionieren. Entit\u00e4ten in den \"einzuschlie\u00dfenden Dom\u00e4nen\" werden f\u00fcr HomeKit verf\u00fcgbar gemacht. Auf dem n\u00e4chsten Bildschirm k\u00f6nnen Sie ausw\u00e4hlen, welche Entit\u00e4ten in diese Liste aufgenommen oder aus dieser ausgeschlossen werden sollen.", - "title": "W\u00e4hle die zu \u00fcberbr\u00fcckenden Dom\u00e4nen aus." + "description": "HomeKit kann so konfiguriert werden, dass eine Br\u00fccke oder ein einzelnes Zubeh\u00f6r verf\u00fcgbar gemacht wird. Im Zubeh\u00f6rmodus kann nur eine einzelne Entit\u00e4t verwendet werden. F\u00fcr Media Player mit der TV-Ger\u00e4teklasse ist ein Zubeh\u00f6rmodus erforderlich, damit sie ordnungsgem\u00e4\u00df funktionieren. Entit\u00e4ten in den \"einzuschlie\u00dfenden Dom\u00e4nen\" werden f\u00fcr HomeKit verf\u00fcgbar gemacht. Auf dem n\u00e4chsten Bildschirm kannst du ausw\u00e4hlen, welche Entit\u00e4ten in diese Liste aufgenommen oder aus dieser ausgeschlossen werden sollen.", + "title": "W\u00e4hle die zu einzubeziehenden Dom\u00e4nen aus." }, "yaml": { "description": "Dieser Eintrag wird \u00fcber YAML gesteuert", diff --git a/homeassistant/components/homekit/translations/el.json b/homeassistant/components/homekit/translations/el.json new file mode 100644 index 0000000000000..58d7a62bc5995 --- /dev/null +++ b/homeassistant/components/homekit/translations/el.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u03a3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 (\u0395\u03bd\u03b1\u03cd\u03c3\u03bc\u03b1\u03c4\u03b1)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index a48b6fdee240e..b118ec16424e0 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -4,31 +4,15 @@ "port_name_in_use": "An accessory or bridge with the same name or port is already configured." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entity" - }, - "description": "Choose the entity to be included. In accessory mode, only a single entity is included.", - "title": "Select entity to be included" - }, - "bridge_mode": { - "data": { - "include_domains": "Domains to include" - }, - "description": "Choose the domains to be included. All supported entities in the domain will be included.", - "title": "Select domains to be included" - }, "pairing": { "description": "To complete pairing following the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.", "title": "Pair HomeKit" }, "user": { "data": { - "auto_start": "Autostart (disable if using Z-Wave or other delayed start system)", - "include_domains": "Domains to include", - "mode": "Mode" + "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", "title": "Select domains to be included" } } @@ -38,17 +22,18 @@ "advanced": { "data": { "auto_start": "Autostart (disable if you are calling the homekit.start service manually)", - "safe_mode": "Safe Mode (enable only if pairing fails)" + "devices": "Devices (Triggers)" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", + "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", "title": "Advanced Configuration" }, "cameras": { "data": { + "camera_audio": "Cameras that support audio", "camera_copy": "Cameras that support native H.264 streams" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Select camera video codec." + "title": "Camera Configuration" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/es-419.json b/homeassistant/components/homekit/translations/es-419.json index 45e42250177fa..2b670f13c7e43 100644 --- a/homeassistant/components/homekit/translations/es-419.json +++ b/homeassistant/components/homekit/translations/es-419.json @@ -10,7 +10,6 @@ }, "user": { "data": { - "auto_start": "Inicio autom\u00e1tico (deshabilitar si se usa Z-Wave u otro sistema de inicio diferido)", "include_domains": "Dominios para incluir" }, "description": "Un HomeKit Bridge le permitir\u00e1 acceder a sus entidades de Home Assistant en HomeKit. Los puentes HomeKit est\u00e1n limitados a 150 accesorios por instancia, incluido el puente mismo. Si desea unir m\u00e1s de la cantidad m\u00e1xima de accesorios, se recomienda que use m\u00faltiples puentes HomeKit para diferentes dominios. La configuraci\u00f3n detallada de la entidad solo est\u00e1 disponible a trav\u00e9s de YAML para el puente primario.", @@ -22,8 +21,7 @@ "step": { "advanced": { "data": { - "auto_start": "Inicio autom\u00e1tico (deshabilitar si se usa Z-Wave u otro sistema de inicio diferido)", - "safe_mode": "Modo seguro (habil\u00edtelo solo si falla el emparejamiento)" + "auto_start": "Inicio autom\u00e1tico (deshabilitar si se usa Z-Wave u otro sistema de inicio diferido)" }, "description": "Esta configuraci\u00f3n solo necesita ser ajustada si el puente HomeKit no es funcional.", "title": "Configuraci\u00f3n avanzada" diff --git a/homeassistant/components/homekit/translations/es.json b/homeassistant/components/homekit/translations/es.json index 694b7dcdb6cf9..6008d399d641e 100644 --- a/homeassistant/components/homekit/translations/es.json +++ b/homeassistant/components/homekit/translations/es.json @@ -4,29 +4,13 @@ "port_name_in_use": "Ya est\u00e1 configurada una pasarela con el mismo nombre o puerto." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entidad" - }, - "description": "Elija la entidad que desea incluir. En el modo accesorio, s\u00f3lo se incluye una \u00fanica entidad.", - "title": "Seleccione la entidad a incluir" - }, - "bridge_mode": { - "data": { - "include_domains": "Dominios a incluir" - }, - "description": "Elija los dominios que se van a incluir. Se incluir\u00e1n todas las entidades admitidas en el dominio.", - "title": "Selecciona los dominios a incluir" - }, "pairing": { - "description": "Tan pronto como la pasarela {name} est\u00e9 lista, la vinculaci\u00f3n estar\u00e1 disponible en \"Notificaciones\" como \"configuraci\u00f3n de pasarela Homekit\"", + "description": "Para completar el emparejamiento, sigue las instrucciones en \"Notificaciones\" en \"Emparejamiento HomeKit\".", "title": "Vincular pasarela Homekit" }, "user": { "data": { - "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)", - "include_domains": "Dominios para incluir", - "mode": "Modo" + "include_domains": "Dominios para incluir" }, "description": "Una pasarela Homekit permitir\u00e1 a Homekit acceder a sus entidades de Home Assistant. La pasarela Homekit est\u00e1 limitada a 150 accesorios por instancia incluyendo la propia pasarela. Si desea enlazar m\u00e1s del m\u00e1ximo n\u00famero de accesorios, se recomienda que use multiples pasarelas Homekit para diferentes dominios. Configuraci\u00f3n detallada de la entidad solo est\u00e1 disponible via YAML para la pasarela primaria.", "title": "Activar pasarela Homekit" @@ -38,13 +22,14 @@ "advanced": { "data": { "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)", - "safe_mode": "Modo seguro (habil\u00edtelo solo si falla el emparejamiento)" + "devices": "Dispositivos (disparadores)" }, "description": "Esta configuraci\u00f3n solo necesita ser ajustada si el puente HomeKit no es funcional.", "title": "Configuraci\u00f3n avanzada" }, "cameras": { "data": { + "camera_audio": "C\u00e1maras que admiten audio", "camera_copy": "C\u00e1maras compatibles con transmisiones H.264 nativas" }, "description": "Verifique todas las c\u00e1maras que admiten transmisiones H.264 nativas. Si la c\u00e1mara no emite una transmisi\u00f3n H.264, el sistema transcodificar\u00e1 el video a H.264 para HomeKit. La transcodificaci\u00f3n requiere una CPU de alto rendimiento y es poco probable que funcione en ordenadores de placa \u00fanica.", diff --git a/homeassistant/components/homekit/translations/et.json b/homeassistant/components/homekit/translations/et.json index 38d063d9bf390..cd02425a2b63a 100644 --- a/homeassistant/components/homekit/translations/et.json +++ b/homeassistant/components/homekit/translations/et.json @@ -4,31 +4,15 @@ "port_name_in_use": "Sama nime v\u00f5i pordiga tarvik v\u00f5i sild on juba konfigureeritud." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Olem" - }, - "description": "Vali kaasatav olem. Lisare\u017eiimis on kaasatav ainult \u00fcks olem.", - "title": "Vali kaasatav olem" - }, - "bridge_mode": { - "data": { - "include_domains": "Kaasatavad domeenid" - }, - "description": "Vali kaasatavad domeenid. Kaasatakse k\u00f5ik domeenis toetatud olemid.", - "title": "Vali kaasatavad domeenid" - }, "pairing": { "description": "Sidumise l\u00f5puleviimiseks j\u00e4rgi jaotises \"HomeKiti sidumine\" toodud juhiseid alajaotises \"Teatised\".", "title": "HomeKiti sidumine" }, "user": { "data": { - "auto_start": "Autostart (keela, kui kasutad Z-Wave'i v\u00f5i muud viivitatud k\u00e4ivituss\u00fcsteemi)", - "include_domains": "Kaasatavad domeenid", - "mode": "Re\u017eiim" + "include_domains": "Kaasatavad domeenid" }, - "description": "Vali kaasatavad domeenid. Kaasatakse k\u00f5ik domeenis toetatud olemid. Iga telemeedia pleieri ja kaamera jaoks luuakse eraldi HomeKiti eksemplar tarvikure\u017eiimis.", + "description": "Vali kaasatavad domeenid. Lisatakse k\u00f5ik domeenis toetatud \u00fcksused. Iga tv-meediam\u00e4ngija, tegevusp\u00f5hise kaugjuhtimispuldi, luku ja kaamera jaoks luuakse eraldi HomeKit-instants lisaseadme re\u017eiimis.", "title": "Vali kaasatavad domeenid" } } @@ -38,17 +22,18 @@ "advanced": { "data": { "auto_start": "Autostart (keela kui kasutad homekit.start teenust k\u00e4sitsi)", - "safe_mode": "Turvare\u017eiim (luba ainult siis, kui sidumine nurjub)" + "devices": "Seadmed (p\u00e4\u00e4stikud)" }, - "description": "Neid s\u00e4tteid tuleb muuta ainult siis kui HomeKit ei t\u00f6\u00f6ta.", + "description": "Iga valitud seadme jaoks luuakse programmeeritavad l\u00fclitid. Seadme p\u00e4\u00e4stiku k\u00e4ivitamisel saab HomeKiti seadistada automaatiseeringu v\u00f5i stseeni k\u00e4ivitamiseks.", "title": "T\u00e4psem seadistamine" }, "cameras": { "data": { + "camera_audio": "Heliedastusega kaamerad", "camera_copy": "Kaamerad, mis toetavad riistvaralist H.264 voogu" }, "description": "Vali k\u00f5iki kaameraid, mis toetavad kohalikku H.264 voogu. Kui kaamera ei edasta H.264 voogu, kodeerib s\u00fcsteem video HomeKiti jaoks versioonile H.264. \u00dcmberkodeerimine n\u00f5uab j\u00f5udsat protsessorit ja t\u00f5en\u00e4oliselt ei t\u00f6\u00f6ta see \u00fcheplaadilistes arvutites.", - "title": "Vali kaamera videokoodek." + "title": "Kaamera s\u00e4tted" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/fr.json b/homeassistant/components/homekit/translations/fr.json index dae09002c54ae..a66192ace9a75 100644 --- a/homeassistant/components/homekit/translations/fr.json +++ b/homeassistant/components/homekit/translations/fr.json @@ -4,29 +4,13 @@ "port_name_in_use": "Une passerelle avec le m\u00eame nom ou port est d\u00e9j\u00e0 configur\u00e9e." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entit\u00e9" - }, - "description": "Choisissez l'entit\u00e9 \u00e0 inclure. En mode accessoire, une seule entit\u00e9 est incluse.", - "title": "S\u00e9lectionnez l'entit\u00e9 \u00e0 inclure" - }, - "bridge_mode": { - "data": { - "include_domains": "Domaines \u00e0 inclure" - }, - "description": "Choisissez les domaines \u00e0 inclure. Toutes les entit\u00e9s prises en charge dans le domaine seront incluses.", - "title": "S\u00e9lectionnez les domaines \u00e0 inclure" - }, "pairing": { "description": "Pour compl\u00e9ter l'appariement, suivez les instructions dans les \"Notifications\" sous \"Appariement HomeKit\".", "title": "Appairage de la Passerelle Homekit" }, "user": { "data": { - "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", - "include_domains": "Domaines \u00e0 inclure", - "mode": "Mode" + "include_domains": "Domaines \u00e0 inclure" }, "description": "Choisissez les domaines \u00e0 inclure. Toutes les entit\u00e9s prises en charge dans le domaine seront incluses. Une instance HomeKit distincte en mode accessoire sera cr\u00e9\u00e9e pour chaque lecteur multim\u00e9dia TV et cam\u00e9ra.", "title": "S\u00e9lectionnez les domaines \u00e0 inclure" @@ -38,13 +22,14 @@ "advanced": { "data": { "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", - "safe_mode": "Mode sans \u00e9chec (activez uniquement si le jumelage \u00e9choue)" + "devices": "P\u00e9riph\u00e9riques (d\u00e9clencheurs)" }, "description": "Ces param\u00e8tres ne doivent \u00eatre ajust\u00e9s que si le pont HomeKit n'est pas fonctionnel.", "title": "Configuration avanc\u00e9e" }, "cameras": { "data": { + "camera_audio": "Cam\u00e9ras prenant en charge l'audio", "camera_copy": "Cam\u00e9ras prenant en charge les flux H.264 natifs" }, "description": "V\u00e9rifiez toutes les cam\u00e9ras prenant en charge les flux H.264 natifs. Si la cam\u00e9ra ne produit pas de flux H.264, le syst\u00e8me transcodera la vid\u00e9o en H.264 pour HomeKit. Le transcodage n\u00e9cessite un processeur performant et il est peu probable qu'il fonctionne sur des ordinateurs \u00e0 carte unique.", diff --git a/homeassistant/components/homekit/translations/he.json b/homeassistant/components/homekit/translations/he.json index cb5a530b739d2..320bf20304425 100644 --- a/homeassistant/components/homekit/translations/he.json +++ b/homeassistant/components/homekit/translations/he.json @@ -1,6 +1,23 @@ { + "config": { + "step": { + "user": { + "data": { + "include_domains": "\u05ea\u05d7\u05d5\u05de\u05d9\u05dd \u05e9\u05d9\u05e9 \u05dc\u05db\u05dc\u05d5\u05dc" + } + } + } + }, "options": { "step": { + "advanced": { + "data": { + "devices": "\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd (\u05d8\u05e8\u05d9\u05d2\u05e8\u05d9\u05dd)" + } + }, + "cameras": { + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05de\u05e6\u05dc\u05de\u05d4" + }, "include_exclude": { "data": { "mode": "\u05de\u05e6\u05d1" @@ -9,6 +26,7 @@ }, "init": { "data": { + "include_domains": "\u05ea\u05d7\u05d5\u05de\u05d9\u05dd \u05e9\u05d9\u05e9 \u05dc\u05db\u05dc\u05d5\u05dc", "mode": "\u05de\u05e6\u05d1" } }, diff --git a/homeassistant/components/homekit/translations/hu.json b/homeassistant/components/homekit/translations/hu.json index 7cc2577cb313c..046cf57e9b9a5 100644 --- a/homeassistant/components/homekit/translations/hu.json +++ b/homeassistant/components/homekit/translations/hu.json @@ -1,20 +1,18 @@ { "config": { + "abort": { + "port_name_in_use": "Az azonos nev\u0171 vagy port\u00fa tartoz\u00e9k vagy h\u00edd m\u00e1r konfigur\u00e1lva van." + }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entit\u00e1s" - }, - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt entit\u00e1st" - }, "pairing": { + "description": "A p\u00e1ros\u00edt\u00e1s befejez\u00e9s\u00e9hez k\u00f6vesse a \u201eHomeKit p\u00e1ros\u00edt\u00e1s\u201d szakasz \u201e\u00c9rtes\u00edt\u00e9sek\u201d szakasz\u00e1ban tal\u00e1lhat\u00f3 utas\u00edt\u00e1sokat.", "title": "HomeKit p\u00e1ros\u00edt\u00e1s" }, "user": { "data": { - "include_domains": "Felvenni k\u00edv\u00e1nt domainek", - "mode": "M\u00f3d" + "include_domains": "Felvenni k\u00edv\u00e1nt domainek" }, + "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt domaineket. A domain minden t\u00e1mogatott entit\u00e1sa szerepelni fog. Minden tartoz\u00e9k m\u00f3dban k\u00fcl\u00f6n HomeKit p\u00e9ld\u00e1ny j\u00f6n l\u00e9tre minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9g alap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez.", "title": "Felvenni k\u00edv\u00e1nt domainek kiv\u00e1laszt\u00e1sa" } } @@ -22,27 +20,36 @@ "options": { "step": { "advanced": { + "data": { + "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)", + "devices": "Eszk\u00f6z\u00f6k (triggerek)" + }, + "description": "Programozhat\u00f3 kapcsol\u00f3k j\u00f6nnek l\u00e9tre minden kiv\u00e1lasztott eszk\u00f6zh\u00f6z. Amikor egy eszk\u00f6z esem\u00e9nyt ind\u00edt el, a HomeKit be\u00e1ll\u00edthat\u00f3 \u00fagy, hogy egy automatizmus vagy egy jelenet induljon el.", "title": "Halad\u00f3 be\u00e1ll\u00edt\u00e1sok" }, "cameras": { "data": { - "camera_copy": "A nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" + "camera_audio": "Hangot t\u00e1mogat\u00f3 kamer\u00e1k", + "camera_copy": "Nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" }, - "title": "V\u00e1laszd ki a kamera vide\u00f3 kodekj\u00e9t." + "description": "Ellen\u0151rizze az \u00f6sszes kamer\u00e1t, amely t\u00e1mogatja a nat\u00edv H.264 adatfolyamokat. Ha a f\u00e9nyk\u00e9pez\u0151g\u00e9p nem ad ki H.264 adatfolyamot, a rendszer \u00e1tk\u00f3dolja a vide\u00f3t H.264 form\u00e1tumba a HomeKit sz\u00e1m\u00e1ra. Az \u00e1tk\u00f3dol\u00e1shoz nagy teljes\u00edtm\u00e9ny\u0171 CPU sz\u00fcks\u00e9ges, \u00e9s val\u00f3sz\u00edn\u0171leg nem fog m\u0171k\u00f6dni egylapos sz\u00e1m\u00edt\u00f3g\u00e9peken.", + "title": "V\u00e1lassza ki a kamera vide\u00f3 kodekj\u00e9t." }, "include_exclude": { "data": { "entities": "Entit\u00e1sok", "mode": "M\u00f3d" }, - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" + "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s szerepel. H\u00eddbefogad\u00e1si m\u00f3dban a tartom\u00e1ny \u00f6sszes entit\u00e1sa szerepelni fog, hacsak nincsenek kijel\u00f6lve konkr\u00e9t entit\u00e1sok. H\u00eddkiz\u00e1r\u00e1si m\u00f3dban a domain \u00f6sszes entit\u00e1sa szerepelni fog, kiv\u00e9ve a kiz\u00e1rt entit\u00e1sokat. A legjobb teljes\u00edtm\u00e9ny \u00e9rdek\u00e9ben minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9galap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez k\u00fcl\u00f6n HomeKit tartoz\u00e9kot hoznak l\u00e9tre.", + "title": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" }, "init": { "data": { "include_domains": "Felvenni k\u00edv\u00e1nt domainek", "mode": "M\u00f3d" }, - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt domaineket." + "description": "A HomeKit konfigur\u00e1lhat\u00f3 \u00fagy, hogy egy h\u00edd vagy egyetlen tartoz\u00e9k l\u00e1that\u00f3 legyen. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s haszn\u00e1lhat\u00f3. A tartoz\u00e9k m\u00f3dra van sz\u00fcks\u00e9g ahhoz, hogy a TV -eszk\u00f6zoszt\u00e1ly\u00fa m\u00e9dialej\u00e1tsz\u00f3k megfelel\u0151en m\u0171k\u00f6djenek. A \u201eTartalmazand\u00f3 tartom\u00e1nyok\u201d entit\u00e1sai szerepelni fognak a HomeKitben. A k\u00f6vetkez\u0151 k\u00e9perny\u0151n kiv\u00e1laszthatja, hogy mely entit\u00e1sokat k\u00edv\u00e1nja felvenni vagy kiz\u00e1rni a list\u00e1b\u00f3l.", + "title": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt domaineket." }, "yaml": { "description": "Ez a bejegyz\u00e9s YAML-en kereszt\u00fcl vez\u00e9relhet\u0151", diff --git a/homeassistant/components/homekit/translations/id.json b/homeassistant/components/homekit/translations/id.json index 588631a5215ce..d849d2164cc5c 100644 --- a/homeassistant/components/homekit/translations/id.json +++ b/homeassistant/components/homekit/translations/id.json @@ -4,31 +4,15 @@ "port_name_in_use": "Aksesori atau bridge dengan nama atau port yang sama telah dikonfigurasi." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entitas" - }, - "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan.", - "title": "Pilih entitas yang akan disertakan" - }, - "bridge_mode": { - "data": { - "include_domains": "Domain yang disertakan" - }, - "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan.", - "title": "Pilih domain yang akan disertakan" - }, "pairing": { "description": "Untuk menyelesaikan pemasangan ikuti petunjuk di \"Notifikasi\" di bawah \"Pemasangan HomeKit\".", "title": "Pasangkan HomeKit" }, "user": { "data": { - "auto_start": "Mulai otomatis (nonaktifkan jika menggunakan Z-Wave atau sistem mulai tertunda lainnya)", - "include_domains": "Domain yang disertakan", - "mode": "Mode" + "include_domains": "Domain yang disertakan" }, - "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan. Instans HomeKit terpisah dalam mode aksesori akan dibuat untuk setiap pemutar media TV dan kamera.", + "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan. Instans HomeKit terpisah dalam mode aksesori akan dibuat untuk setiap pemutar media TV, remote berbasis aktivitas, kunci, dan kamera.", "title": "Pilih domain yang akan disertakan" } } @@ -38,24 +22,25 @@ "advanced": { "data": { "auto_start": "Mulai otomatis (nonaktifkan jika Anda memanggil layanan homekit.start secara manual)", - "safe_mode": "Mode Aman (aktifkan hanya jika pemasangan gagal)" + "devices": "Perangkat (Pemicu)" }, - "description": "Pengaturan ini hanya perlu disesuaikan jika HomeKit tidak berfungsi.", + "description": "Sakelar yang dapat diprogram dibuat untuk setiap perangkat yang dipilih. Saat pemicu perangkat aktif, HomeKit dapat dikonfigurasi untuk menjalankan otomatisasi atau skenario.", "title": "Konfigurasi Tingkat Lanjut" }, "cameras": { "data": { + "camera_audio": "Kamera yang mendukung audio", "camera_copy": "Kamera yang mendukung aliran H.264 asli" }, "description": "Periksa semua kamera yang mendukung streaming H.264 asli. Jika kamera tidak mengeluarkan aliran H.264, sistem akan mentranskode video ke H.264 untuk HomeKit. Proses transcoding membutuhkan CPU kinerja tinggi dan tidak mungkin bekerja pada komputer papan tunggal.", - "title": "Pilih codec video kamera." + "title": "Konfigurasi Kamera" }, "include_exclude": { "data": { "entities": "Entitas", "mode": "Mode" }, - "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan. Dalam mode \"bridge include\", semua entitas di domain akan disertakan, kecuali entitas tertentu dipilih. Dalam mode \"bridge exclude\", semua entitas di domain akan disertakan, kecuali untuk entitas tertentu yang dipilih. Untuk kinerja terbaik, aksesori HomeKit terpisah diperlukan untuk masing-masing pemutar media, TV, dan kamera.", + "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan. Dalam mode \"bridge include\", semua entitas di domain akan disertakan, kecuali entitas tertentu dipilih. Dalam mode \"bridge exclude\", semua entitas di domain akan disertakan, kecuali untuk entitas tertentu yang dipilih. Untuk kinerja terbaik, aksesori HomeKit terpisah diperlukan untuk masing-masing pemutar media TV, remote berbasis aktivitas, kunci, dan kamera.", "title": "Pilih entitas untuk disertakan" }, "init": { diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index c61aececec7e9..44d274e32e5e1 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -4,31 +4,15 @@ "port_name_in_use": "Un accessorio o un bridge con lo stesso nome o porta \u00e8 gi\u00e0 configurato." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entit\u00e0" - }, - "description": "Scegli l'entit\u00e0 da includere. In modalit\u00e0 accessorio, \u00e8 inclusa solo una singola entit\u00e0.", - "title": "Seleziona l'entit\u00e0 da includere" - }, - "bridge_mode": { - "data": { - "include_domains": "Domini da includere" - }, - "description": "Scegli i domini da includere. Verranno incluse tutte le entit\u00e0 supportate nel dominio.", - "title": "Seleziona i domini da includere" - }, "pairing": { "description": "Per completare l'associazione, seguire le istruzioni in \"Notifiche\" sotto \"Associazione HomeKit\".", "title": "Associa HomeKit" }, "user": { "data": { - "auto_start": "Avvio automatico (disabilitare se si utilizza Z-Wave o un altro sistema di avvio ritardato)", - "include_domains": "Domini da includere", - "mode": "Modalit\u00e0" + "include_domains": "Domini da includere" }, - "description": "Scegli i domini da includere. Verranno incluse tutte le entit\u00e0 supportate nel dominio. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessorio per ogni lettore multimediale TV e telecamera.", + "description": "Scegli i domini da includere. Tutte le entit\u00e0 supportate nel dominio saranno incluse. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessoria per ogni lettore multimediale TV, telecomando basato sulle attivit\u00e0, serratura e telecamera.", "title": "Seleziona i domini da includere" } } @@ -37,25 +21,26 @@ "step": { "advanced": { "data": { - "auto_start": "Avvio automatico (disabilitare se stai chiamando manualmente il servizio homekit.start)", - "safe_mode": "Modalit\u00e0 provvisoria (attivare solo in caso di errore di associazione)" + "auto_start": "Avvio automatico (disabilita se stai chiamando manualmente il servizio homekit.start)", + "devices": "Dispositivi (Attivatori)" }, - "description": "Queste impostazioni devono essere regolate solo se HomeKit non funziona.", + "description": "Gli interruttori programmabili vengono creati per ogni dispositivo selezionato. Quando si attiva un trigger del dispositivo, HomeKit pu\u00f2 essere configurato per eseguire un'automazione o una scena.", "title": "Configurazione Avanzata" }, "cameras": { "data": { + "camera_audio": "Telecamere che supportano l'audio", "camera_copy": "Telecamere che supportano flussi H.264 nativi" }, - "description": "Controllare tutte le telecamere che supportano i flussi H.264 nativi. Se la videocamera non emette uno stream H.264, il sistema provveder\u00e0 a transcodificare il video in H.264 per HomeKit. La transcodifica richiede una CPU performante ed \u00e8 improbabile che funzioni su computer a scheda singola.", - "title": "Seleziona il codec video della videocamera." + "description": "Controllare tutte le telecamere che supportano i flussi H.264 nativi. Se la videocamera non emette un flusso H.264, il sistema provveder\u00e0 a transcodificare il video in H.264 per HomeKit. La transcodifica richiede una CPU performante ed \u00e8 improbabile che funzioni su computer a scheda singola.", + "title": "Configurazione della telecamera" }, "include_exclude": { "data": { "entities": "Entit\u00e0", "mode": "Modalit\u00e0" }, - "description": "Scegliere le entit\u00e0 da includere. In modalit\u00e0 accessorio, \u00e8 inclusa una sola entit\u00e0. In modalit\u00e0 di inclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, a meno che non siano selezionate entit\u00e0 specifiche. In modalit\u00e0 di esclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, ad eccezione delle entit\u00e0 escluse. Per prestazioni ottimali, sar\u00e0 creata una HomeKit separata accessoria per ogni lettore multimediale TV, telecomando basato sulle attivit\u00e0, serratura e videocamera.", + "description": "Scegli le entit\u00e0 da includere. In modalit\u00e0 accessorio, \u00e8 inclusa una sola entit\u00e0. In modalit\u00e0 di inclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, a meno che non siano selezionate entit\u00e0 specifiche. In modalit\u00e0 di esclusione bridge, tutte le entit\u00e0 nel dominio saranno incluse, a eccezione delle entit\u00e0 escluse. Per prestazioni ottimali, sar\u00e0 creata una HomeKit separata accessoria per ogni lettore multimediale TV, telecomando basato sulle attivit\u00e0, serratura e videocamera.", "title": "Seleziona le entit\u00e0 da includere" }, "init": { diff --git a/homeassistant/components/homekit/translations/ja.json b/homeassistant/components/homekit/translations/ja.json new file mode 100644 index 0000000000000..769a3d0f1c7a7 --- /dev/null +++ b/homeassistant/components/homekit/translations/ja.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "port_name_in_use": "\u540c\u3058\u540d\u524d\u3084\u30dd\u30fc\u30c8\u3092\u6301\u3064\u30a2\u30af\u30bb\u30b5\u30ea\u3084\u30d6\u30ea\u30c3\u30b8\u304c\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002" + }, + "step": { + "pairing": { + "description": "\u201cHomeKit Pairing\u201d\u306e\"\u901a\u77e5\"\u306e\u6307\u793a\u306b\u5f93\u3063\u3066\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u5b8c\u4e86\u3057\u307e\u3059\u3002", + "title": "\u30da\u30a2 HomeKit" + }, + "user": { + "data": { + "include_domains": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3" + }, + "description": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u30c9\u30e1\u30a4\u30f3\u5185\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u308b\u3059\u3079\u3066\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u30e2\u30fc\u30c9\u306e\u5225\u306e \u3001HomeKit\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306f\u3001\u5404 \u30c6\u30ec\u30d3\u30e1\u30c7\u30a3\u30a2 \u30d7\u30ec\u30fc\u30e4\u30fc\u3001\u30a2\u30af\u30c6\u30a3\u30d3\u30c6\u30a3 \u30d9\u30fc\u30b9\u306e\u30ea\u30e2\u30fc\u30c8\u3001\u30ed\u30c3\u30af\u3001\u304a\u3088\u3073\u30ab\u30e1\u30e9\u306b\u5bfe\u3057\u3066\u4f5c\u6210\u3055\u308c\u307e\u3059\u3002", + "title": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3\u306e\u9078\u629e" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "auto_start": "\u81ea\u52d5\u8d77\u52d5(homekit.start\u30b5\u30fc\u30d3\u30b9\u3092\u624b\u52d5\u3067\u547c\u3073\u51fa\u3059\u5834\u5408\u306f\u7121\u52b9\u306b\u3059\u308b)", + "devices": "\u30c7\u30d0\u30a4\u30b9(\u30c8\u30ea\u30ac\u30fc)" + }, + "description": "\u9078\u629e\u3057\u305f\u30c7\u30d0\u30a4\u30b9\u3054\u3068\u306b\u3001\u30d7\u30ed\u30b0\u30e9\u30e0\u53ef\u80fd\u306a\u30b9\u30a4\u30c3\u30c1\u304c\u4f5c\u6210\u3055\u308c\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u306e\u30c8\u30ea\u30ac\u30fc\u304c\u767a\u751f\u3059\u308b\u3068\u3001HomeKit\u306f\u30aa\u30fc\u30c8\u30e1\u30fc\u30b7\u30e7\u30f3\u3084\u30b7\u30fc\u30f3\u3092\u5b9f\u884c\u3059\u308b\u3088\u3046\u306b\u69cb\u6210\u3067\u304d\u307e\u3059\u3002", + "title": "\u9ad8\u5ea6\u306a\u8a2d\u5b9a" + }, + "cameras": { + "data": { + "camera_audio": "\u97f3\u58f0\u306b\u5bfe\u5fdc\u3057\u305f\u30ab\u30e1\u30e9", + "camera_copy": "H.264\u306e\u30cd\u30a4\u30c6\u30a3\u30d6\u30b9\u30c8\u30ea\u30fc\u30e0\u3092\u30b5\u30dd\u30fc\u30c8\u3059\u308b\u30ab\u30e1\u30e9" + }, + "description": "\u3059\u3079\u3066\u306e\u30ab\u30e1\u30e9\u304c\u3001\u30cd\u30a4\u30c6\u30a3\u30d6\u3067H.264\u30b9\u30c8\u30ea\u30fc\u30e0\u3092\u30b5\u30dd\u30fc\u30c8\u3057\u3066\u3044\u308b\u304b\u3069\u3046\u304b\u3092\u78ba\u8a8d\u3057\u307e\u3059\u3002\u30ab\u30e1\u30e9\u304c\u3001H.264\u30b9\u30c8\u30ea\u30fc\u30e0\u51fa\u529b\u306b\u5bfe\u5fdc\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u3001\u30b7\u30b9\u30c6\u30e0\u306f\u3001HomeKit\u306eH.264\u306b\u30d3\u30c7\u30aa\u3092\u30c8\u30e9\u30f3\u30b9\u30b3\u30fc\u30c9\u3057\u307e\u3059\u3002\u30c8\u30e9\u30f3\u30b9\u30b3\u30fc\u30c7\u30a3\u30f3\u30b0\u306b\u306f\u9ad8\u30d1\u30d5\u30a9\u30fc\u30de\u30f3\u30b9\u306aCPU\u304c\u5fc5\u8981\u306a\u306e\u3067\u3001\u30b7\u30f3\u30b0\u30eb\u30dc\u30fc\u30c9\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u3067\u306f\u52d5\u4f5c\u3057\u306a\u3044\u3068\u601d\u308f\u308c\u307e\u3059\u3002", + "title": "\u30ab\u30e1\u30e9\u306e\u8a2d\u5b9a" + }, + "include_exclude": { + "data": { + "entities": "\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3", + "mode": "\u30e2\u30fc\u30c9" + }, + "description": "\u542b\u307e\u308c\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea \u30e2\u30fc\u30c9\u3067\u306f\u30011\u3064\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306e\u307f\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u30d6\u30ea\u30c3\u30b8\u30a4\u30f3\u30af\u30eb\u30fc\u30c9\u30e2\u30fc\u30c9\u3067\u306f\u3001\u7279\u5b9a\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u9078\u629e\u3055\u308c\u3066\u3044\u306a\u3044\u9650\u308a\u3001\u30c9\u30e1\u30a4\u30f3\u5185\u306e\u3059\u3079\u3066\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u30d6\u30ea\u30c3\u30b8\u9664\u5916\u30e2\u30fc\u30c9\u3067\u306f\u3001\u9664\u5916\u3055\u308c\u305f\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9664\u3044\u3066\u3001\u30c9\u30e1\u30a4\u30f3\u5185\u306e\u3059\u3079\u3066\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u6700\u9ad8\u306e\u30d1\u30d5\u30a9\u30fc\u30de\u30f3\u30b9\u3092\u5b9f\u73fe\u3059\u308b\u305f\u3081\u306b\u3001\u30c6\u30ec\u30d3\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u3001\u30a2\u30af\u30c6\u30a3\u30d3\u30c6\u30a3\u30d9\u30fc\u30b9\u306e\u30ea\u30e2\u30b3\u30f3(remote)\u3001\u30ed\u30c3\u30af\u3001\u30ab\u30e1\u30e9\u306b\u5bfe\u3057\u3066\u500b\u5225\u306b\u3001HomeKit\u30a2\u30af\u30bb\u30b5\u30ea\u3092\u4f5c\u6210\u3057\u307e\u3059\u3002", + "title": "\u542b\u3081\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9078\u629e" + }, + "init": { + "data": { + "include_domains": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3", + "mode": "\u30e2\u30fc\u30c9" + }, + "description": "HomeKit \u306f\u3001\u30d6\u30ea\u30c3\u30b8\u307e\u305f\u306f\u5358\u4e00\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u3092\u516c\u958b\u3059\u308b\u3088\u3046\u306b\u69cb\u6210\u3067\u304d\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u30e2\u30fc\u30c9\u3067\u306f\u30011\u3064\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306e\u307f\u304c\u4f7f\u7528\u3067\u304d\u307e\u3059\u3002TV\u30c7\u30d0\u30a4\u30b9\u30af\u30e9\u30b9\u3092\u6301\u3064\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u304c\u6b63\u5e38\u306b\u6a5f\u80fd\u3059\u308b\u305f\u3081\u306b\u306f\u3001\u30a2\u30af\u30bb\u30b5\u30ea\u30e2\u30fc\u30c9\u304c\u5fc5\u8981\u3067\u3059\u3002\"\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3(Domains to include)\"\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306f\u3001 HomeKit \u306b\u542b\u307e\u308c\u307e\u3059\u3002\u6b21\u306e\u753b\u9762\u3067\u3053\u306e\u30ea\u30b9\u30c8\u306b\u542b\u3081\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3001\u307e\u305f\u306f\u9664\u5916\u3059\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9078\u629e\u3067\u304d\u307e\u3059\u3002", + "title": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3\u3092\u9078\u629e\u3057\u307e\u3059\u3002" + }, + "yaml": { + "description": "\u3053\u306e\u30a8\u30f3\u30c8\u30ea\u30fc\u306fYAML\u3092\u4ecb\u3057\u3066\u5236\u5fa1\u3055\u308c\u307e\u3059", + "title": "HomeKit\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/ko.json b/homeassistant/components/homekit/translations/ko.json index b9b04aec0a7fd..e93a55db97162 100644 --- a/homeassistant/components/homekit/translations/ko.json +++ b/homeassistant/components/homekit/translations/ko.json @@ -4,29 +4,13 @@ "port_name_in_use": "\uc774\ub984\uc774\ub098 \ud3ec\ud2b8\uac00 \uac19\uc740 \ube0c\ub9ac\uc9c0 \ub610\ub294 \uc561\uc138\uc11c\ub9ac\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "\uad6c\uc131\uc694\uc18c" - }, - "description": "\ud3ec\ud568\ud560 \uad6c\uc131\uc694\uc18c\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \uc561\uc138\uc11c\ub9ac \ubaa8\ub4dc\uc5d0\uc11c\ub294 \ub2e8\uc77c \uad6c\uc131\uc694\uc18c\ub9cc \ud3ec\ud568\ub429\ub2c8\ub2e4.", - "title": "\ud3ec\ud568\ud560 \uad6c\uc131\uc694\uc18c \uc120\ud0dd\ud558\uae30" - }, - "bridge_mode": { - "data": { - "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778" - }, - "description": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \uc9c0\uc6d0\ub418\ub294 \ub3c4\uba54\uc778\uc758 \ubaa8\ub4e0 \uad6c\uc131\uc694\uc18c\uac00 \ud3ec\ud568\ub429\ub2c8\ub2e4.", - "title": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778 \uc120\ud0dd\ud558\uae30" - }, "pairing": { "description": "\"\uc54c\ub9bc\"\uc5d0\uc11c \"HomeKit Pairing\"\uc5d0 \uc788\ub294 \uc548\ub0b4\uc5d0 \ub530\ub77c \ud398\uc5b4\ub9c1\uc744 \uc644\ub8cc\ud574\uc8fc\uc138\uc694.", "title": "HomeKit \ud398\uc5b4\ub9c1\ud558\uae30" }, "user": { "data": { - "auto_start": "\uc790\ub3d9 \uc2dc\uc791 (Z-Wave \ub610\ub294 \uae30\ud0c0 \uc9c0\uc5f0\ub41c \uc2dc\uc791 \uc2dc\uc2a4\ud15c\uc744 \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0 \ube44\ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)", - "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778", - "mode": "\ubaa8\ub4dc" + "include_domains": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778" }, "description": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \ub3c4\uba54\uc778\uc5d0\uc11c \uc9c0\uc6d0\ub418\ub294 \ubaa8\ub4e0 \uad6c\uc131\uc694\uc18c\uac00 \ud3ec\ud568\ub429\ub2c8\ub2e4. \uac01 TV \ubbf8\ub514\uc5b4 \ud50c\ub808\uc774\uc5b4\uc640 \uce74\uba54\ub77c\uc5d0 \ub300\ud574 \uc561\uc138\uc11c\ub9ac \ubaa8\ub4dc\uc758 \uac1c\ubcc4 HomeKit \uc778\uc2a4\ud134\uc2a4\uac00 \uc0dd\uc131\ub429\ub2c8\ub2e4.", "title": "\ud3ec\ud568\ud560 \ub3c4\uba54\uc778 \uc120\ud0dd\ud558\uae30" @@ -37,8 +21,7 @@ "step": { "advanced": { "data": { - "auto_start": "\uc790\ub3d9 \uc2dc\uc791 (homekit.start \uc11c\ube44\uc2a4\ub97c \uc218\ub3d9\uc73c\ub85c \ud638\ucd9c\ud558\ub824\uba74 \ube44\ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)", - "safe_mode": "\uc548\uc804 \ubaa8\ub4dc (\ud398\uc5b4\ub9c1\uc774 \uc2e4\ud328\ud55c \uacbd\uc6b0\uc5d0\ub9cc \ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)" + "auto_start": "\uc790\ub3d9 \uc2dc\uc791 (homekit.start \uc11c\ube44\uc2a4\ub97c \uc218\ub3d9\uc73c\ub85c \ud638\ucd9c\ud558\ub824\uba74 \ube44\ud65c\uc131\ud654\ud574\uc8fc\uc138\uc694)" }, "description": "\uc774 \uc124\uc815\uc740 HomeKit\uac00 \uc791\ub3d9\ud558\uc9c0 \uc54a\ub294 \uacbd\uc6b0\uc5d0\ub9cc \uc124\uc815\ud574\uc8fc\uc138\uc694.", "title": "\uace0\uae09 \uad6c\uc131" diff --git a/homeassistant/components/homekit/translations/lb.json b/homeassistant/components/homekit/translations/lb.json index 97c1bc23aa840..6261d4b5ed419 100644 --- a/homeassistant/components/homekit/translations/lb.json +++ b/homeassistant/components/homekit/translations/lb.json @@ -10,7 +10,6 @@ }, "user": { "data": { - "auto_start": "Autostart (d\u00e9aktiv\u00e9ier falls Z-Wave oder een aanere verz\u00f6gerte Start System benotzt g\u00ebtt)", "include_domains": "Domaine d\u00e9i solle abegraff ginn." }, "description": "HomeKit Integratioun erlaabt et Home Assistant Entit\u00e9iten am HomeKit z'acc\u00e9d\u00e9ieren. HomeKit Bridges sinn op 150 Accessoire limit\u00e9iert, mat der Bridge selwer. Falls d'Bridge m\u00e9i w\u00e9i d\u00e9i max. Unzuel vun Accessoire soll \u00ebnnerst\u00ebtzen ass et recommand\u00e9iert verschidden HomeKit Bridges fir verschidden Domaine anzesetzen. Eng detaill\u00e9iert Konfiguratioun ass n\u00ebmme via YAML fir d\u00e9i prim\u00e4r Bridge verf\u00fcgbar.", @@ -22,8 +21,7 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (d\u00e9aktiv\u00e9ier falls Z-Wave oder een aanere verz\u00f6gerte Start System benotzt g\u00ebtt)", - "safe_mode": "Safe Mode (n\u00ebmmen aktiv\u00e9ieren wann Kopplung net geht)" + "auto_start": "Autostart (d\u00e9aktiv\u00e9ier falls Z-Wave oder een aanere verz\u00f6gerte Start System benotzt g\u00ebtt)" }, "description": "D\u00ebs Astellungen brauche n\u00ebmmen ajust\u00e9iert ze ginn falls HomeKit net funktion\u00e9iert.", "title": "Erweidert Konfiguratioun" diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index 154f271e1a33b..3f65f8e5e7f6d 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -4,31 +4,15 @@ "port_name_in_use": "Er is al een bridge of apparaat met dezelfde naam of poort geconfigureerd." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entiteit" - }, - "description": "Kies de entiteit die moet worden opgenomen. In de accessoiremodus wordt slechts \u00e9\u00e9n entiteit opgenomen.", - "title": "Selecteer de entiteit die u wilt opnemen" - }, - "bridge_mode": { - "data": { - "include_domains": "Domeinen om op te nemen" - }, - "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen.", - "title": "Selecteer domeinen die u wilt opnemen" - }, "pairing": { "description": "Volg de instructies in \"Meldingen\" onder \"HomeKit-koppeling\" om het koppelen te voltooien.", "title": "Koppel HomeKit" }, "user": { "data": { - "auto_start": "Automatisch starten (uitschakelen als u Z-Wave of een ander vertraagd startsysteem gebruikt)", - "include_domains": "Domeinen om op te nemen", - "mode": "Mode" + "include_domains": "Domeinen om op te nemen" }, - "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen. Voor elke tv-mediaspeler en camera wordt een afzonderlijke HomeKit-instantie in accessoiremodus aangemaakt.", + "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen. Voor elke tv-mediaspeler, camera, activiteiten gebaseerde afstandsbediening en slot wordt een afzonderlijke HomeKit-instantie in accessoiremodus aangemaakt.", "title": "Selecteer domeinen die u wilt opnemen" } } @@ -38,24 +22,25 @@ "advanced": { "data": { "auto_start": "Autostart (deactiveer als je de homekit.start service handmatig aanroept)", - "safe_mode": "Veilige modus (alleen inschakelen als het koppelen mislukt)" + "devices": "Apparaten (triggers)" }, - "description": "Deze instellingen hoeven alleen te worden aangepast als HomeKit niet functioneert.", + "description": "Voor elk geselecteerd apparaat worden programmeerbare schakelaars gemaakt. Wanneer een apparaattrigger wordt geactiveerd, kan HomeKit worden geconfigureerd om een automatisering of sc\u00e8ne uit te voeren.", "title": "Geavanceerde configuratie" }, "cameras": { "data": { + "camera_audio": "Camera's die audio ondersteunen", "camera_copy": "Camera's die native H.264-streams ondersteunen" }, "description": "Controleer alle camera's die native H.264-streams ondersteunen. Als de camera geen H.264-stream uitvoert, transcodeert het systeem de video naar H.264 voor HomeKit. Transcodering vereist een performante CPU en het is onwaarschijnlijk dat dit werkt op computers met \u00e9\u00e9n bord.", - "title": "Selecteer de videocodec van de camera." + "title": "Cameraconfiguratie" }, "include_exclude": { "data": { "entities": "Entiteiten", "mode": "Mode" }, - "description": "Kies de entiteiten die u wilt opnemen. In de accessoiremodus is slechts een enkele entiteit inbegrepen. In de bridge-include-modus worden alle entiteiten in het domein opgenomen, tenzij specifieke entiteiten zijn geselecteerd. In de bridge-uitsluitingsmodus worden alle entiteiten in het domein opgenomen, behalve de uitgesloten entiteiten. Voor de beste prestaties is elke tv-mediaspeler en camera een apart HomeKit-accessoire.", + "description": "Kies de entiteiten die u wilt opnemen. In de accessoiremodus is slechts een enkele entiteit inbegrepen. In de bridge-include-modus worden alle entiteiten in het domein opgenomen, tenzij specifieke entiteiten zijn geselecteerd. In de bridge-uitsluitingsmodus worden alle entiteiten in het domein opgenomen, behalve de uitgesloten entiteiten. Voor de beste prestaties wordt voor elke media speler, activiteit gebaseerde afstandsbediening, slot en camera een afzonderlijke Homekit-accessoire gemaakt.", "title": "Selecteer de entiteiten die u wilt opnemen" }, "init": { diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index e18f9224c6816..868b3ff03fe74 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -4,31 +4,15 @@ "port_name_in_use": "Et tilbeh\u00f8r eller bro med samme navn eller port er allerede konfigurert." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Enhet" - }, - "description": "Velg enheten som skal inkluderes. I tilbeh\u00f8rsmodus er bare en enkelt enhet inkludert.", - "title": "Velg enhet som skal inkluderes" - }, - "bridge_mode": { - "data": { - "include_domains": "Domener \u00e5 inkludere" - }, - "description": "Velg domenene som skal inkluderes. Alle st\u00f8ttede enheter i domenet vil bli inkludert.", - "title": "Velg domener som skal inkluderes" - }, "pairing": { "description": "For \u00e5 fullf\u00f8re sammenkoblingen ved \u00e5 f\u00f8lge instruksjonene i \"Varsler\" under \"Sammenkobling av HomeKit\".", "title": "Koble sammen HomeKit" }, "user": { "data": { - "auto_start": "Autostart (deaktiver hvis du bruker Z-Wave eller annet forsinket startsystem)", - "include_domains": "Domener \u00e5 inkludere", - "mode": "Modus" + "include_domains": "Domener \u00e5 inkludere" }, - "description": "Velg domenene som skal inkluderes. Alle st\u00f8ttede enheter i domenet vil bli inkludert. Det opprettes en egen HomeKit-forekomst i tilbeh\u00f8rsmodus for hver tv-mediaspiller og kamera.", + "description": "Velg domenene som skal inkluderes. Alle enheter som st\u00f8ttes p\u00e5 domenet vil bli inkludert. En egen HomeKit -forekomst i tilbeh\u00f8rsmodus vil bli opprettet for hver tv -mediespiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", "title": "Velg domener som skal inkluderes" } } @@ -38,25 +22,26 @@ "advanced": { "data": { "auto_start": "Autostart (deaktiver hvis du ringer til homekit.start-tjenesten manuelt)", - "safe_mode": "Sikker modus (aktiver bare hvis sammenkoblingen mislykkes)" + "devices": "Enheter (utl\u00f8sere)" }, - "description": "Disse innstillingene m\u00e5 bare justeres hvis HomeKit ikke fungerer.", + "description": "Programmerbare brytere opprettes for hver valgt enhet. N\u00e5r en enhetstrigger utl\u00f8ses, kan HomeKit konfigureres til \u00e5 kj\u00f8re en automatisering eller scene.", "title": "Avansert konfigurasjon" }, "cameras": { "data": { + "camera_audio": "Kameraer som st\u00f8tter lyd", "camera_copy": "Kameraer som st\u00f8tter opprinnelige H.264-str\u00f8mmer" }, "description": "Sjekk alle kameraer som st\u00f8tter opprinnelige H.264-str\u00f8mmer. Hvis kameraet ikke sender ut en H.264-str\u00f8m, vil systemet omkode videoen til H.264 for HomeKit. Transkoding krever en potent prosessor og er usannsynlig \u00e5 fungere p\u00e5 enkeltkortdatamaskiner som Raspberry Pi o.l.", - "title": "Velg videokodek for kamera." + "title": "Kamerakonfigurasjon" }, "include_exclude": { "data": { "entities": "Entiteter", "mode": "Modus" }, - "description": "Velg enhetene som skal inkluderes. I tilbeh\u00f8rsmodus er bare en enkelt enhet inkludert. I bridge-inkluderingsmodus vil alle enheter i domenet bli inkludert, med mindre spesifikke enheter er valgt. I bridge-ekskluderingsmodus vil alle enheter i domenet bli inkludert, bortsett fra de ekskluderte enhetene. For best ytelse opprettes et eget HomeKit-tilbeh\u00f8r for hver tv-mediaspiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", - "title": "Velg enheter som skal inkluderes" + "description": "Velg entitetene som skal inkluderes. I tilbeh\u00f8rsmodus er bare en enkelt entitet inkludert. I bridge-inkluderingsmodus vil alle entiteter i domenet bli inkludert, med mindre spesifikke entiteter er valgt. I bridge-ekskluderingsmodus vil alle entiteter i domenet bli inkludert, bortsett fra de ekskluderte entitetene. For best ytelse opprettes et eget HomeKit-tilbeh\u00f8r for hver tv-mediaspiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", + "title": "Velg entiteter som skal inkluderes" }, "init": { "data": { diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index bcd088762ca96..5cc9ae00f0b0c 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -4,31 +4,15 @@ "port_name_in_use": "Akcesorium lub mostek o tej samej nazwie lub adresie IP jest ju\u017c skonfigurowany" }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Encja" - }, - "description": "Wybierz uwzgl\u0119dniane encje. W trybie akcesori\u00f3w uwzgl\u0119dniana jest tylko jedna encja.", - "title": "Wybierz uwzgl\u0119dniane encje" - }, - "bridge_mode": { - "data": { - "include_domains": "Domeny do uwzgl\u0119dnienia" - }, - "description": "Wybierz uwzgl\u0119dniane domeny. Wszystkie obs\u0142ugiwane encje w domenie zostan\u0105 uwzgl\u0119dnione.", - "title": "Wybierz uwzgl\u0119dniane domeny" - }, "pairing": { "description": "Aby doko\u0144czy\u0107 parowanie, post\u0119puj wg instrukcji \u201eParowanie HomeKit\u201d w \u201ePowiadomieniach\u201d.", "title": "Parowanie z HomeKit" }, "user": { "data": { - "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli u\u017cywasz Z-Wave lub innej integracji op\u00f3\u017aniaj\u0105cej start systemu)", - "include_domains": "Domeny do uwzgl\u0119dnienia", - "mode": "Tryb" + "include_domains": "Domeny do uwzgl\u0119dnienia" }, - "description": "Wybierz domeny do uwzgl\u0119dnienia. Wszystkie wspierane encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione. W trybie akcesorium, oddzielna instancja HomeKit zostanie utworzona dla ka\u017cdego tv media playera oraz kamery.", + "description": "Wybierz domeny do uwzgl\u0119dnienia. Wszystkie wspierane encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione. W trybie akcesorium, oddzielna instancja HomeKit zostanie utworzona dla ka\u017cdego tv media playera, pilota na bazie aktywno\u015bci, zamka oraz kamery.", "title": "Wybierz uwzgl\u0119dniane domeny" } } @@ -38,17 +22,18 @@ "advanced": { "data": { "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli r\u0119cznie uruchamiasz us\u0142ug\u0119 homekit.start)", - "safe_mode": "Tryb awaryjny (w\u0142\u0105cz tylko wtedy, gdy parowanie nie powiedzie si\u0119)" + "devices": "Urz\u0105dzenia (Wyzwalacze)" }, - "description": "Te ustawienia nale\u017cy dostosowa\u0107 tylko wtedy, gdy HomeKit nie dzia\u0142a.", + "description": "Dla ka\u017cdego wybranego urz\u0105dzenia stworzony zostanie programowalny prze\u0142\u0105cznik. Po uruchomieniu wyzwalacza urz\u0105dzenia, HomeKit mo\u017cna skonfigurowa\u0107 do uruchamiania automatyzacji lub sceny.", "title": "Konfiguracja zaawansowana" }, "cameras": { "data": { + "camera_audio": "Kamery obs\u0142uguj\u0105ce d\u017awi\u0119k", "camera_copy": "Kamery obs\u0142uguj\u0105ce kodek H.264" }, "description": "Sprawd\u017a, czy wszystkie kamery obs\u0142uguj\u0105 kodek H.264. Je\u015bli kamera nie wysy\u0142a strumienia skompresowanego kodekiem H.264, system b\u0119dzie transkodowa\u0142 wideo do H.264 dla HomeKit. Transkodowanie wymaga wydajnego procesora i jest ma\u0142o prawdopodobne, aby dzia\u0142a\u0142o na komputerach jednop\u0142ytkowych.", - "title": "Wyb\u00f3r kodeka wideo kamery" + "title": "Konfiguracja kamery" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/pt.json b/homeassistant/components/homekit/translations/pt.json index b5da3fdfc970c..920beaa98df9f 100644 --- a/homeassistant/components/homekit/translations/pt.json +++ b/homeassistant/components/homekit/translations/pt.json @@ -15,6 +15,9 @@ "options": { "step": { "advanced": { + "data": { + "auto_start": "[%key:component::homekit::config::step::user::data::auto_start%]" + }, "title": "Configura\u00e7\u00e3o avan\u00e7ada" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/ru.json b/homeassistant/components/homekit/translations/ru.json index 81199b2971ce1..f871636df0054 100644 --- a/homeassistant/components/homekit/translations/ru.json +++ b/homeassistant/components/homekit/translations/ru.json @@ -4,29 +4,13 @@ "port_name_in_use": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u0438\u043b\u0438 \u043f\u043e\u0440\u0442\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "\u041e\u0431\u044a\u0435\u043a\u0442" - }, - "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b. \u0412 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u043e\u0431\u044a\u0435\u043a\u0442.", - "title": "\u0412\u044b\u0431\u043e\u0440 \u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" - }, - "bridge_mode": { - "data": { - "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b" - }, - "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u043e\u043c\u0435\u043d\u044b. \u0411\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0438\u0437 \u0434\u043e\u043c\u0435\u043d\u0430.", - "title": "\u0412\u044b\u0431\u043e\u0440 \u0434\u043e\u043c\u0435\u043d\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" - }, "pairing": { "description": "\u0414\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u0441\u043b\u0435\u0434\u0443\u0439\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u043c \u0432 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0438 \"HomeKit Pairing\".", "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 HomeKit" }, "user": { "data": { - "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 Z-Wave \u0438\u043b\u0438 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043e\u0442\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0430)", - "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b", - "mode": "\u0420\u0435\u0436\u0438\u043c" + "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b" }, "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u043e\u043c\u0435\u043d\u044b. \u0411\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432\u0441\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0438\u0437 \u0434\u043e\u043c\u0435\u043d\u0430. \u0414\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043c\u0435\u0434\u0438\u0430\u043f\u043b\u0435\u0435\u0440\u0430 \u0438\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u044b \u0431\u0443\u0434\u0435\u0442 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430.", "title": "\u0412\u044b\u0431\u043e\u0440 \u0434\u043e\u043c\u0435\u043d\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" @@ -38,17 +22,18 @@ "advanced": { "data": { "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u0412\u044b \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442\u0435 \u0441\u043b\u0443\u0436\u0431\u0443 homekit.start)", - "safe_mode": "\u0411\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c (\u0432\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0441\u0431\u043e\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f)" + "devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0442\u0440\u0438\u0433\u0433\u0435\u0440\u044b)" }, - "description": "\u042d\u0442\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b, \u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438 HomeKit \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442.", + "description": "\u041f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u044b\u0435 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u0438 \u0441\u043e\u0437\u0434\u0430\u044e\u0442\u0441\u044f \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. HomeKit \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0434\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0438 \u0438\u043b\u0438 \u0441\u0446\u0435\u043d\u044b, \u043a\u043e\u0433\u0434\u0430 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442 \u0442\u0440\u0438\u0433\u0433\u0435\u0440 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", "title": "\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" }, "cameras": { "data": { - "camera_copy": "\u041a\u0430\u043c\u0435\u0440\u044b, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442 \u043f\u043e\u0442\u043e\u043a\u0438 H.264" + "camera_audio": "\u041a\u0430\u043c\u0435\u0440\u044b \u0441 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u043e\u0439 \u0430\u0443\u0434\u0438\u043e", + "camera_copy": "\u041a\u0430\u043c\u0435\u0440\u044b \u0441 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u043e\u0439 H.264" }, "description": "\u0415\u0441\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u0430 \u043d\u0435 \u0432\u044b\u0432\u043e\u0434\u0438\u0442 \u043f\u043e\u0442\u043e\u043a H.264, \u0441\u0438\u0441\u0442\u0435\u043c\u0430 \u043f\u0435\u0440\u0435\u043a\u043e\u0434\u0438\u0440\u0443\u0435\u0442 \u0432\u0438\u0434\u0435\u043e \u0432 H.264 \u0434\u043b\u044f HomeKit. \u0422\u0440\u0430\u043d\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0432\u044b\u0441\u043e\u043a\u043e\u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0430 \u0438 \u0432\u0440\u044f\u0434 \u043b\u0438 \u0431\u0443\u0434\u0435\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043d\u0430 \u043e\u0434\u043d\u043e\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430\u0445.", - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0432\u0438\u0434\u0435\u043e\u043a\u043e\u0434\u0435\u043a \u043a\u0430\u043c\u0435\u0440\u044b." + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043a\u0430\u043c\u0435\u0440\u044b" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/sl.json b/homeassistant/components/homekit/translations/sl.json index caeba3a9b6cb9..dcfc249601102 100644 --- a/homeassistant/components/homekit/translations/sl.json +++ b/homeassistant/components/homekit/translations/sl.json @@ -10,7 +10,6 @@ }, "user": { "data": { - "auto_start": "Samodejni zagon (onemogo\u010di, \u010de uporabljate Z-Wave ali drug sistem z zakasnjenim zagonom)", "include_domains": "Domene, ki jih \u017eelite vklju\u010diti" }, "description": "HomeKit most vam bo omogo\u010dil dostop do Home Assistant entitet v HomeKit-u. HomeKit mostovi so omejeni na 150 entitet na primerek, vklju\u010dno z mostom. \u010ce \u017eelite premostiti dovoljeno \u0161tevilo dodatkov, priporo\u010damo, da uporabite ve\u010d mostov HomeKit za razli\u010dne domene. Podrobna konfiguracija entitete je na voljo samo prek YAML za primarni most.", @@ -22,8 +21,7 @@ "step": { "advanced": { "data": { - "auto_start": "Samodejni zagon (onemogo\u010dite, \u010de uporabljate Z-wave ali kakteri drug sistem z zakasnjenim zagonom)", - "safe_mode": "Varni na\u010din (omogo\u010dite samo, \u010de seznanjanje ne uspe)" + "auto_start": "Samodejni zagon (onemogo\u010dite, \u010de uporabljate Z-wave ali kakteri drug sistem z zakasnjenim zagonom)" }, "description": "Te nastavitve je treba prilagoditi le, \u010de most HomeKit ni funkcionalen.", "title": "Napredna konfiguracija" diff --git a/homeassistant/components/homekit/translations/sv.json b/homeassistant/components/homekit/translations/sv.json index 1e2fcae04b5bc..0bc23c456ff7f 100644 --- a/homeassistant/components/homekit/translations/sv.json +++ b/homeassistant/components/homekit/translations/sv.json @@ -1,11 +1,6 @@ { "config": { "step": { - "bridge_mode": { - "data": { - "include_domains": "Dom\u00e4ner att inkludera" - } - }, "pairing": { "title": "Para HomeKit" }, diff --git a/homeassistant/components/homekit/translations/te.json b/homeassistant/components/homekit/translations/te.json new file mode 100644 index 0000000000000..3ad5c6451b131 --- /dev/null +++ b/homeassistant/components/homekit/translations/te.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u0c2a\u0c30\u0c3f\u0c15\u0c30\u0c3e\u0c32\u0c41 (\u0c1f\u0c4d\u0c30\u0c3f\u0c17\u0c4d\u0c17\u0c30\u0c4d\u0c32\u0c41)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/th.json b/homeassistant/components/homekit/translations/th.json new file mode 100644 index 0000000000000..50e648c9e6ef6 --- /dev/null +++ b/homeassistant/components/homekit/translations/th.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c (\u0e17\u0e23\u0e34\u0e01\u0e40\u0e01\u0e2d\u0e23\u0e4c)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/tr.json b/homeassistant/components/homekit/translations/tr.json index f9391fd0686c6..875d8a720a8f1 100644 --- a/homeassistant/components/homekit/translations/tr.json +++ b/homeassistant/components/homekit/translations/tr.json @@ -4,28 +4,16 @@ "port_name_in_use": "Ayn\u0131 ada veya ba\u011flant\u0131 noktas\u0131na sahip bir aksesuar veya k\u00f6pr\u00fc zaten yap\u0131land\u0131r\u0131lm\u0131\u015f." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Varl\u0131k" - }, - "description": "Dahil edilecek varl\u0131\u011f\u0131 se\u00e7in. Aksesuar modunda, yaln\u0131zca tek bir varl\u0131k dahildir.", - "title": "Dahil edilecek varl\u0131\u011f\u0131 se\u00e7in" - }, - "bridge_mode": { - "data": { - "include_domains": "\u0130\u00e7erecek etki alanlar\u0131" - }, - "description": "Dahil edilecek alanlar\u0131 se\u00e7in. Etki alan\u0131ndaki t\u00fcm desteklenen varl\u0131klar dahil edilecektir.", - "title": "Dahil edilecek etki alanlar\u0131n\u0131 se\u00e7in" - }, "pairing": { - "description": "{name} haz\u0131r olur olmaz e\u015fle\u015ftirme, \"Bildirimler\" i\u00e7inde \"HomeKit K\u00f6pr\u00fc Kurulumu\" olarak mevcut olacakt\u0131r.", + "description": "\u201cHomeKit E\u015fle\u015ftirme\u201d alt\u0131ndaki \u201cBildirimler\u201d b\u00f6l\u00fcm\u00fcndeki talimatlar\u0131 izleyerek e\u015fle\u015ftirmeyi tamamlamak i\u00e7in.", "title": "HomeKit'i E\u015fle\u015ftir" }, "user": { "data": { - "mode": "Mod" - } + "include_domains": "\u0130\u00e7erecek etki alanlar\u0131" + }, + "description": "Dahil edilecek alanlar\u0131 se\u00e7in. Etki alan\u0131ndaki t\u00fcm desteklenen varl\u0131klar dahil edilecektir. Her TV medya oynat\u0131c\u0131, aktivite tabanl\u0131 uzaktan kumanda, kilit ve kamera i\u00e7in aksesuar modunda ayr\u0131 bir HomeKit \u00f6rne\u011fi olu\u015fturulacakt\u0131r.", + "title": "Dahil edilecek etki alanlar\u0131n\u0131 se\u00e7in" } } }, @@ -33,27 +21,39 @@ "step": { "advanced": { "data": { - "safe_mode": "G\u00fcvenli Mod (yaln\u0131zca e\u015fle\u015ftirme ba\u015far\u0131s\u0131z olursa etkinle\u015ftirin)" - } + "auto_start": "Otomatik ba\u015flatma (homekit.start hizmetini manuel olarak ar\u0131yorsan\u0131z devre d\u0131\u015f\u0131 b\u0131rak\u0131n)", + "devices": "Cihazlar (Tetikleyiciler)" + }, + "description": "Se\u00e7ilen her cihaz i\u00e7in programlanabilir anahtarlar olu\u015fturulur. Bir cihaz tetikleyicisi tetiklendi\u011finde, HomeKit bir otomasyon veya sahne \u00e7al\u0131\u015ft\u0131racak \u015fekilde yap\u0131land\u0131r\u0131labilir.", + "title": "Geli\u015fmi\u015f yap\u0131land\u0131rma" }, "cameras": { "data": { + "camera_audio": "Sesi destekleyen kameralar", "camera_copy": "Yerel H.264 ak\u0131\u015flar\u0131n\u0131 destekleyen kameralar" }, "description": "Yerel H.264 ak\u0131\u015flar\u0131n\u0131 destekleyen t\u00fcm kameralar\u0131 kontrol edin. Kamera bir H.264 ak\u0131\u015f\u0131 vermezse, sistem videoyu HomeKit i\u00e7in H.264'e d\u00f6n\u00fc\u015ft\u00fcr\u00fcr. Kod d\u00f6n\u00fc\u015ft\u00fcrme, y\u00fcksek performansl\u0131 bir CPU gerektirir ve tek kartl\u0131 bilgisayarlarda \u00e7al\u0131\u015fma olas\u0131l\u0131\u011f\u0131 d\u00fc\u015f\u00fckt\u00fcr.", - "title": "Kamera video codec bile\u015fenini se\u00e7in." + "title": "Kamera Yap\u0131land\u0131rmas\u0131" }, "include_exclude": { "data": { "entities": "Varl\u0131klar", "mode": "Mod" }, + "description": "Dahil edilecek varl\u0131klar\u0131 se\u00e7in. Aksesuar modunda yaln\u0131zca tek bir varl\u0131k dahil edilir. K\u00f6pr\u00fc dahil modunda, belirli varl\u0131klar se\u00e7ilmedi\u011fi s\u00fcrece etki alan\u0131ndaki t\u00fcm varl\u0131klar dahil edilecektir. K\u00f6pr\u00fc hari\u00e7 tutma modunda, hari\u00e7 tutulan varl\u0131klar d\u0131\u015f\u0131nda etki alan\u0131ndaki t\u00fcm varl\u0131klar dahil edilecektir. En iyi performans i\u00e7in, her TV medya oynat\u0131c\u0131, aktivite tabanl\u0131 uzaktan kumanda, kilit ve kamera i\u00e7in ayr\u0131 bir HomeKit aksesuar\u0131 olu\u015fturulacakt\u0131r.", "title": "Dahil edilecek varl\u0131klar\u0131 se\u00e7in" }, "init": { "data": { + "include_domains": "\u0130\u00e7erecek etki alanlar\u0131", "mode": "Mod" - } + }, + "description": "HomeKit, bir k\u00f6pr\u00fcy\u00fc veya tek bir aksesuar\u0131 g\u00f6sterecek \u015fekilde yap\u0131land\u0131r\u0131labilir. Aksesuar modunda yaln\u0131zca tek bir varl\u0131k kullan\u0131labilir. TV cihaz s\u0131n\u0131f\u0131na sahip medya oynat\u0131c\u0131lar\u0131n d\u00fczg\u00fcn \u00e7al\u0131\u015fmas\u0131 i\u00e7in aksesuar modu gereklidir. \"Eklenecek alan adlar\u0131\"ndaki varl\u0131klar HomeKit'e dahil edilecektir. Bir sonraki ekranda bu listeye dahil edilecek veya hari\u00e7 tutulacak varl\u0131klar\u0131 se\u00e7ebileceksiniz.", + "title": "Dahil edilecek alanlar\u0131 se\u00e7in." + }, + "yaml": { + "description": "Bu giri\u015f YAML arac\u0131l\u0131\u011f\u0131yla kontrol edilir", + "title": "HomeKit Se\u00e7eneklerini Ayarlay\u0131n" } } } diff --git a/homeassistant/components/homekit/translations/uk.json b/homeassistant/components/homekit/translations/uk.json index 876b200bdf821..0210380ad38ca 100644 --- a/homeassistant/components/homekit/translations/uk.json +++ b/homeassistant/components/homekit/translations/uk.json @@ -10,7 +10,6 @@ }, "user": { "data": { - "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u0432\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438 \u043f\u0440\u0438 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u0430\u043d\u043d\u0456 Z-Wave \u0430\u0431\u043e \u0456\u043d\u0448\u043e\u0457 \u0441\u0438\u0441\u0442\u0435\u043c\u0438 \u0432\u0456\u0434\u043a\u043b\u0430\u0434\u0435\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0443)", "include_domains": "\u0412\u0438\u0431\u0440\u0430\u0442\u0438 \u0434\u043e\u043c\u0435\u043d\u0438" }, "description": "\u0426\u044f \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u044f \u0434\u043e\u0437\u0432\u043e\u043b\u044f\u0454 \u043e\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u0442\u0438 \u0434\u043e\u0441\u0442\u0443\u043f \u0434\u043e \u043e\u0431'\u0454\u043a\u0442\u0456\u0432 Home Assistant \u0447\u0435\u0440\u0435\u0437 HomeKit. HomeKit Bridge \u043e\u0431\u043c\u0435\u0436\u0435\u043d\u0438\u0439 150 \u0430\u043a\u0441\u0435\u0441\u0443\u0430\u0440\u0430\u043c\u0438 \u043d\u0430 \u0435\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440, \u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0447\u0438 \u0441\u0430\u043c \u0431\u0440\u0438\u0434\u0436. \u042f\u043a\u0449\u043e \u0412\u0430\u043c \u043f\u043e\u0442\u0440\u0456\u0431\u043d\u043e \u0431\u0456\u043b\u044c\u0448\u0435, \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0454\u0442\u044c\u0441\u044f \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u043a\u0456\u043b\u044c\u043a\u0430 HomeKit Bridge \u0434\u043b\u044f \u0440\u0456\u0437\u043d\u0438\u0445 \u0434\u043e\u043c\u0435\u043d\u0456\u0432. \u0414\u0435\u0442\u0430\u043b\u044c\u043d\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0434\u043b\u044f \u043a\u043e\u0436\u043d\u043e\u0433\u043e \u043e\u0431'\u0454\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0435 \u0442\u0456\u043b\u044c\u043a\u0438 \u0447\u0435\u0440\u0435\u0437 YAML \u0434\u043b\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e \u043c\u043e\u0441\u0442\u0430.", @@ -22,8 +21,7 @@ "step": { "advanced": { "data": { - "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u0432\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438 \u043f\u0440\u0438 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u0430\u043d\u043d\u0456 Z-Wave \u0430\u0431\u043e \u0456\u043d\u0448\u043e\u0457 \u0441\u0438\u0441\u0442\u0435\u043c\u0438 \u0432\u0456\u0434\u043a\u043b\u0430\u0434\u0435\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0443)", - "safe_mode": "\u0411\u0435\u0437\u043f\u0435\u0447\u043d\u0438\u0439 \u0440\u0435\u0436\u0438\u043c (\u0443\u0432\u0456\u043c\u043a\u043d\u0456\u0442\u044c \u0442\u0456\u043b\u044c\u043a\u0438 \u0432 \u0440\u0430\u0437\u0456 \u0437\u0431\u043e\u044e \u0441\u043f\u0430\u0440\u044e\u0432\u0430\u043d\u043d\u044f)" + "auto_start": "[%key:component::homekit::config::step::user::data::auto_start%]" }, "description": "\u0426\u0456 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u043f\u043e\u0442\u0440\u0456\u0431\u043d\u0456, \u043b\u0438\u0448\u0435 \u044f\u043a\u0449\u043e HomeKit \u043d\u0435 \u043f\u0440\u0430\u0446\u044e\u0454.", "title": "\u0420\u043e\u0437\u0448\u0438\u0440\u0435\u043d\u0456 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f" diff --git a/homeassistant/components/homekit/translations/zh-Hans.json b/homeassistant/components/homekit/translations/zh-Hans.json index d11234f4c6dda..73875ea042333 100644 --- a/homeassistant/components/homekit/translations/zh-Hans.json +++ b/homeassistant/components/homekit/translations/zh-Hans.json @@ -10,11 +10,10 @@ }, "user": { "data": { - "auto_start": "\u81ea\u52a8\u542f\u52a8\uff08\u5982\u679c\u4f7f\u7528 Z-Wave \u6216\u5176\u4ed6\u5ef6\u8fdf\u542f\u52a8\u7cfb\u7edf\uff0c\u8bf7\u7981\u7528\u6b64\u9879\uff09", "include_domains": "\u8981\u5305\u542b\u7684\u57df" }, "description": "HomeKit \u96c6\u6210\u53ef\u4ee5\u8ba9\u60a8\u901a\u8fc7 HomeKit \u8bbf\u95ee Home Assistant \u4e2d\u7684\u5b9e\u4f53\u3002\u5728\u6865\u63a5\u6a21\u5f0f\u4e2d\uff0c\u6bcf\u4e2a\u6865\u63a5\u5668\u5b9e\u4f8b\u6700\u591a\u53ef\u6a21\u62df 150 \u4e2a\u914d\u4ef6\uff0c\u5305\u62ec\u6865\u63a5\u5668\u672c\u8eab\u3002\u5982\u679c\u60a8\u5e0c\u671b\u6865\u63a5\u7684\u914d\u4ef6\u591a\u4e8e\u6b64\u6570\u91cf\uff0c\u5efa\u8bae\u4e3a\u4e0d\u540c\u7684\u57df\u4f7f\u7528\u591a\u4e2a HomeKit \u6865\u63a5\u5668\u3002\u8be6\u7ec6\u7684\u5b9e\u4f53\u914d\u7f6e\u4ec5\u53ef\u7528\u4e8e\u4e3b\u6865\u63a5\u5668\uff0c\u4e14\u987b\u901a\u8fc7 YAML \u914d\u7f6e\u3002", - "title": "\u6fc0\u6d3b HomeKit" + "title": "\u9009\u62e9\u8981\u5305\u542b\u7684\u57df" } } }, @@ -22,18 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "\u81ea\u52a8\u542f\u52a8\uff08\u5982\u679c\u4f7f\u7528 Z-Wave \u6216\u5176\u4ed6\u5ef6\u8fdf\u542f\u52a8\u7cfb\u7edf\uff0c\u8bf7\u7981\u7528\u6b64\u9879\uff09", - "safe_mode": "\u5b89\u5168\u6a21\u5f0f\uff08\u4ec5\u5728\u914d\u5bf9\u5931\u8d25\u65f6\u542f\u7528\uff09" + "auto_start": "\u81ea\u52a8\u542f\u52a8\uff08\u5982\u679c\u60a8\u624b\u52a8\u8c03\u7528 homekit.start \u670d\u52a1\uff0c\u8bf7\u7981\u7528\u6b64\u9879\uff09", + "devices": "\u8bbe\u5907 (\u89e6\u53d1\u5668)" }, - "description": "\u8fd9\u4e9b\u8bbe\u7f6e\u53ea\u6709\u5f53 HomeKit \u529f\u80fd\u4e0d\u6b63\u5e38\u65f6\u624d\u9700\u8981\u8c03\u6574\u3002", + "description": "\u5c06\u4e3a\u6bcf\u4e2a\u9009\u62e9\u7684\u8bbe\u5907\u521b\u5efa\u4e00\u4e2a\u53ef\u7f16\u7a0b\u5f00\u5173\u914d\u4ef6\u3002\u53ef\u4ee5\u5728 HomeKit \u4e2d\u914d\u7f6e\u8fd9\u4e9b\u914d\u4ef6\uff0c\u5f53\u8bbe\u5907\u89e6\u53d1\u65f6\uff0c\u6267\u884c\u6307\u5b9a\u7684\u81ea\u52a8\u5316\u6216\u573a\u666f\u3002", "title": "\u9ad8\u7ea7\u914d\u7f6e" }, "cameras": { "data": { + "camera_audio": "\u652f\u6301\u97f3\u9891\u7684\u6444\u50cf\u673a", "camera_copy": "\u652f\u6301\u539f\u751f H.264 \u63a8\u6d41\u7684\u6444\u50cf\u673a" }, "description": "\u67e5\u627e\u6240\u6709\u652f\u6301\u539f\u751f H.264 \u63a8\u6d41\u7684\u6444\u50cf\u673a\u3002\u5982\u679c\u6444\u50cf\u673a\u8f93\u51fa\u7684\u4e0d\u662f H.264 \u6d41\uff0c\u7cfb\u7edf\u4f1a\u5c06\u89c6\u9891\u8f6c\u7801\u4e3a H.264 \u4ee5\u4f9b HomeKit \u4f7f\u7528\u3002\u8f6c\u7801\u9700\u8981\u9ad8\u6027\u80fd\u7684 CPU\uff0c\u56e0\u6b64\u5728\u5f00\u53d1\u677f\u8ba1\u7b97\u673a\u4e0a\u5f88\u96be\u5b8c\u6210\u3002", - "title": "\u8bf7\u9009\u62e9\u6444\u50cf\u673a\u7684\u89c6\u9891\u7f16\u7801\u3002" + "title": "\u6444\u50cf\u673a\u914d\u7f6e" }, "include_exclude": { "data": { @@ -41,7 +41,7 @@ "mode": "\u6a21\u5f0f" }, "description": "\u9009\u62e9\u8981\u5f00\u653e\u7684\u5b9e\u4f53\u3002\u5728\u9644\u4ef6\u6a21\u5f0f\u4e2d\uff0c\u53ea\u80fd\u5f00\u653e\u4e00\u4e2a\u5b9e\u4f53\u3002\u5728\u6865\u63a5\u5305\u542b\u6a21\u5f0f\u4e2d\uff0c\u5982\u679c\u4e0d\u9009\u62e9\u5305\u542b\u7684\u5b9e\u4f53\uff0c\u57df\u4e2d\u6240\u6709\u5b9e\u4f53\u90fd\u4f1a\u5f00\u653e\u3002\u5728\u6865\u63a5\u6392\u9664\u6a21\u5f0f\u4e2d\uff0c\u5982\u679c\u4e0d\u9009\u62e9\u6392\u9664\u7684\u5b9e\u4f53\uff0c\u57df\u4e2d\u6240\u6709\u5b9e\u4f53\u4e5f\u90fd\u4f1a\u5f00\u653e\u3002", - "title": "\u9009\u62e9\u8981\u5f00\u653e\u7684\u5b9e\u4f53" + "title": "\u9009\u62e9\u8981\u5305\u542b\u7684\u5b9e\u4f53" }, "init": { "data": { @@ -49,7 +49,7 @@ "mode": "\u6a21\u5f0f" }, "description": "HomeKit \u53ef\u4ee5\u88ab\u914d\u7f6e\u4e3a\u5bf9\u5916\u5c55\u793a\u4e00\u4e2a\u6865\u63a5\u5668\u6216\u5355\u4e2a\u914d\u4ef6\u3002\u5728\u914d\u4ef6\u6a21\u5f0f\u4e2d\uff0c\u53ea\u80fd\u4f7f\u7528\u4e00\u4e2a\u5b9e\u4f53\u3002\u8bbe\u5907\u7c7b\u578b\u4e3a\u201c\u7535\u89c6\u201d\u7684\u5a92\u4f53\u64ad\u653e\u5668\u5fc5\u987b\u4f7f\u7528\u914d\u4ef6\u6a21\u5f0f\u624d\u80fd\u6b63\u5e38\u5de5\u4f5c\u3002\u201c\u8981\u5305\u542b\u7684\u57df\u201d\u4e2d\u7684\u5b9e\u4f53\u5c06\u5411 HomeKit \u5f00\u653e\u3002\u5728\u4e0b\u4e00\u9875\u53ef\u4ee5\u9009\u62e9\u8981\u5305\u542b\u6216\u6392\u9664\u5176\u4e2d\u7684\u54ea\u4e9b\u5b9e\u4f53\u3002", - "title": "\u9009\u62e9\u8981\u5f00\u653e\u7684\u57df\u3002" + "title": "\u9009\u62e9\u8981\u5305\u542b\u7684\u57df\u3002" }, "yaml": { "description": "\u8be5\u6761\u76ee\u662f\u901a\u8fc7 YAML \u63a7\u5236\u7684", diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json index 09f9220c20f88..ba1cd8adf888b 100644 --- a/homeassistant/components/homekit/translations/zh-Hant.json +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -4,31 +4,15 @@ "port_name_in_use": "\u4f7f\u7528\u76f8\u540c\u540d\u7a31\u6216\u901a\u8a0a\u57e0\u7684\u914d\u4ef6\u6216 Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" }, "step": { - "accessory_mode": { - "data": { - "entity_id": "\u5be6\u9ad4" - }, - "description": "\u9078\u64c7\u8981\u5305\u542b\u7684\u5be6\u9ad4\u3002\u65bc\u914d\u4ef6\u6a21\u5f0f\u4e0b\uff0c\u50c5\u80fd\u5305\u542b\u55ae\u4e00\u5be6\u9ad4\u3002", - "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u5be6\u9ad4" - }, - "bridge_mode": { - "data": { - "include_domains": "\u5305\u542b\u7db2\u57df" - }, - "description": "\u9078\u64c7\u8981\u5305\u542b\u7684\u7db2\u57df\u3002\u6240\u6709\u7db2\u57df\u5167\u652f\u63f4\u7684\u5be6\u9ad4\u90fd\u6703\u5305\u542b\u3002", - "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u7db2\u57df" - }, "pairing": { "description": "\u6b32\u5b8c\u6210\u914d\u5c0d\u3001\u8acb\u8ddf\u96a8\u300c\u901a\u77e5\u300d\u5167\u7684\u300cHomekit \u914d\u5c0d\u300d\u6307\u5f15\u3002", "title": "\u914d\u5c0d HomeKit" }, "user": { "data": { - "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u4f7f\u7528 Z-Wave \u6216\u5176\u4ed6\u5ef6\u9072\u555f\u52d5\u7cfb\u7d71\u6642\u3001\u8acb\u95dc\u9589\uff09", - "include_domains": "\u5305\u542b\u7db2\u57df", - "mode": "\u6a21\u5f0f" + "include_domains": "\u5305\u542b\u7db2\u57df" }, - "description": "\u9078\u64c7\u6240\u8981\u5305\u542b\u7684\u7db2\u57df\uff0c\u6240\u6709\u8a72\u7db2\u57df\u5167\u652f\u63f4\u7684\u5be6\u9ad4\u90fd\u5c07\u6703\u88ab\u5305\u542b\u3002 \u5176\u4ed6 Homekit \u5a92\u9ad4\u64ad\u653e\u5668\u8207\u651d\u5f71\u6a5f\u5be6\u4f8b\uff0c\u5c07\u6703\u4ee5\u914d\u4ef6\u6a21\u5f0f\u65b0\u589e\u3002", + "description": "\u9078\u64c7\u6240\u8981\u5305\u542b\u7684\u7db2\u57df\uff0c\u6240\u6709\u8a72\u7db2\u57df\u5167\u652f\u63f4\u7684\u5be6\u9ad4\u90fd\u5c07\u6703\u88ab\u5305\u542b\u3002\u6bcf\u4e00\u500b\u96fb\u8996\u5a92\u9ad4\u64ad\u653e\u5668\u3001\u9060\u7aef\u9059\u63a7\u5668\u3001\u9580\u9396\u53ca\u651d\u5f71\u6a5f\uff0c\u5c07\u4ee5 Homekit \u914d\u4ef6\u6a21\u5f0f\u65b0\u589e\u3002", "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u7db2\u57df" } } @@ -38,17 +22,18 @@ "advanced": { "data": { "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u624b\u52d5\u4f7f\u7528 homekit.start \u670d\u52d9\u6642\u3001\u8acb\u95dc\u9589\uff09", - "safe_mode": "\u5b89\u5168\u6a21\u5f0f\uff08\u50c5\u65bc\u914d\u5c0d\u5931\u6557\u6642\u4f7f\u7528\uff09" + "devices": "\u88dd\u7f6e\uff08\u89f8\u767c\u5668\uff09" }, - "description": "\u50c5\u65bc Homekit \u7121\u6cd5\u6b63\u5e38\u4f7f\u7528\u6642\uff0c\u8abf\u6574\u6b64\u4e9b\u8a2d\u5b9a\u3002", + "description": "\u70ba\u6240\u9078\u64c7\u7684\u88dd\u7f6e\u65b0\u589e\u53ef\u7a0b\u5f0f\u958b\u95dc\u3002\u7576\u88dd\u7f6e\u89f8\u767c\u5668\u89f8\u767c\u6642\u3001Homekit \u53ef\u8a2d\u5b9a\u70ba\u57f7\u884c\u81ea\u52d5\u5316\u6216\u5834\u666f\u3002", "title": "\u9032\u968e\u8a2d\u5b9a" }, "cameras": { "data": { + "camera_audio": "\u652f\u63f4\u97f3\u6548\u8f38\u51fa\u651d\u5f71\u6a5f", "camera_copy": "\u652f\u63f4\u539f\u751f H.264 \u4e32\u6d41\u651d\u5f71\u6a5f" }, "description": "\u6aa2\u67e5\u6240\u6709\u652f\u63f4\u539f\u751f H.264 \u4e32\u6d41\u4e4b\u651d\u5f71\u6a5f\u3002\u5047\u5982\u651d\u5f71\u6a5f\u4e0d\u652f\u63f4 H.264 \u4e32\u6d41\u3001\u7cfb\u7d71\u5c07\u6703\u91dd\u5c0d Homekit \u9032\u884c H.264 \u8f49\u78bc\u3002\u8f49\u78bc\u5c07\u9700\u8981\u4f7f\u7528 CPU \u9032\u884c\u904b\u7b97\u3001\u55ae\u6676\u7247\u96fb\u8166\u53ef\u80fd\u6703\u906d\u9047\u6548\u80fd\u554f\u984c\u3002", - "title": "\u9078\u64c7\u651d\u5f71\u6a5f\u7de8\u78bc\u3002" + "title": "\u651d\u5f71\u6a5f\u8a2d\u5b9a" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 48f7ad9b06479..9d1821ec7242a 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -11,14 +11,13 @@ ) from pyhap.const import CATEGORY_CAMERA -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import STATE_ON from homeassistant.core import callback from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, ) -from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( @@ -56,7 +55,6 @@ SERV_SPEAKER, SERV_STATELESS_PROGRAMMABLE_SWITCH, ) -from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) @@ -141,10 +139,10 @@ class Camera(HomeAccessory, PyhapCamera): def __init__(self, hass, driver, name, entity_id, aid, config): """Initialize a Camera accessory object.""" - self._ffmpeg = hass.data[DATA_FFMPEG] - for config_key in CONFIG_DEFAULTS: + self._ffmpeg = get_ffmpeg_manager(hass) + for config_key, conf in CONFIG_DEFAULTS.items(): if config_key not in config: - config[config_key] = CONFIG_DEFAULTS[config_key] + config[config_key] = conf max_fps = config[CONF_MAX_FPS] max_width = config[CONF_MAX_WIDTH] @@ -181,7 +179,7 @@ def __init__(self, hass, driver, name, entity_id, aid, config): ] } - stream_address = config.get(CONF_STREAM_ADDRESS, get_local_ip()) + stream_address = config.get(CONF_STREAM_ADDRESS, driver.state.address) options = { "video": video_options, @@ -246,17 +244,21 @@ async def run(self): Run inside the Home Assistant event loop. """ if self._char_motion_detected: - async_track_state_change_event( - self.hass, - [self.linked_motion_sensor], - self._async_update_motion_state_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_motion_sensor], + self._async_update_motion_state_event, + ) ) if self._char_doorbell_detected: - async_track_state_change_event( - self.hass, - [self.linked_doorbell_sensor], - self._async_update_doorbell_state_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_doorbell_sensor], + self._async_update_doorbell_state_event, + ) ) await super().run() @@ -312,8 +314,7 @@ def async_update_state(self, new_state): async def _async_get_stream_source(self): """Find the camera stream source url.""" - stream_source = self.config.get(CONF_STREAM_SOURCE) - if stream_source: + if stream_source := self.config.get(CONF_STREAM_SOURCE): return stream_source try: stream_source = await self.hass.components.camera.async_get_stream_source( @@ -323,8 +324,6 @@ async def _async_get_stream_source(self): _LOGGER.exception( "Failed to get stream source - this could be a transient error or your camera might not be compatible with HomeKit yet" ) - if stream_source: - self.config[CONF_STREAM_SOURCE] = stream_source return stream_source async def start_stream(self, session_info, stream_config): @@ -334,8 +333,7 @@ async def start_stream(self, session_info, stream_config): session_info["id"], stream_config, ) - input_source = await self._async_get_stream_source() - if not input_source: + if not (input_source := await self._async_get_stream_source()): _LOGGER.error("Camera has no stream source") return False if "-i " not in input_source: @@ -438,11 +436,16 @@ def _async_stop_ffmpeg_watch(self, session_id): self.sessions[session_id].pop(FFMPEG_WATCHER)() self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() + async def stop(self): + """Stop any streams when the accessory is stopped.""" + for session_info in self.sessions.values(): + asyncio.create_task(self.stop_stream(session_info)) + await super().stop() + async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] - stream = session_info.get("stream") - if not stream: + if not (stream := session_info.get("stream")): _LOGGER.debug("No stream for session ID %s", session_id) return @@ -452,7 +455,7 @@ async def stop_stream(self, session_info): _LOGGER.info("[%s] Stream already stopped", session_id) return True - for shutdown_method in ["close", "kill"]: + for shutdown_method in ("close", "kill"): _LOGGER.info("[%s] %s stream", session_id, shutdown_method) try: await getattr(stream, shutdown_method)() @@ -468,8 +471,9 @@ async def reconfigure_stream(self, session_info, stream_config): async def async_get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" - return scale_jpeg_camera_image( - await self.hass.components.camera.async_get_image(self.entity_id), - image_size["image-width"], - image_size["image-height"], + image = await self.hass.components.camera.async_get_image( + self.entity_id, + width=image_size["image-width"], + height=image_size["image-height"], ) + return image.content diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index f21287b3bf847..51d0480bde377 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -13,6 +13,7 @@ ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) @@ -53,6 +54,8 @@ HK_POSITION_GOING_TO_MAX, HK_POSITION_GOING_TO_MIN, HK_POSITION_STOPPED, + PROP_MAX_VALUE, + PROP_MIN_VALUE, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW, SERV_WINDOW_COVERING, @@ -119,10 +122,12 @@ async def run(self): Run inside the Home Assistant event loop. """ if self.linked_obstruction_sensor: - async_track_state_change_event( - self.hass, - [self.linked_obstruction_sensor], - self._async_update_obstruction_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_obstruction_sensor], + self._async_update_obstruction_event, + ) ) await super().run() @@ -175,18 +180,11 @@ def async_update_state(self, new_state): obstruction_detected = ( new_state.attributes[ATTR_OBSTRUCTION_DETECTED] is True ) - if self.char_obstruction_detected.value != obstruction_detected: - self.char_obstruction_detected.set_value(obstruction_detected) + self.char_obstruction_detected.set_value(obstruction_detected) - if ( - target_door_state is not None - and self.char_target_state.value != target_door_state - ): + if target_door_state is not None: self.char_target_state.set_value(target_door_state) - if ( - current_door_state is not None - and self.char_current_state.value != current_door_state - ): + if current_door_state is not None: self.char_current_state.set_value(current_door_state) @@ -251,16 +249,17 @@ def set_tilt(self, value): def async_update_state(self, new_state): """Update cover position and tilt after state changed.""" # update tilt + if not self._supports_tilt: + return current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - if isinstance(current_tilt, (float, int)): - # HomeKit sends values between -90 and 90. - # We'll have to normalize to [0,100] - current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 - current_tilt = int(current_tilt) - if self.char_current_tilt.value != current_tilt: - self.char_current_tilt.set_value(current_tilt) - if self.char_target_tilt.value != current_tilt: - self.char_target_tilt.set_value(current_tilt) + if not isinstance(current_tilt, (float, int)): + return + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 + current_tilt = int(current_tilt) + self.char_current_tilt.set_value(current_tilt) + self.char_target_tilt.set_value(current_tilt) class OpeningDevice(OpeningDeviceBase, HomeAccessory): @@ -273,12 +272,24 @@ def __init__(self, *args, category, service): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=category, service=service) state = self.hass.states.get(self.entity_id) - self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) + target_args = {"value": 0} + if self.features & SUPPORT_SET_POSITION: + target_args["setter_callback"] = self.move_cover + else: + # If its tilt only we lock the position state to 0 (closed) + # since CHAR_CURRENT_POSITION/CHAR_TARGET_POSITION are required + # by homekit, but really don't exist. + _LOGGER.debug( + "%s does not support setting position, current position will be locked to closed", + self.entity_id, + ) + target_args["properties"] = {PROP_MIN_VALUE: 0, PROP_MAX_VALUE: 0} + self.char_target_position = self.serv_cover.configure_char( - CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + CHAR_TARGET_POSITION, **target_args ) self.char_position_state = self.serv_cover.configure_char( CHAR_POSITION_STATE, value=HK_POSITION_STOPPED @@ -297,21 +308,18 @@ def async_update_state(self, new_state): current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, (float, int)): current_position = int(current_position) - if self.char_current_position.value != current_position: - self.char_current_position.set_value(current_position) - if self.char_target_position.value != current_position: - self.char_target_position.set_value(current_position) + self.char_current_position.set_value(current_position) + self.char_target_position.set_value(current_position) position_state = _hass_state_to_position_start(new_state.state) - if self.char_position_state.value != position_state: - self.char_position_state.set_value(position_state) + self.char_position_state.set_value(position_state) super().async_update_state(new_state) @TYPES.register("Window") class Window(OpeningDevice): - """Generate a Window accessory for a cover entity with DEVICE_CLASS_WINDOW. + """Generate a Window accessory for a cover entity with WINDOW device class. The entity must support: set_cover_position. """ diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 1efb3b6c8bed8..d25f197e0ca7a 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -39,6 +39,7 @@ CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE, + MAX_NAME_LENGTH, PROP_MIN_STEP, SERV_FANV2, SERV_SWITCH, @@ -100,7 +101,8 @@ def __init__(self, *args): preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( - CHAR_NAME, value=f"{self.display_name} {preset_mode}" + CHAR_NAME, + value=f"{self.display_name} {preset_mode}"[:MAX_NAME_LENGTH], ) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( @@ -191,16 +193,14 @@ def async_update_state(self, new_state): state = new_state.state if state in (STATE_ON, STATE_OFF): self._state = 1 if state == STATE_ON else 0 - if self.char_active.value != self._state: - self.char_active.set_value(self._state) + self.char_active.set_value(self._state) # Handle Direction if self.char_direction is not None: direction = new_state.attributes.get(ATTR_DIRECTION) if direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): hk_direction = 1 if direction == DIRECTION_REVERSE else 0 - if self.char_direction.value != hk_direction: - self.char_direction.set_value(hk_direction) + self.char_direction.set_value(hk_direction) # Handle Speed if self.char_speed is not None and state != STATE_OFF: @@ -219,8 +219,8 @@ def async_update_state(self, new_state): # the rotation speed is mapped to 1 otherwise the update is ignored # in order to avoid this incorrect behavior. if percentage == 0 and state == STATE_ON: - percentage = 1 - if percentage is not None and self.char_speed.value != percentage: + percentage = max(1, self.char_speed.properties[PROP_MIN_STEP]) + if percentage is not None: self.char_speed.set_value(percentage) # Handle Oscillating @@ -228,11 +228,9 @@ def async_update_state(self, new_state): oscillating = new_state.attributes.get(ATTR_OSCILLATING) if isinstance(oscillating, bool): hk_oscillating = 1 if oscillating else 0 - if self.char_swing.value != hk_oscillating: - self.char_swing.set_value(hk_oscillating) + self.char_swing.set_value(hk_oscillating) current_preset_mode = new_state.attributes.get(ATTR_PRESET_MODE) for preset_mode, char in self.preset_mode_chars.items(): hk_value = 1 if preset_mode == current_preset_mode else 0 - if char.value != hk_value: - char.set_value(hk_value) + char.set_value(hk_value) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index a4a73abf998fa..a468efd42b4ee 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -3,14 +3,13 @@ from pyhap.const import CATEGORY_HUMIDIFIER +from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.components.humidifier.const import ( ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, DOMAIN, SERVICE_SET_HUMIDITY, ) @@ -46,13 +45,13 @@ HC_DEHUMIDIFIER = 2 HC_HASS_TO_HOMEKIT_DEVICE_CLASS = { - DEVICE_CLASS_HUMIDIFIER: HC_HUMIDIFIER, - DEVICE_CLASS_DEHUMIDIFIER: HC_DEHUMIDIFIER, + HumidifierDeviceClass.HUMIDIFIER: HC_HUMIDIFIER, + HumidifierDeviceClass.DEHUMIDIFIER: HC_DEHUMIDIFIER, } HC_HASS_TO_HOMEKIT_DEVICE_CLASS_NAME = { - DEVICE_CLASS_HUMIDIFIER: "Humidifier", - DEVICE_CLASS_DEHUMIDIFIER: "Dehumidifier", + HumidifierDeviceClass.HUMIDIFIER: "Humidifier", + HumidifierDeviceClass.DEHUMIDIFIER: "Dehumidifier", } HC_DEVICE_CLASS_TO_TARGET_CHAR = { @@ -75,7 +74,9 @@ def __init__(self, *args): super().__init__(*args, category=CATEGORY_HUMIDIFIER) self.chars = [] state = self.hass.states.get(self.entity_id) - device_class = state.attributes.get(ATTR_DEVICE_CLASS, DEVICE_CLASS_HUMIDIFIER) + device_class = state.attributes.get( + ATTR_DEVICE_CLASS, HumidifierDeviceClass.HUMIDIFIER + ) self._hk_device_class = HC_HASS_TO_HOMEKIT_DEVICE_CLASS[device_class] self._target_humidity_char_name = HC_DEVICE_CLASS_TO_TARGET_CHAR[ @@ -149,10 +150,12 @@ async def run(self): Run inside the Home Assistant event loop. """ if self.linked_humidity_sensor: - async_track_state_change_event( - self.hass, - [self.linked_humidity_sensor], - self.async_update_current_humidity_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self.async_update_current_humidity_event, + ) ) await super().run() @@ -224,8 +227,7 @@ def async_update_state(self, new_state): is_active = new_state.state == STATE_ON # Update active state - if self.char_active.value != is_active: - self.char_active.set_value(is_active) + self.char_active.set_value(is_active) # Set current state if is_active: @@ -235,13 +237,9 @@ def async_update_state(self, new_state): current_state = HC_STATE_DEHUMIDIFYING else: current_state = HC_STATE_INACTIVE - if self.char_current_humidifier_dehumidifier.value != current_state: - self.char_current_humidifier_dehumidifier.set_value(current_state) + self.char_current_humidifier_dehumidifier.set_value(current_state) # Update target humidity target_humidity = new_state.attributes.get(ATTR_HUMIDITY) - if ( - isinstance(target_humidity, (int, float)) - and self.char_target_humidity.value != target_humidity - ): + if isinstance(target_humidity, (int, float)): self.char_target_humidity.set_value(target_humidity) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cb3c97fadb4fb..f925f0a15a492 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -1,5 +1,6 @@ """Class to hold all light accessories.""" import logging +import math from pyhap.const import CATEGORY_LIGHTBULB @@ -18,13 +19,12 @@ ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, ) from homeassistant.core import callback +from homeassistant.helpers.event import async_call_later from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_temperature_to_hs, @@ -46,6 +46,8 @@ RGB_COLOR = "rgb_color" +CHANGE_COALESCE_TIME_WINDOW = 0.01 + @TYPES.register("Light") class Light(HomeAccessory): @@ -59,61 +61,78 @@ def __init__(self, *args): super().__init__(*args, category=CATEGORY_LIGHTBULB) self.chars = [] - state = self.hass.states.get(self.entity_id) + self._event_timer = None + self._pending_events = {} - self._features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self._color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) + state = self.hass.states.get(self.entity_id) + attributes = state.attributes + color_modes = attributes.get(ATTR_SUPPORTED_COLOR_MODES) + self.color_supported = color_supported(color_modes) + self.color_temp_supported = color_temp_supported(color_modes) + self.brightness_supported = brightness_supported(color_modes) - if brightness_supported(self._color_modes): + if self.brightness_supported: self.chars.append(CHAR_BRIGHTNESS) - if color_supported(self._color_modes): - self.chars.append(CHAR_HUE) - self.chars.append(CHAR_SATURATION) - elif color_temp_supported(self._color_modes): - # ColorTemperature and Hue characteristic should not be - # exposed both. Both states are tracked separately in HomeKit, - # causing "source of truth" problems. + if self.color_supported: + self.chars.extend([CHAR_HUE, CHAR_SATURATION]) + + if self.color_temp_supported: self.chars.append(CHAR_COLOR_TEMPERATURE) serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) - self.char_on = serv_light.configure_char(CHAR_ON, value=0) - if CHAR_BRIGHTNESS in self.chars: + if self.brightness_supported: # Initial value is set to 100 because 0 is a special value (off). 100 is # an arbitrary non-zero value. It is updated immediately by async_update_state # to set to the correct initial value. self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) - if CHAR_COLOR_TEMPERATURE in self.chars: - min_mireds = self.hass.states.get(self.entity_id).attributes.get( - ATTR_MIN_MIREDS, 153 - ) - max_mireds = self.hass.states.get(self.entity_id).attributes.get( - ATTR_MAX_MIREDS, 500 - ) - self.char_color_temperature = serv_light.configure_char( + if self.color_temp_supported: + min_mireds = math.floor(attributes.get(ATTR_MIN_MIREDS, 153)) + max_mireds = math.ceil(attributes.get(ATTR_MAX_MIREDS, 500)) + self.char_color_temp = serv_light.configure_char( CHAR_COLOR_TEMPERATURE, value=min_mireds, properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, ) - if CHAR_HUE in self.chars: + if self.color_supported: self.char_hue = serv_light.configure_char(CHAR_HUE, value=0) - - if CHAR_SATURATION in self.chars: self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) self.async_update_state(state) - serv_light.setter_callback = self._set_chars def _set_chars(self, char_values): _LOGGER.debug("Light _set_chars: %s", char_values) + # Newest change always wins + if CHAR_COLOR_TEMPERATURE in self._pending_events and ( + CHAR_SATURATION in char_values or CHAR_HUE in char_values + ): + del self._pending_events[CHAR_COLOR_TEMPERATURE] + for char in (CHAR_HUE, CHAR_SATURATION): + if char in self._pending_events and CHAR_COLOR_TEMPERATURE in char_values: + del self._pending_events[char] + + self._pending_events.update(char_values) + if self._event_timer: + self._event_timer() + self._event_timer = async_call_later( + self.hass, CHANGE_COALESCE_TIME_WINDOW, self._async_send_events + ) + + @callback + def _async_send_events(self, *_): + """Process all changes at once.""" + _LOGGER.debug("Coalesced _set_chars: %s", self._pending_events) + char_values = self._pending_events + self._pending_events = {} events = [] service = SERVICE_TURN_ON params = {ATTR_ENTITY_ID: self.entity_id} + if CHAR_ON in char_values: if not char_values[CHAR_ON]: service = SERVICE_TURN_OFF @@ -127,18 +146,22 @@ def _set_chars(self, char_values): params[ATTR_BRIGHTNESS_PCT] = char_values[CHAR_BRIGHTNESS] events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%") + if service == SERVICE_TURN_OFF: + self.async_call_service( + DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id}, ", ".join(events) + ) + return + if CHAR_COLOR_TEMPERATURE in char_values: params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] - events.append(f"color temperature at {char_values[CHAR_COLOR_TEMPERATURE]}") + events.append(f"color temperature at {params[ATTR_COLOR_TEMP]}") - if ( - color_supported(self._color_modes) - and CHAR_HUE in char_values - and CHAR_SATURATION in char_values - ): - color = (char_values[CHAR_HUE], char_values[CHAR_SATURATION]) + elif CHAR_HUE in char_values or CHAR_SATURATION in char_values: + color = params[ATTR_HS_COLOR] = ( + char_values.get(CHAR_HUE, self.char_hue.value), + char_values.get(CHAR_SATURATION, self.char_saturation.value), + ) _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) - params[ATTR_HS_COLOR] = color events.append(f"set color at {color}") self.async_call_service(DOMAIN, service, params, ", ".join(events)) @@ -148,14 +171,12 @@ def async_update_state(self, new_state): """Update light after state change.""" # Handle State state = new_state.state - if state == STATE_ON and self.char_on.value != 1: - self.char_on.set_value(1) - elif state == STATE_OFF and self.char_on.value != 0: - self.char_on.set_value(0) + attributes = new_state.attributes + self.char_on.set_value(int(state == STATE_ON)) # Handle Brightness - if CHAR_BRIGHTNESS in self.chars: - brightness = new_state.attributes.get(ATTR_BRIGHTNESS) + if self.brightness_supported: + brightness = attributes.get(ATTR_BRIGHTNESS) if isinstance(brightness, (int, float)): brightness = round(brightness / 255 * 100, 0) # The homeassistant component might report its brightness as 0 but is @@ -170,33 +191,25 @@ def async_update_state(self, new_state): # order to avoid this incorrect behavior. if brightness == 0 and state == STATE_ON: brightness = 1 - if self.char_brightness.value != brightness: - self.char_brightness.set_value(brightness) + self.char_brightness.set_value(brightness) - # Handle color temperature - if CHAR_COLOR_TEMPERATURE in self.chars: - color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) - if isinstance(color_temperature, (int, float)): - color_temperature = round(color_temperature, 0) - if self.char_color_temperature.value != color_temperature: - self.char_color_temperature.set_value(color_temperature) - - # Handle Color - if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars: - if ATTR_HS_COLOR in new_state.attributes: - hue, saturation = new_state.attributes[ATTR_HS_COLOR] - elif ATTR_COLOR_TEMP in new_state.attributes: + # Handle Color - color must always be set before color temperature + # or the iOS UI will not display it correctly. + if self.color_supported: + if ATTR_COLOR_TEMP in attributes: hue, saturation = color_temperature_to_hs( color_temperature_mired_to_kelvin( new_state.attributes[ATTR_COLOR_TEMP] ) ) else: - hue, saturation = None, None + hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): - hue = round(hue, 0) - saturation = round(saturation, 0) - if hue != self.char_hue.value: - self.char_hue.set_value(hue) - if saturation != self.char_saturation.value: - self.char_saturation.set_value(saturation) + self.char_hue.set_value(round(hue, 0)) + self.char_saturation.set_value(round(saturation, 0)) + + # Handle color temperature + if self.color_temp_supported: + color_temp = attributes.get(ATTR_COLOR_TEMP) + if isinstance(color_temp, (int, float)): + self.char_color_temp.set_value(round(color_temp, 0)) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 17e2eee46e87f..af7501e186997 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -3,7 +3,14 @@ from pyhap.const import CATEGORY_DOOR_LOCK -from homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import ( + DOMAIN, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import callback @@ -12,16 +19,37 @@ _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = { +HASS_TO_HOMEKIT_CURRENT = { STATE_UNLOCKED: 0, + STATE_UNLOCKING: 1, + STATE_LOCKING: 0, STATE_LOCKED: 1, - # Value 2 is Jammed which hass doesn't have a state for + STATE_JAMMED: 2, STATE_UNKNOWN: 3, } -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +HASS_TO_HOMEKIT_TARGET = { + STATE_UNLOCKED: 0, + STATE_UNLOCKING: 0, + STATE_LOCKING: 1, + STATE_LOCKED: 1, +} + +VALID_TARGET_STATES = {STATE_LOCKING, STATE_UNLOCKING, STATE_LOCKED, STATE_UNLOCKED} + +HOMEKIT_TO_HASS = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: STATE_UNKNOWN, +} -STATE_TO_SERVICE = {STATE_LOCKED: "lock", STATE_UNLOCKED: "unlock"} +STATE_TO_SERVICE = { + STATE_LOCKING: "unlock", + STATE_LOCKED: "lock", + STATE_UNLOCKING: "lock", + STATE_UNLOCKED: "unlock", +} @TYPES.register("Lock") @@ -39,11 +67,11 @@ def __init__(self, *args): serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( - CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT[STATE_UNKNOWN] + CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT_CURRENT[STATE_UNKNOWN] ) self.char_target_state = serv_lock_mechanism.configure_char( CHAR_LOCK_TARGET_STATE, - value=HASS_TO_HOMEKIT[STATE_LOCKED], + value=HASS_TO_HOMEKIT_CURRENT[STATE_LOCKED], setter_callback=self.set_state, ) self.async_update_state(state) @@ -52,12 +80,9 @@ def set_state(self, value): """Set lock state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) - hass_value = HOMEKIT_TO_HASS.get(value) + hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] - if self.char_current_state.value != value: - self.char_current_state.set_value(value) - params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code @@ -67,25 +92,24 @@ def set_state(self, value): def async_update_state(self, new_state): """Update lock after state changed.""" hass_state = new_state.state - if hass_state in HASS_TO_HOMEKIT: - current_lock_state = HASS_TO_HOMEKIT[hass_state] - _LOGGER.debug( - "%s: Updated current state to %s (%d)", - self.entity_id, - hass_state, - current_lock_state, - ) - # LockTargetState only supports locked and unlocked - # Must set lock target state before current state - # or there will be no notification - if ( - hass_state in (STATE_LOCKED, STATE_UNLOCKED) - and self.char_target_state.value != current_lock_state - ): - self.char_target_state.set_value(current_lock_state) - - # Set lock current state ONLY after ensuring that - # target state is correct or there will be no - # notification - if self.char_current_state.value != current_lock_state: - self.char_current_state.set_value(current_lock_state) + current_lock_state = HASS_TO_HOMEKIT_CURRENT.get( + hass_state, HASS_TO_HOMEKIT_CURRENT[STATE_UNKNOWN] + ) + target_lock_state = HASS_TO_HOMEKIT_TARGET.get(hass_state) + _LOGGER.debug( + "%s: Updated current state to %s (current=%d) (target=%s)", + self.entity_id, + hass_state, + current_lock_state, + target_lock_state, + ) + # LockTargetState only supports locked and unlocked + # Must set lock target state before current state + # or there will be no notification + if target_lock_state is not None: + self.char_target_state.set_value(target_lock_state) + + # Set lock current state ONLY after ensuring that + # target state is correct or there will be no + # notification + self.char_current_state.set_value(current_lock_state) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 5cd27109bd8fc..61c043ebcaac2 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -55,6 +55,7 @@ FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, KEY_PLAY_PAUSE, + MAX_NAME_LENGTH, SERV_SWITCH, SERV_TELEVISION_SPEAKER, ) @@ -134,7 +135,7 @@ def __init__(self, *args): def generate_service_name(self, mode): """Generate name for individual service.""" - return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" + return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}"[:MAX_NAME_LENGTH] def set_on_off(self, value): """Move switch state to value if call came from HomeKit.""" @@ -179,8 +180,7 @@ def async_update_state(self, new_state): _LOGGER.debug( '%s: Set current state for "on_off" to %s', self.entity_id, hk_state ) - if self.chars[FEATURE_ON_OFF].value != hk_state: - self.chars[FEATURE_ON_OFF].set_value(hk_state) + self.chars[FEATURE_ON_OFF].set_value(hk_state) if self.chars[FEATURE_PLAY_PAUSE]: hk_state = current_state == STATE_PLAYING @@ -189,8 +189,7 @@ def async_update_state(self, new_state): self.entity_id, hk_state, ) - if self.chars[FEATURE_PLAY_PAUSE].value != hk_state: - self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) if self.chars[FEATURE_PLAY_STOP]: hk_state = current_state == STATE_PLAYING @@ -199,8 +198,7 @@ def async_update_state(self, new_state): self.entity_id, hk_state, ) - if self.chars[FEATURE_PLAY_STOP].value != hk_state: - self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + self.chars[FEATURE_PLAY_STOP].set_value(hk_state) if self.chars[FEATURE_TOGGLE_MUTE]: current_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) @@ -209,8 +207,7 @@ def async_update_state(self, new_state): self.entity_id, current_state, ) - if self.chars[FEATURE_TOGGLE_MUTE].value != current_state: - self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) @TYPES.register("TelevisionMediaPlayer") @@ -306,8 +303,7 @@ def set_input_source(self, value): def set_remote_key(self, value): """Send remote key value if call came from HomeKit.""" _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) - key_name = REMOTE_KEYS.get(value) - if key_name is None: + if (key_name := REMOTE_KEYS.get(value)) is None: _LOGGER.warning("%s: Unhandled key press for %s", self.entity_id, value) return @@ -340,8 +336,7 @@ def async_update_state(self, new_state): if current_state not in MEDIA_PLAYER_OFF_STATES: hk_state = 1 _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) - if self.char_active.value != hk_state: - self.char_active.set_value(hk_state) + self.char_active.set_value(hk_state) # Set mute state if CHAR_VOLUME_SELECTOR in self.chars_speaker: @@ -351,7 +346,6 @@ def async_update_state(self, new_state): self.entity_id, current_mute_state, ) - if self.char_mute.value != current_mute_state: - self.char_mute.set_value(current_mute_state) + self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index e4f18a7c16f42..69267c733d28a 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -47,10 +47,15 @@ KEY_PREVIOUS_TRACK, KEY_REWIND, KEY_SELECT, + MAX_NAME_LENGTH, SERV_INPUT_SOURCE, SERV_TELEVISION, ) +MAXIMUM_SOURCES = ( + 90 # Maximum services per accessory is 100. The base acccessory uses 9 +) + _LOGGER = logging.getLogger(__name__) REMOTE_KEYS = { @@ -87,10 +92,18 @@ def __init__( features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) self.source_key = source_key + self.source_list_key = source_list_key self.sources = [] self.support_select_source = False if features & required_feature: - self.sources = state.attributes.get(source_list_key, []) + sources = state.attributes.get(source_list_key, []) + if len(sources) > MAXIMUM_SOURCES: + _LOGGER.warning( + "%s: Reached maximum number of sources (%s)", + self.entity_id, + MAXIMUM_SOURCES, + ) + self.sources = sources[:MAXIMUM_SOURCES] if self.sources: self.support_select_source = True @@ -119,8 +132,10 @@ def __init__( SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME] ) serv_tv.add_linked_service(serv_input) - serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source) - serv_input.configure_char(CHAR_NAME, value=source) + serv_input.configure_char( + CHAR_CONFIGURED_NAME, value=source[:MAX_NAME_LENGTH] + ) + serv_input.configure_char(CHAR_NAME, value=source[:MAX_NAME_LENGTH]) serv_input.configure_char(CHAR_IDENTIFIER, value=index) serv_input.configure_char(CHAR_IS_CONFIGURED, value=True) input_type = 3 if "hdmi" in source.lower() else 0 @@ -150,15 +165,34 @@ def _async_update_input_state(self, hk_state, new_state): _LOGGER.debug("%s: Set current input to %s", self.entity_id, source_name) if source_name in self.sources: index = self.sources.index(source_name) - if self.char_input_source.value != index: - self.char_input_source.set_value(index) - elif hk_state: - _LOGGER.warning( - "%s: Sources out of sync. Restart Home Assistant", - self.entity_id, - ) - if self.char_input_source.value != 0: - self.char_input_source.set_value(0) + self.char_input_source.set_value(index) + return + + possible_sources = new_state.attributes.get(self.source_list_key, []) + if source_name in possible_sources: + index = possible_sources.index(source_name) + if index >= MAXIMUM_SOURCES: + _LOGGER.debug( + "%s: Source %s and above are not supported", + self.entity_id, + MAXIMUM_SOURCES, + ) + else: + _LOGGER.debug( + "%s: Sources out of sync. Rebuilding Accessory", + self.entity_id, + ) + # Sources are out of sync, recreate the accessory + self.async_reset() + return + + _LOGGER.debug( + "%s: Source %s does not exist the source list: %s", + self.entity_id, + source_name, + possible_sources, + ) + self.char_input_source.set_value(0) @TYPES.register("ActivityRemote") @@ -192,8 +226,7 @@ def set_input_source(self, value): def set_remote_key(self, value): """Send remote key value if call came from HomeKit.""" _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) - key_name = REMOTE_KEYS.get(value) - if key_name is None: + if (key_name := REMOTE_KEYS.get(value)) is None: _LOGGER.warning("%s: Unhandled key press for %s", self.entity_id, value) return self.hass.bus.async_fire( @@ -208,7 +241,6 @@ def async_update_state(self, new_state): # Power state remote hk_state = 1 if current_state == STATE_ON else 0 _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) - if self.char_active.value != hk_state: - self.char_active.set_value(hk_state) + self.char_active.set_value(hk_state) self._async_update_input_state(hk_state, new_state) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index acbf636c1c3b3..d76fbf0f534c3 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -2,13 +2,13 @@ import logging from pyhap.const import CATEGORY_ALARM_SYSTEM -from pyhap.loader import get_loader from homeassistant.components.alarm_control_panel import DOMAIN from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( @@ -22,6 +22,8 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -36,28 +38,43 @@ _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = { - STATE_ALARM_ARMED_HOME: 0, - STATE_ALARM_ARMED_AWAY: 1, - STATE_ALARM_ARMED_NIGHT: 2, - STATE_ALARM_DISARMED: 3, - STATE_ALARM_TRIGGERED: 4, +HK_ALARM_STAY_ARMED = 0 +HK_ALARM_AWAY_ARMED = 1 +HK_ALARM_NIGHT_ARMED = 2 +HK_ALARM_DISARMED = 3 +HK_ALARM_TRIGGERED = 4 + +HASS_TO_HOMEKIT_CURRENT = { + STATE_ALARM_ARMED_HOME: HK_ALARM_STAY_ARMED, + STATE_ALARM_ARMED_VACATION: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_AWAY: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_NIGHT: HK_ALARM_NIGHT_ARMED, + STATE_ALARM_ARMING: HK_ALARM_DISARMED, + STATE_ALARM_DISARMED: HK_ALARM_DISARMED, + STATE_ALARM_TRIGGERED: HK_ALARM_TRIGGERED, } -HASS_TO_HOMEKIT_SERVICES = { - SERVICE_ALARM_ARM_HOME: 0, - SERVICE_ALARM_ARM_AWAY: 1, - SERVICE_ALARM_ARM_NIGHT: 2, - SERVICE_ALARM_DISARM: 3, +HASS_TO_HOMEKIT_TARGET = { + STATE_ALARM_ARMED_HOME: HK_ALARM_STAY_ARMED, + STATE_ALARM_ARMED_VACATION: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_AWAY: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_NIGHT: HK_ALARM_NIGHT_ARMED, + STATE_ALARM_ARMING: HK_ALARM_AWAY_ARMED, + STATE_ALARM_DISARMED: HK_ALARM_DISARMED, } -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +HASS_TO_HOMEKIT_SERVICES = { + SERVICE_ALARM_ARM_HOME: HK_ALARM_STAY_ARMED, + SERVICE_ALARM_ARM_AWAY: HK_ALARM_AWAY_ARMED, + SERVICE_ALARM_ARM_NIGHT: HK_ALARM_NIGHT_ARMED, + SERVICE_ALARM_DISARM: HK_ALARM_DISARMED, +} -STATE_TO_SERVICE = { - STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, - STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, - STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, - STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM, +HK_TO_SERVICE = { + HK_ALARM_AWAY_ARMED: SERVICE_ALARM_ARM_AWAY, + HK_ALARM_STAY_ARMED: SERVICE_ALARM_ARM_HOME, + HK_ALARM_NIGHT_ARMED: SERVICE_ALARM_ARM_NIGHT, + HK_ALARM_DISARMED: SERVICE_ALARM_DISARM, } @@ -75,65 +92,51 @@ def __init__(self, *args): ATTR_SUPPORTED_FEATURES, ( SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER ), ) - loader = get_loader() - default_current_states = loader.get_char( - "SecuritySystemCurrentState" - ).properties.get("ValidValues") - default_target_services = loader.get_char( - "SecuritySystemTargetState" - ).properties.get("ValidValues") + serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) + current_char = serv_alarm.get_characteristic(CHAR_CURRENT_SECURITY_STATE) + target_char = serv_alarm.get_characteristic(CHAR_TARGET_SECURITY_STATE) + default_current_states = current_char.properties.get("ValidValues") + default_target_services = target_char.properties.get("ValidValues") - current_supported_states = [ - HASS_TO_HOMEKIT[STATE_ALARM_DISARMED], - HASS_TO_HOMEKIT[STATE_ALARM_TRIGGERED], - ] - target_supported_services = [HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_DISARM]] + current_supported_states = [HK_ALARM_DISARMED, HK_ALARM_TRIGGERED] + target_supported_services = [HK_ALARM_DISARMED] if supported_states & SUPPORT_ALARM_ARM_HOME: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_HOME]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_HOME] - ) + current_supported_states.append(HK_ALARM_STAY_ARMED) + target_supported_services.append(HK_ALARM_STAY_ARMED) - if supported_states & SUPPORT_ALARM_ARM_AWAY: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_AWAY]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_AWAY] - ) + if supported_states & (SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_VACATION): + current_supported_states.append(HK_ALARM_AWAY_ARMED) + target_supported_services.append(HK_ALARM_AWAY_ARMED) if supported_states & SUPPORT_ALARM_ARM_NIGHT: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_NIGHT]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_NIGHT] - ) - - new_current_states = { - key: val - for key, val in default_current_states.items() - if val in current_supported_states - } - new_target_services = { - key: val - for key, val in default_target_services.items() - if val in target_supported_services - } + current_supported_states.append(HK_ALARM_NIGHT_ARMED) + target_supported_services.append(HK_ALARM_NIGHT_ARMED) - serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) self.char_current_state = serv_alarm.configure_char( CHAR_CURRENT_SECURITY_STATE, - value=HASS_TO_HOMEKIT[STATE_ALARM_DISARMED], - valid_values=new_current_states, + value=HASS_TO_HOMEKIT_CURRENT[STATE_ALARM_DISARMED], + valid_values={ + key: val + for key, val in default_current_states.items() + if val in current_supported_states + }, ) self.char_target_state = serv_alarm.configure_char( CHAR_TARGET_SECURITY_STATE, value=HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_DISARM], - valid_values=new_target_services, + valid_values={ + key: val + for key, val in default_target_services.items() + if val in target_supported_services + }, setter_callback=self.set_security_state, ) @@ -144,9 +147,7 @@ def __init__(self, *args): def set_security_state(self, value): """Move security state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) - hass_value = HOMEKIT_TO_HASS[value] - service = STATE_TO_SERVICE[hass_value] - + service = HK_TO_SERVICE[value] params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code @@ -156,20 +157,13 @@ def set_security_state(self, value): def async_update_state(self, new_state): """Update security state after state changed.""" hass_state = new_state.state - if hass_state in HASS_TO_HOMEKIT: - current_security_state = HASS_TO_HOMEKIT[hass_state] - if self.char_current_state.value != current_security_state: - self.char_current_state.set_value(current_security_state) - _LOGGER.debug( - "%s: Updated current state to %s (%d)", - self.entity_id, - hass_state, - current_security_state, - ) - - # SecuritySystemTargetState does not support triggered - if ( - hass_state != STATE_ALARM_TRIGGERED - and self.char_target_state.value != current_security_state - ): - self.char_target_state.set_value(current_security_state) + if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: + self.char_current_state.set_value(current_state) + _LOGGER.debug( + "%s: Updated current state to %s (%d)", + self.entity_id, + hass_state, + current_state, + ) + if (target_state := HASS_TO_HOMEKIT_TARGET.get(hass_state)) is not None: + self.char_target_state.set_value(target_state) diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index b6cc4b0512562..881d91044eef5 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -1,8 +1,13 @@ """Class to hold all sensor accessories.""" +from __future__ import annotations + +from collections.abc import Callable import logging +from typing import NamedTuple from pyhap.const import CATEGORY_SENSOR +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -32,15 +37,6 @@ CHAR_MOTION_DETECTED, CHAR_OCCUPANCY_DETECTED, CHAR_SMOKE_DETECTED, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GARAGE_DOOR, - DEVICE_CLASS_GAS, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_WINDOW, PROP_CELSIUS, SERV_AIR_QUALITY_SENSOR, SERV_CARBON_DIOXIDE_SENSOR, @@ -60,18 +56,41 @@ _LOGGER = logging.getLogger(__name__) -BINARY_SENSOR_SERVICE_MAP = { - DEVICE_CLASS_CO: (SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED, int), - DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, int), - DEVICE_CLASS_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), - DEVICE_CLASS_GARAGE_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), - DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED, int), - DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED, int), - DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, bool), - DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, int), - DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), - DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED, int), - DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), + +class SI(NamedTuple): + """Service info.""" + + service: str + char: str + format: Callable[[bool], int | bool] + + +BINARY_SENSOR_SERVICE_MAP: dict[str, SI] = { + DEVICE_CLASS_CO: SI( + SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED, int + ), + DEVICE_CLASS_CO2: SI(SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, int), + BinarySensorDeviceClass.DOOR: SI( + SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int + ), + BinarySensorDeviceClass.GARAGE_DOOR: SI( + SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int + ), + BinarySensorDeviceClass.GAS: SI( + SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED, int + ), + BinarySensorDeviceClass.MOISTURE: SI(SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED, int), + BinarySensorDeviceClass.MOTION: SI(SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, bool), + BinarySensorDeviceClass.OCCUPANCY: SI( + SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, int + ), + BinarySensorDeviceClass.OPENING: SI( + SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int + ), + BinarySensorDeviceClass.SMOKE: SI(SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED, int), + BinarySensorDeviceClass.WINDOW: SI( + SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int + ), } @@ -98,14 +117,12 @@ def __init__(self, *args): def async_update_state(self, new_state): """Update temperature after state changed.""" unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) - temperature = convert_to_float(new_state.state) - if temperature: + if (temperature := convert_to_float(new_state.state)) is not None: temperature = temperature_to_homekit(temperature, unit) - if self.char_temp.value != temperature: - self.char_temp.set_value(temperature) - _LOGGER.debug( - "%s: Current temperature set to %.1f°C", self.entity_id, temperature - ) + self.char_temp.set_value(temperature) + _LOGGER.debug( + "%s: Current temperature set to %.1f°C", self.entity_id, temperature + ) @TYPES.register("HumiditySensor") @@ -127,8 +144,7 @@ def __init__(self, *args): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - humidity = convert_to_float(new_state.state) - if humidity and self.char_humidity.value != humidity: + if (humidity := convert_to_float(new_state.state)) is not None: self.char_humidity.set_value(humidity) _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) @@ -155,15 +171,13 @@ def __init__(self, *args): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - density = convert_to_float(new_state.state) - if density: + if (density := convert_to_float(new_state.state)) is not None: if self.char_density.value != density: self.char_density.set_value(density) _LOGGER.debug("%s: Set density to %d", self.entity_id, density) air_quality = density_to_air_quality(density) - if self.char_quality.value != air_quality: - self.char_quality.set_value(air_quality) - _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + self.char_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) @TYPES.register("CarbonMonoxideSensor") @@ -192,16 +206,13 @@ def __init__(self, *args): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - value = convert_to_float(new_state.state) - if value: - if self.char_level.value != value: - self.char_level.set_value(value) + if (value := convert_to_float(new_state.state)) is not None: + self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) co_detected = value > THRESHOLD_CO - if self.char_detected.value is not co_detected: - self.char_detected.set_value(co_detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, value) + self.char_detected.set_value(co_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) @TYPES.register("CarbonDioxideSensor") @@ -230,16 +241,13 @@ def __init__(self, *args): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - value = convert_to_float(new_state.state) - if value: - if self.char_level.value != value: - self.char_level.set_value(value) + if (value := convert_to_float(new_state.state)) is not None: + self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) co2_detected = value > THRESHOLD_CO2 - if self.char_detected.value is not co2_detected: - self.char_detected.set_value(co2_detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, value) + self.char_detected.set_value(co2_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) @TYPES.register("LightSensor") @@ -261,8 +269,7 @@ def __init__(self, *args): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - luminance = convert_to_float(new_state.state) - if luminance and self.char_light.value != luminance: + if (luminance := convert_to_float(new_state.state)) is not None: self.char_light.set_value(luminance) _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) @@ -279,14 +286,14 @@ def __init__(self, *args): service_char = ( BINARY_SENSOR_SERVICE_MAP[device_class] if device_class in BINARY_SENSOR_SERVICE_MAP - else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY] + else BINARY_SENSOR_SERVICE_MAP[BinarySensorDeviceClass.OCCUPANCY] ) - self.format = service_char[2] - service = self.add_preload_service(service_char[0]) + self.format = service_char.format + service = self.add_preload_service(service_char.service) initial_value = False if self.format is bool else 0 self.char_detected = service.configure_char( - service_char[1], value=initial_value + service_char.char, value=initial_value ) # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup @@ -297,6 +304,5 @@ def async_update_state(self, new_state): """Update accessory after state change.""" state = new_state.state detected = self.format(state in (STATE_ON, STATE_HOME)) - if self.char_detected.value != detected: - self.char_detected.set_value(detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, detected) + self.char_detected.set_value(detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 8ea19897420b8..cd0d42437264c 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -1,5 +1,8 @@ """Class to hold all switch accessories.""" +from __future__ import annotations + import logging +from typing import NamedTuple from pyhap.const import ( CATEGORY_FAUCET, @@ -9,6 +12,8 @@ CATEGORY_SWITCH, ) +from homeassistant.components import button, input_button +from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.switch import DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -33,9 +38,11 @@ from .const import ( CHAR_ACTIVE, CHAR_IN_USE, + CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, CHAR_VALVE_TYPE, + MAX_NAME_LENGTH, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -47,14 +54,27 @@ _LOGGER = logging.getLogger(__name__) -VALVE_TYPE = { - TYPE_FAUCET: (CATEGORY_FAUCET, 3), - TYPE_SHOWER: (CATEGORY_SHOWER_HEAD, 2), - TYPE_SPRINKLER: (CATEGORY_SPRINKLER, 1), - TYPE_VALVE: (CATEGORY_FAUCET, 0), + +class ValveInfo(NamedTuple): + """Category and type information for valve.""" + + category: int + valve_type: int + + +VALVE_TYPE: dict[str, ValveInfo] = { + TYPE_FAUCET: ValveInfo(CATEGORY_FAUCET, 3), + TYPE_SHOWER: ValveInfo(CATEGORY_SHOWER_HEAD, 2), + TYPE_SPRINKLER: ValveInfo(CATEGORY_SPRINKLER, 1), + TYPE_VALVE: ValveInfo(CATEGORY_FAUCET, 0), } +ACTIVATE_ONLY_SWITCH_DOMAINS = {"button", "input_button", "scene", "script"} + +ACTIVATE_ONLY_RESET_SECONDS = 10 + + @TYPES.register("Outlet") class Outlet(HomeAccessory): """Generate an Outlet accessory.""" @@ -86,9 +106,8 @@ def set_state(self, value): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state == STATE_ON - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Switch") @@ -98,7 +117,7 @@ class Switch(HomeAccessory): def __init__(self, *args): """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) - self._domain = split_entity_id(self.entity_id)[0] + self._domain, self._object_id = split_entity_id(self.entity_id) state = self.hass.states.get(self.entity_id) self.activate_only = self.is_activate(self.hass.states.get(self.entity_id)) @@ -113,15 +132,12 @@ def __init__(self, *args): def is_activate(self, state): """Check if entity is activate only.""" - if self._domain == "scene": - return True - return False + return self._domain in ACTIVATE_ONLY_SWITCH_DOMAINS def reset_switch(self, *args): """Reset switch to emulate activate click.""" _LOGGER.debug("%s: Reset switch to off", self.entity_id) - if self.char_on.value is not False: - self.char_on.set_value(False) + self.char_on.set_value(False) def set_state(self, value): """Move switch state to value if call came from HomeKit.""" @@ -129,12 +145,22 @@ def set_state(self, value): if self.activate_only and not value: _LOGGER.debug("%s: Ignoring turn_off call", self.entity_id) return + params = {ATTR_ENTITY_ID: self.entity_id} - service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + if self._domain == "script": + service = self._object_id + params = {} + elif self._domain == button.DOMAIN: + service = button.SERVICE_PRESS + elif self._domain == input_button.DOMAIN: + service = input_button.SERVICE_PRESS + else: + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.async_call_service(self._domain, service, params) if self.activate_only: - async_call_later(self.hass, 1, self.reset_switch) + async_call_later(self.hass, ACTIVATE_ONLY_RESET_SECONDS, self.reset_switch) @callback def async_update_state(self, new_state): @@ -147,9 +173,8 @@ def async_update_state(self, new_state): return current_state = new_state.state == STATE_ON - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Vacuum") @@ -177,9 +202,8 @@ def set_state(self, value): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state in (STATE_CLEANING, STATE_ON) - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Valve") @@ -191,7 +215,7 @@ def __init__(self, *args): super().__init__(*args) state = self.hass.states.get(self.entity_id) valve_type = self.config[CONF_TYPE] - self.category = VALVE_TYPE[valve_type][0] + self.category = VALVE_TYPE[valve_type].category serv_valve = self.add_preload_service(SERV_VALVE) self.char_active = serv_valve.configure_char( @@ -199,7 +223,7 @@ def __init__(self, *args): ) self.char_in_use = serv_valve.configure_char(CHAR_IN_USE, value=False) self.char_valve_type = serv_valve.configure_char( - CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type][1] + CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type ) # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup @@ -217,9 +241,51 @@ def set_state(self, value): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = 1 if new_state.state == STATE_ON else 0 - if self.char_active.value != current_state: - _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) - self.char_active.set_value(current_state) - if self.char_in_use.value != current_state: - _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) - self.char_in_use.set_value(current_state) + _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) + self.char_active.set_value(current_state) + _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) + self.char_in_use.set_value(current_state) + + +@TYPES.register("SelectSwitch") +class SelectSwitch(HomeAccessory): + """Generate a Switch accessory that contains multiple switches.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self.domain = split_entity_id(self.entity_id)[0] + state = self.hass.states.get(self.entity_id) + self.select_chars = {} + options = state.attributes[ATTR_OPTIONS] + for option in options: + serv_option = self.add_preload_service( + SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE] + ) + serv_option.configure_char( + CHAR_NAME, + value=f"{option}"[:MAX_NAME_LENGTH], + ) + serv_option.configure_char(CHAR_IN_USE, value=False) + self.select_chars[option] = serv_option.configure_char( + CHAR_ON, + value=False, + setter_callback=lambda value, option=option: self.select_option(option), + ) + self.set_primary_service(self.select_chars[options[0]]) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.async_update_state(state) + + def select_option(self, option): + """Set option from HomeKit.""" + _LOGGER.debug("%s: Set option to %s", self.entity_id, option) + params = {ATTR_ENTITY_ID: self.entity_id, "option": option} + self.async_call_service(self.domain, SERVICE_SELECT_OPTION, params) + + @callback + def async_update_state(self, new_state): + """Update switch state after state changed.""" + current_option = new_state.state + for option, char in self.select_chars.items(): + char.set_value(option == current_option) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index fb3063704c2b1..804f0b8616766 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -245,7 +245,7 @@ def _temperature_to_states(self, temp): def _set_chars(self, char_values): _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] - params = {} + params = {ATTR_ENTITY_ID: self.entity_id} service = None state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -285,12 +285,20 @@ def _set_chars(self, char_values): target_hc = hc_fallback break - service = SERVICE_SET_HVAC_MODE_THERMOSTAT - hass_value = self.hc_homekit_to_hass[target_hc] - params = {ATTR_HVAC_MODE: hass_value} + params[ATTR_HVAC_MODE] = self.hc_homekit_to_hass[target_hc] events.append( f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" ) + # Many integrations do not actually implement `hvac_mode` for the + # `SERVICE_SET_TEMPERATURE_THERMOSTAT` service so we made a call to + # `SERVICE_SET_HVAC_MODE_THERMOSTAT` before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT` + # to ensure the device is in the right mode before setting the temp. + self.async_call_service( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE_THERMOSTAT, + params.copy(), + ", ".join(events), + ) if CHAR_TARGET_TEMPERATURE in char_values: hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] @@ -357,7 +365,6 @@ def _set_chars(self, char_values): ) if service: - params[ATTR_ENTITY_ID] = self.entity_id self.async_call_service( DOMAIN_CLIMATE, service, @@ -446,8 +453,7 @@ def _async_update_state(self, new_state): if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] if homekit_hvac_mode in self.hc_homekit_to_hass: - if self.char_target_heat_cool.value != homekit_hvac_mode: - self.char_target_heat_cool.set_value(homekit_hvac_mode) + self.char_target_heat_cool.set_value(homekit_hvac_mode) else: _LOGGER.error( "Cannot map hvac target mode: %s to homekit as only %s modes are supported", @@ -456,33 +462,25 @@ def _async_update_state(self, new_state): ) # Set current operation mode for supported thermostats - hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) - if hvac_action: + if hvac_action := new_state.attributes.get(ATTR_HVAC_ACTION): homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action] - if self.char_current_heat_cool.value != homekit_hvac_action: - self.char_current_heat_cool.set_value(homekit_hvac_action) + self.char_current_heat_cool.set_value(homekit_hvac_action) # Update current temperature current_temp = _get_current_temperature(new_state, self._unit) - if current_temp is not None and self.char_current_temp.value != current_temp: + if current_temp is not None: self.char_current_temp.set_value(current_temp) # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) - if ( - isinstance(current_humdity, (int, float)) - and self.char_current_humidity.value != current_humdity - ): + if isinstance(current_humdity, (int, float)): self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: target_humdity = new_state.attributes.get(ATTR_HUMIDITY) - if ( - isinstance(target_humdity, (int, float)) - and self.char_target_humidity.value != target_humdity - ): + if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) # Update cooling threshold temperature if characteristic exists @@ -490,16 +488,14 @@ def _async_update_state(self, new_state): cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(cooling_thresh, (int, float)): cooling_thresh = self._temperature_to_homekit(cooling_thresh) - if self.char_heating_thresh_temp.value != cooling_thresh: - self.char_cooling_thresh_temp.set_value(cooling_thresh) + self.char_cooling_thresh_temp.set_value(cooling_thresh) # Update heating threshold temperature if characteristic exists if self.char_heating_thresh_temp: heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) if isinstance(heating_thresh, (int, float)): heating_thresh = self._temperature_to_homekit(heating_thresh) - if self.char_heating_thresh_temp.value != heating_thresh: - self.char_heating_thresh_temp.set_value(heating_thresh) + self.char_heating_thresh_temp.set_value(heating_thresh) # Update target temperature target_temp = _get_target_temperature(new_state, self._unit) @@ -515,14 +511,13 @@ def _async_update_state(self, new_state): temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(temp_high, (int, float)): target_temp = self._temperature_to_homekit(temp_high) - if target_temp and self.char_target_temp.value != target_temp: + if target_temp: self.char_target_temp.set_value(target_temp) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: unit = UNIT_HASS_TO_HOMEKIT[self._unit] - if self.char_display_units.value != unit: - self.char_display_units.set_value(unit) + self.char_display_units.set_value(unit) @TYPES.register("WaterHeater") @@ -579,8 +574,7 @@ def get_temperature_range(self): def set_heat_cool(self, value): """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) - hass_value = HC_HOMEKIT_TO_HASS[value] - if hass_value != HVAC_MODE_HEAT and self.char_target_heat_cool.value != 1: + if HC_HOMEKIT_TO_HASS[value] != HVAC_MODE_HEAT: self.char_target_heat_cool.set_value(1) # Heat def set_target_temperature(self, value): @@ -600,41 +594,31 @@ def async_update_state(self, new_state): """Update water_heater state after state change.""" # Update current and target temperature target_temperature = _get_target_temperature(new_state, self._unit) - if ( - target_temperature is not None - and target_temperature != self.char_target_temp.value - ): + if target_temperature is not None: self.char_target_temp.set_value(target_temperature) current_temperature = _get_current_temperature(new_state, self._unit) - if ( - current_temperature is not None - and current_temperature != self.char_current_temp.value - ): + if current_temperature is not None: self.char_current_temp.set_value(current_temperature) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: unit = UNIT_HASS_TO_HOMEKIT[self._unit] - if self.char_display_units.value != unit: - self.char_display_units.set_value(unit) + self.char_display_units.set_value(unit) # Update target operation mode - operation_mode = new_state.state - if operation_mode and self.char_target_heat_cool.value != 1: + if new_state.state: self.char_target_heat_cool.set_value(1) # Heat def _get_temperature_range_from_state(state, unit, default_min, default_max): """Calculate the temperature range from a state.""" - min_temp = state.attributes.get(ATTR_MIN_TEMP) - if min_temp: + if min_temp := state.attributes.get(ATTR_MIN_TEMP): min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 else: min_temp = default_min - max_temp = state.attributes.get(ATTR_MAX_TEMP) - if max_temp: + if max_temp := state.attributes.get(ATTR_MAX_TEMP): max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2 else: max_temp = default_max diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py new file mode 100644 index 0000000000000..6d5f67f99157f --- /dev/null +++ b/homeassistant/components/homekit/type_triggers.py @@ -0,0 +1,89 @@ +"""Class to hold all sensor accessories.""" +import logging + +from pyhap.const import CATEGORY_SENSOR + +from homeassistant.helpers.trigger import async_initialize_triggers + +from .accessories import TYPES, HomeAccessory +from .const import ( + CHAR_NAME, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + CHAR_SERVICE_LABEL_INDEX, + CHAR_SERVICE_LABEL_NAMESPACE, + SERV_SERVICE_LABEL, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register("DeviceTriggerAccessory") +class DeviceTriggerAccessory(HomeAccessory): + """Generate a Programmable switch.""" + + def __init__(self, *args, device_triggers=None, device_id=None): + """Initialize a Programmable switch accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR, device_id=device_id) + self._device_triggers = device_triggers + self._remove_triggers = None + self.triggers = [] + for idx, trigger in enumerate(device_triggers): + type_ = trigger.get("type") + subtype = trigger.get("subtype") + trigger_name = ( + f"{type_.title()} {subtype.title()}" if subtype else type_.title() + ) + serv_stateless_switch = self.add_preload_service( + SERV_STATELESS_PROGRAMMABLE_SWITCH, + [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + ) + self.triggers.append( + serv_stateless_switch.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + valid_values={"Trigger": 0}, + ) + ) + serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name) + serv_stateless_switch.configure_char( + CHAR_SERVICE_LABEL_INDEX, value=idx + 1 + ) + serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL) + serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1) + serv_stateless_switch.add_linked_service(serv_service_label) + + async def async_trigger(self, run_variables, context=None, skip_condition=False): + """Trigger button press. + + This method is a coroutine. + """ + reason = "" + if "trigger" in run_variables and "description" in run_variables["trigger"]: + reason = f' by {run_variables["trigger"]["description"]}' + _LOGGER.debug("Button triggered%s - %s", reason, run_variables) + idx = int(run_variables["trigger"]["idx"]) + self.triggers[idx].set_value(0) + + # Attach the trigger using the helper in async run + # and detach it in async stop + async def run(self): + """Handle accessory driver started event.""" + self._remove_triggers = await async_initialize_triggers( + self.hass, + self._device_triggers, + self.async_trigger, + "homekit", + self.display_name, + _LOGGER.log, + ) + + async def stop(self): + """Handle accessory driver stop event.""" + if self._remove_triggers: + self._remove_triggers() + + @property + def available(self): + """Return available.""" + return True diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 673abc5da67ee..894bfcf89855d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -14,8 +14,8 @@ from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import ( - DEVICE_CLASS_TV, DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerDeviceClass, ) from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN, SUPPORT_ACTIVITY from homeassistant.const import ( @@ -27,7 +27,7 @@ CONF_TYPE, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR import homeassistant.util.temperature as temp_util @@ -294,9 +294,7 @@ def get_media_player_features(state): def validate_media_player_features(state, feature_list): """Validate features for media players.""" - supported_modes = get_media_player_features(state) - - if not supported_modes: + if not (supported_modes := get_media_player_features(state)): _LOGGER.error("%s does not support any media_player features", state.entity_id) return False @@ -317,7 +315,7 @@ def validate_media_player_features(state, feature_list): return True -def show_setup_message(hass, entry_id, bridge_name, pincode, uri): +def async_show_setup_message(hass, entry_id, bridge_name, pincode, uri): """Display persistent notification with setup information.""" pin = pincode.decode() _LOGGER.info("Pincode: %s", pin) @@ -336,12 +334,14 @@ def show_setup_message(hass, entry_id, bridge_name, pincode, uri): f"### {pin}\n" f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})" ) - hass.components.persistent_notification.create(message, "HomeKit Pairing", entry_id) + hass.components.persistent_notification.async_create( + message, "HomeKit Pairing", entry_id + ) -def dismiss_setup_message(hass, entry_id): +def async_dismiss_setup_message(hass, entry_id): """Dismiss persistent notification and remove QR code.""" - hass.components.persistent_notification.dismiss(entry_id) + hass.components.persistent_notification.async_dismiss(entry_id) def convert_to_float(state): @@ -433,34 +433,32 @@ def _get_test_socket(): return test_socket -def port_is_available(port: int) -> bool: +@callback +def async_port_is_available(port: int) -> bool: """Check to see if a port is available.""" - test_socket = _get_test_socket() try: - test_socket.bind(("", port)) + _get_test_socket().bind(("", port)) except OSError: return False - return True -async def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: +@callback +def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: """Find the next available port not assigned to a config entry.""" exclude_ports = { entry.data[CONF_PORT] for entry in hass.config_entries.async_entries(DOMAIN) if CONF_PORT in entry.data } - - return await hass.async_add_executor_job( - _find_next_available_port, start_port, exclude_ports - ) + return _async_find_next_available_port(start_port, exclude_ports) -def _find_next_available_port(start_port: int, exclude_ports: set) -> int: +@callback +def _async_find_next_available_port(start_port: int, exclude_ports: set) -> int: """Find the next available port starting with the given port.""" test_socket = _get_test_socket() - for port in range(start_port, MAX_PORT): + for port in range(start_port, MAX_PORT + 1): if port in exclude_ports: continue try: @@ -499,13 +497,12 @@ def accessory_friendly_name(hass_name, accessory): def state_needs_accessory_mode(state): """Return if the entity represented by the state must be paired in accessory mode.""" - if state.domain == CAMERA_DOMAIN: + if state.domain in (CAMERA_DOMAIN, LOCK_DOMAIN): return True return ( - state.domain == LOCK_DOMAIN - or state.domain == MEDIA_PLAYER_DOMAIN - and state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TV + state.domain == MEDIA_PLAYER_DOMAIN + and state.attributes.get(ATTR_DEVICE_CLASS) == MediaPlayerDeviceClass.TV or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_ACTIVITY ) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 3db6c1800c9e4..7c07e921818c6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -14,13 +14,21 @@ from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components import zeroconf -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .config_flow import normalize_hkid -from .connection import HKDevice -from .const import CONTROLLER, DOMAIN, ENTITY_MAP, KNOWN_DEVICES, TRIGGERS +from .connection import HKDevice, valid_serial_number +from .const import ( + CONTROLLER, + DOMAIN, + ENTITY_MAP, + IDENTIFIER_ACCESSORY_ID, + IDENTIFIER_SERIAL_NUMBER, + KNOWN_DEVICES, + TRIGGERS, +) from .storage import EntityMapStorage @@ -32,6 +40,8 @@ def escape_characteristic_name(char_name): class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" + _attr_should_poll = False + def __init__(self, accessory, devinfo): """Initialise a generic HomeKit device.""" self._accessory = accessory @@ -99,14 +109,6 @@ async def async_put_characteristics(self, characteristics: dict[str, Any]): payload = self.service.build_update(characteristics) return await self._accessory.put_characteristics(payload) - @property - def should_poll(self) -> bool: - """Return False. - - Data update is triggered from HKDevice. - """ - return False - def setup(self): """Configure an entity baed on its HomeKit characteristics metadata.""" self.pollable_characteristics = [] @@ -137,8 +139,12 @@ def _setup_characteristic(self, char: Characteristic): @property def unique_id(self) -> str: """Return the ID of this device.""" - serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) - return f"homekit-{serial}-{self._iid}" + info = self.accessory_info + serial = info.value(CharacteristicsTypes.SERIAL_NUMBER) + if valid_serial_number(serial): + return f"homekit-{serial}-{self._iid}" + # Some accessories do not have a serial number + return f"homekit-{self._accessory.unique_id}-{self._aid}-{self._iid}" @property def name(self) -> str: @@ -148,27 +154,41 @@ def name(self) -> str: @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available + return self._accessory.available and self.service.available @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" info = self.accessory_info accessory_serial = info.value(CharacteristicsTypes.SERIAL_NUMBER) + if valid_serial_number(accessory_serial): + # Some accessories do not have a serial number + identifier = (DOMAIN, IDENTIFIER_SERIAL_NUMBER, accessory_serial) + else: + identifier = ( + DOMAIN, + IDENTIFIER_ACCESSORY_ID, + f"{self._accessory.unique_id}_{self._aid}", + ) - device_info = { - "identifiers": {(DOMAIN, "serial-number", accessory_serial)}, - "name": info.value(CharacteristicsTypes.NAME), - "manufacturer": info.value(CharacteristicsTypes.MANUFACTURER, ""), - "model": info.value(CharacteristicsTypes.MODEL, ""), - "sw_version": info.value(CharacteristicsTypes.FIRMWARE_REVISION, ""), - } + device_info = DeviceInfo( + identifiers={identifier}, + manufacturer=info.value(CharacteristicsTypes.MANUFACTURER, ""), + model=info.value(CharacteristicsTypes.MODEL, ""), + name=info.value(CharacteristicsTypes.NAME), + sw_version=info.value(CharacteristicsTypes.FIRMWARE_REVISION, ""), + hw_version=info.value(CharacteristicsTypes.HARDWARE_REVISION, ""), + ) # Some devices only have a single accessory - we don't add a # via_device otherwise it would be self referential. bridge_serial = self._accessory.connection_info["serial-number"] if accessory_serial != bridge_serial: - device_info["via_device"] = (DOMAIN, "serial-number", bridge_serial) + device_info[ATTR_VIA_DEVICE] = ( + DOMAIN, + IDENTIFIER_SERIAL_NUMBER, + bridge_serial, + ) return device_info @@ -195,11 +215,16 @@ class CharacteristicEntity(HomeKitEntity): the service entity. """ + def __init__(self, accessory, devinfo, char): + """Initialise a generic single characteristic HomeKit entity.""" + self._char = char + super().__init__(accessory, devinfo) + @property def unique_id(self) -> str: """Return the ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) - return f"homekit-{serial}-aid:{self._aid}-sid:{self._iid}-cid:{self._iid}" + return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" async def async_setup_entry(hass, entry): @@ -225,17 +250,19 @@ async def async_setup(hass, config): map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - zeroconf_instance = await zeroconf.async_get_instance(hass) - hass.data[CONTROLLER] = aiohomekit.Controller(zeroconf_instance=zeroconf_instance) + async_zeroconf_instance = await zeroconf.async_get_async_instance(hass) + hass.data[CONTROLLER] = aiohomekit.Controller( + async_zeroconf_instance=async_zeroconf_instance + ) hass.data[KNOWN_DEVICES] = {} hass.data[TRIGGERS] = {} async def _async_stop_homekit_controller(event): await asyncio.gather( - *[ + *( connection.async_unload() for connection in hass.data[KNOWN_DEVICES].values() - ] + ) ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_homekit_controller) diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py deleted file mode 100644 index 2a162eb2b2a37..0000000000000 --- a/homeassistant/components/homekit_controller/air_quality.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Support for HomeKit Controller air quality sensors.""" -from aiohomekit.model.characteristics import CharacteristicsTypes -from aiohomekit.model.services import ServicesTypes - -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.core import callback - -from . import KNOWN_DEVICES, HomeKitEntity - -AIR_QUALITY_TEXT = { - 0: "unknown", - 1: "excellent", - 2: "good", - 3: "fair", - 4: "inferior", - 5: "poor", -} - - -class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): - """Representation of a HomeKit Controller Air Quality sensor.""" - - def get_characteristic_types(self): - """Define the homekit characteristics the entity cares about.""" - return [ - CharacteristicsTypes.AIR_QUALITY, - CharacteristicsTypes.DENSITY_PM25, - CharacteristicsTypes.DENSITY_PM10, - CharacteristicsTypes.DENSITY_OZONE, - CharacteristicsTypes.DENSITY_NO2, - CharacteristicsTypes.DENSITY_SO2, - CharacteristicsTypes.DENSITY_VOC, - ] - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return self.service.value(CharacteristicsTypes.DENSITY_PM25) - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self.service.value(CharacteristicsTypes.DENSITY_PM10) - - @property - def ozone(self): - """Return the O3 (ozone) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_OZONE) - - @property - def sulphur_dioxide(self): - """Return the SO2 (sulphur dioxide) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_SO2) - - @property - def nitrogen_dioxide(self): - """Return the NO2 (nitrogen dioxide) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_NO2) - - @property - def air_quality_text(self): - """Return the Air Quality Index (AQI).""" - air_quality = self.service.value(CharacteristicsTypes.AIR_QUALITY) - return AIR_QUALITY_TEXT.get(air_quality, "unknown") - - @property - def volatile_organic_compounds(self): - """Return the volatile organic compounds (VOC) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_VOC) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - data = {"air_quality_text": self.air_quality_text} - - voc = self.volatile_organic_compounds - if voc: - data["volatile_organic_compounds"] = voc - - return data - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up Homekit air quality sensor.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] - - @callback - def async_add_service(service): - if service.short_type != ServicesTypes.AIR_QUALITY_SENSOR: - return False - info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([HomeAirQualitySensor(conn, info)], True) - return True - - conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 537e9c2a6988a..191aee6fca0c4 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -3,12 +3,7 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_GAS, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_SMOKE, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.core import callback @@ -19,15 +14,12 @@ class HomeKitMotionSensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit motion sensor.""" + _attr_device_class = BinarySensorDeviceClass.MOTION + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.MOTION_DETECTED] - @property - def device_class(self): - """Define this binary_sensor as a motion sensor.""" - return DEVICE_CLASS_MOTION - @property def is_on(self): """Has motion been detected.""" @@ -37,15 +29,12 @@ def is_on(self): class HomeKitContactSensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit contact sensor.""" + _attr_device_class = BinarySensorDeviceClass.OPENING + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.CONTACT_STATE] - @property - def device_class(self): - """Define this binary_sensor as a opening sensor.""" - return DEVICE_CLASS_OPENING - @property def is_on(self): """Return true if the binary sensor is on/open.""" @@ -55,10 +44,7 @@ def is_on(self): class HomeKitSmokeSensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit smoke sensor.""" - @property - def device_class(self) -> str: - """Return the class of this sensor.""" - return DEVICE_CLASS_SMOKE + _attr_device_class = BinarySensorDeviceClass.SMOKE def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -73,10 +59,7 @@ def is_on(self): class HomeKitCarbonMonoxideSensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit BO sensor.""" - @property - def device_class(self) -> str: - """Return the class of this sensor.""" - return DEVICE_CLASS_GAS + _attr_device_class = BinarySensorDeviceClass.GAS def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -91,10 +74,7 @@ def is_on(self): class HomeKitOccupancySensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit occupancy sensor.""" - @property - def device_class(self) -> str: - """Return the class of this sensor.""" - return DEVICE_CLASS_OCCUPANCY + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -109,15 +89,12 @@ def is_on(self): class HomeKitLeakSensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit leak sensor.""" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.LEAK_DETECTED] - @property - def device_class(self): - """Define this binary_sensor as a leak sensor.""" - return DEVICE_CLASS_MOISTURE - @property def is_on(self): """Return true if a leak is detected from the binary sensor.""" @@ -141,8 +118,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(service): - entity_class = ENTITY_TYPES.get(service.short_type) - if not entity_class: + if not (entity_class := ENTITY_TYPES.get(service.short_type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} async_add_entities([entity_class(conn, info)], True) diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py new file mode 100644 index 0000000000000..07759475249dd --- /dev/null +++ b/homeassistant/components/homekit_controller/button.py @@ -0,0 +1,96 @@ +""" +Support for Homekit buttons. + +These are mostly used where a HomeKit accessory exposes additional non-standard +characteristics that don't map to a Home Assistant feature. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityCategory + +from . import KNOWN_DEVICES, CharacteristicEntity + + +@dataclass +class HomeKitButtonEntityDescription(ButtonEntityDescription): + """Describes Homekit button.""" + + write_value: int | str | None = None + + +BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { + CharacteristicsTypes.Vendor.HAA_SETUP: HomeKitButtonEntityDescription( + key=CharacteristicsTypes.Vendor.HAA_SETUP, + name="Setup", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + write_value="#HAA@trcmd", + ), + CharacteristicsTypes.Vendor.HAA_UPDATE: HomeKitButtonEntityDescription( + key=CharacteristicsTypes.Vendor.HAA_UPDATE, + name="Update", + device_class=ButtonDeviceClass.UPDATE, + entity_category=EntityCategory.CONFIG, + write_value="#HAA@trcmd", + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit buttons.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_characteristic(char: Characteristic): + if not (description := BUTTON_ENTITIES.get(char.type)): + return False + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + async_add_entities([HomeKitButton(conn, info, char, description)], True) + return True + + conn.add_char_factory(async_add_characteristic) + + +class HomeKitButton(CharacteristicEntity, ButtonEntity): + """Representation of a Button control on a homekit accessory.""" + + entity_description: HomeKitButtonEntityDescription + + def __init__( + self, + conn, + info, + char, + description: HomeKitButtonEntityDescription, + ): + """Initialise a HomeKit button control.""" + self.entity_description = description + super().__init__(conn, info, char) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + def name(self) -> str: + """Return the name of the device if any.""" + if name := super().name: + return f"{name} - {self.entity_description.name}" + return f"{self.entity_description.name}" + + async def async_press(self) -> None: + """Press the button.""" + key = self.entity_description.key + val = self.entity_description.write_value + return await self.async_put_characteristics({key: val}) diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index fc6a5bb4522f6..820574e3ffd2f 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -1,4 +1,6 @@ """Support for Homekit cameras.""" +from __future__ import annotations + from aiohomekit.model.services import ServicesTypes from homeassistant.components.camera import Camera @@ -16,17 +18,14 @@ def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [] - @property - def state(self): - """Return the current state of the camera.""" - return "idle" - - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a jpeg with the current camera snapshot.""" return await self._accessory.pairing.image( self._aid, - 640, - 480, + width or 640, + height or 480, ) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 2c251d41fb398..cd9b1fe004c88 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -13,12 +13,9 @@ from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char -from homeassistant.components.climate import ( - DEFAULT_MAX_HUMIDITY, - DEFAULT_MIN_HUMIDITY, - ClimateEntity, -) +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_COOL, @@ -92,8 +89,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(service): - entity_class = ENTITY_TYPES.get(service.short_type) - if not entity_class: + if not (entity_class := ENTITY_TYPES.get(service.short_type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} async_add_entities([entity_class(conn, info)], True) @@ -342,16 +338,27 @@ def get_characteristic_types(self): async def async_set_temperature(self, **kwargs): """Set new target temperature.""" + chars = {} + + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + mode = MODE_HOMEKIT_TO_HASS.get(value) + + if kwargs.get(ATTR_HVAC_MODE, mode) != mode: + mode = kwargs[ATTR_HVAC_MODE] + chars[CharacteristicsTypes.HEATING_COOLING_TARGET] = MODE_HASS_TO_HOMEKIT[ + mode + ] + temp = kwargs.get(ATTR_TEMPERATURE) heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) - value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) - if (MODE_HOMEKIT_TO_HASS.get(value) in {HVAC_MODE_HEAT_COOL}) and ( + + if (mode == HVAC_MODE_HEAT_COOL) and ( SUPPORT_TARGET_TEMPERATURE_RANGE & self.supported_features ): if temp is None: temp = (cool_temp + heat_temp) / 2 - await self.async_put_characteristics( + chars.update( { CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD: heat_temp, CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD: cool_temp, @@ -359,9 +366,9 @@ async def async_set_temperature(self, **kwargs): } ) else: - await self.async_put_characteristics( - {CharacteristicsTypes.TEMPERATURE_TARGET: temp} - ) + chars[CharacteristicsTypes.TEMPERATURE_TARGET] = temp + + await self.async_put_characteristics(chars) async def async_set_humidity(self, humidity): """Set new target humidity.""" @@ -476,14 +483,22 @@ def target_humidity(self): @property def min_humidity(self): """Return the minimum humidity.""" - char = self.service[CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET] - return char.minValue or DEFAULT_MIN_HUMIDITY + min_humidity = self.service[ + CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET + ].minValue + if min_humidity is not None: + return min_humidity + return super().min_humidity @property def max_humidity(self): """Return the maximum humidity.""" - char = self.service[CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET] - return char.maxValue or DEFAULT_MAX_HUMIDITY + max_humidity = self.service[ + CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET + ].maxValue + if max_humidity is not None: + return max_humidity + return super().max_humidity @property def hvac_action(self): diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 654bd56c755ce..26055b964f8de 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -3,11 +3,13 @@ import re import aiohomekit +from aiohomekit.exceptions import AuthenticationError import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, async_get_registry as async_get_device_registry, @@ -37,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) -DISALLOWED_CODES = { +INSECURE_CODES = { "00000000", "11111111", "22222222", @@ -66,7 +68,7 @@ def find_existing_host(hass, serial): return entry -def ensure_pin_format(pin): +def ensure_pin_format(pin, allow_insecure_setup_codes=None): """ Ensure a pin code is correctly formatted. @@ -74,12 +76,11 @@ def ensure_pin_format(pin): If incorrect code is entered, an exception is raised. """ - match = PIN_FORMAT.search(pin.strip()) - if not match: + if not (match := PIN_FORMAT.search(pin.strip())): raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") pin_without_dashes = "".join(match.groups()) - if pin_without_dashes in DISALLOWED_CODES: - raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") + if not allow_insecure_setup_codes and pin_without_dashes in INSECURE_CODES: + raise InsecureSetupCode(f"Invalid PIN code f{pin}") return "-".join(match.groups()) @@ -99,8 +100,10 @@ def __init__(self): async def _async_setup_controller(self): """Create the controller.""" - zeroconf_instance = await zeroconf.async_get_instance(self.hass) - self.controller = aiohomekit.Controller(zeroconf_instance=zeroconf_instance) + async_zeroconf_instance = await zeroconf.async_get_async_instance(self.hass) + self.controller = aiohomekit.Controller( + async_zeroconf_instance=async_zeroconf_instance + ) async def async_step_user(self, user_input=None): """Handle a flow start.""" @@ -155,16 +158,16 @@ async def async_step_unignore(self, user_input): continue record = device.info return await self.async_step_zeroconf( - { - "host": record["address"], - "port": record["port"], - "hostname": record["name"], - "type": "_hap._tcp.local.", - "name": record["name"], - "properties": { + zeroconf.ZeroconfServiceInfo( + host=record["address"], + port=record["port"], + hostname=record["name"], + type="_hap._tcp.local.", + name=record["name"], + properties={ "md": record["md"], "pv": record["pv"], - "id": unique_id, + zeroconf.ATTR_PROPERTIES_ID: unique_id, "c#": record["c#"], "s#": record["s#"], "ff": record["ff"], @@ -172,7 +175,7 @@ async def async_step_unignore(self, user_input): "sf": record["sf"], "sh": "", }, - } + ) ) return self.async_abort(reason="no_devices") @@ -194,7 +197,9 @@ async def _hkid_is_homekit(self, hkid): return False - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a discovered HomeKit accessory. This flow is triggered by the discovery component. @@ -203,10 +208,10 @@ async def async_step_zeroconf(self, discovery_info): # homekit_python has code to do this, but not in a form we can # easily use, so do the bare minimum ourselves here instead. properties = { - key.lower(): value for (key, value) in discovery_info["properties"].items() + key.lower(): value for (key, value) in discovery_info.properties.items() } - if "id" not in properties: + if zeroconf.ATTR_PROPERTIES_ID not in properties: # This can happen if the TXT record is received after the PTR record # we will wait for the next update in this case _LOGGER.debug( @@ -217,9 +222,11 @@ async def async_step_zeroconf(self, discovery_info): # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid = properties["id"] + hkid = properties[zeroconf.ATTR_PROPERTIES_ID] + normalized_hkid = normalize_hkid(hkid) + model = properties["md"] - name = discovery_info["name"].replace("._hap._tcp.local.", "") + name = discovery_info.name.replace("._hap._tcp.local.", "") status_flags = int(properties["sf"]) paired = not status_flags & 0x01 @@ -234,10 +241,28 @@ async def async_step_zeroconf(self, discovery_info): ) config_num = None + # Set unique-id and error out if it's already configured + existing_entry = await self.async_set_unique_id( + normalized_hkid, raise_on_progress=False + ) + updated_ip_port = { + "AccessoryIP": discovery_info.host, + "AccessoryPort": discovery_info.port, + } + # If the device is already paired and known to us we should monitor c# # (config_num) for changes. If it changes, we check for new entities if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data={**existing_entry.data, **updated_ip_port} + ) conn = self.hass.data[KNOWN_DEVICES][hkid] + # When we rediscover the device, let aiohomekit know + # that the device is available and we should not wait + # to retry connecting any longer. reconnect_soon + # will do nothing if the device is already connected + await conn.pairing.connection.reconnect_soon() if conn.config_num != config_num: _LOGGER.debug( "HomeKit info %s: c# incremented, refreshing entities", hkid @@ -252,13 +277,45 @@ async def async_step_zeroconf(self, discovery_info): # invalid. Remove it automatically. existing = find_existing_host(self.hass, hkid) if not paired and existing: - await self.hass.config_entries.async_remove(existing.entry_id) + if self.controller is None: + await self._async_setup_controller() - # Set unique-id and error out if it's already configured - await self.async_set_unique_id(normalize_hkid(hkid)) - self._abort_if_unique_id_configured() + pairing = self.controller.load_pairing( + existing.data["AccessoryPairingID"], dict(existing.data) + ) + try: + await pairing.list_accessories_and_characteristics() + except AuthenticationError: + _LOGGER.debug( + "%s (%s - %s) is unpaired. Removing invalid pairing for this device", + name, + model, + hkid, + ) + await self.hass.config_entries.async_remove(existing.entry_id) + else: + _LOGGER.debug( + "%s (%s - %s) claims to be unpaired but isn't. " + "It's implementation of HomeKit is defective " + "or a zeroconf relay is broadcasting stale data", + name, + model, + hkid, + ) + return self.async_abort(reason="already_paired") - self.context["hkid"] = hkid + # Set unique-id and error out if it's already configured + self._abort_if_unique_id_configured(updates=updated_ip_port) + + for progress in self._async_in_progress(include_uninitialized=True): + if progress["context"].get("unique_id") == normalized_hkid: + if paired: + # If the device gets paired, we want to dismiss + # an existing discovery since we can no longer + # pair with it + self.hass.config_entries.flow.async_abort(progress["flow_id"]) + else: + raise AbortFlow("already_in_progress") if paired: # Device is paired but not to us - ignore it @@ -310,7 +367,12 @@ async def async_step_pair(self, pair_info=None): if pair_info and self.finish_pairing: code = pair_info["pairing_code"] try: - code = ensure_pin_format(code) + code = ensure_pin_format( + code, + allow_insecure_setup_codes=pair_info.get( + "allow_insecure_setup_codes" + ), + ) pairing = await self.finish_pairing(code) return await self._entry_from_accessory(pairing) except aiohomekit.exceptions.MalformedPinError: @@ -336,6 +398,8 @@ async def async_step_pair(self, pair_info=None): except aiohomekit.AccessoryNotFoundError: # Can no longer find the device on the network return self.async_abort(reason="accessory_not_found_error") + except InsecureSetupCode: + errors["pairing_code"] = "insecure_setup_code" except Exception: # pylint: disable=broad-except _LOGGER.exception("Pairing attempt failed with an unhandled exception") self.finish_pairing = None @@ -399,13 +463,15 @@ def _async_step_pair_show_form(self, errors=None): placeholders = {"name": self.name} self.context["title_placeholders"] = {"name": self.name} + schema = {vol.Required("pairing_code"): vol.All(str, vol.Strip)} + if errors and errors.get("pairing_code") == "insecure_setup_code": + schema[vol.Optional("allow_insecure_setup_codes")] = bool + return self.async_show_form( step_id="pair", errors=errors or {}, description_placeholders=placeholders, - data_schema=vol.Schema( - {vol.Required("pairing_code"): vol.All(str, vol.Strip)} - ), + data_schema=vol.Schema(schema), ) async def _entry_from_accessory(self, pairing): @@ -420,11 +486,14 @@ async def _entry_from_accessory(self, pairing): # available. Otherwise request a fresh copy from the API. # This removes the 'accessories' key from pairing_data at # the same time. - accessories = pairing_data.pop("accessories", None) - if not accessories: + if not (accessories := pairing_data.pop("accessories", None)): accessories = await pairing.list_accessories_and_characteristics() bridge_info = get_bridge_information(accessories) name = get_accessory_name(bridge_info) return self.async_create_entry(title=name, data=pairing_data) + + +class InsecureSetupCode(Exception): + """An exception for insecure trivial setup codes.""" diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index cc9ba7b620e1f..112c05b17f5a3 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -12,7 +12,10 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.const import ATTR_VIA_DEVICE from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -21,15 +24,28 @@ DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH, + IDENTIFIER_ACCESSORY_ID, + IDENTIFIER_SERIAL_NUMBER, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60) RETRY_INTERVAL = 60 # seconds +MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3 _LOGGER = logging.getLogger(__name__) +def valid_serial_number(serial): + """Return if the serial number appears to be valid.""" + if not serial: + return False + try: + return float("".join(serial.rsplit(".", 1))) > 1 + except ValueError: + return True + + def get_accessory_information(accessory): """Obtain the accessory information service of a HomeKit device.""" result = {} @@ -106,7 +122,7 @@ def __init__(self, hass, config_entry, pairing_data): # Useful when routing events to triggers self.devices = {} - self.available = True + self.available = False self.signal_state_updated = "_".join((DOMAIN, self.unique_id, "state_updated")) @@ -123,6 +139,7 @@ def __init__(self, hass, config_entry, pairing_data): # Never allow concurrent polling of the same accessory or bridge self._polling_lock = asyncio.Lock() self._polling_lock_warned = False + self._poll_failures = 0 self.watchable_characteristics = [] @@ -150,9 +167,14 @@ def remove_watchable_characteristics(self, accessory_id): ] @callback - def async_set_unavailable(self): - """Mark state of all entities on this connection as unavailable.""" - self.available = False + def async_set_available_state(self, available): + """Mark state of all entities on this connection when it becomes available or unavailable.""" + _LOGGER.debug( + "Called async_set_available_state with %s for %s", available, self.unique_id + ) + if self.available == available: + return + self.available = available self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) async def async_setup(self): @@ -179,7 +201,8 @@ async def async_setup(self): return True - async def async_create_devices(self): + @callback + def async_create_devices(self): """ Build device registry entries for all accessories paired with the bridge. @@ -187,7 +210,7 @@ async def async_create_devices(self): might not have any entities attached to it. Secondly there are stateless entities like doorbells and remote controls. """ - device_registry = await self.hass.helpers.device_registry.async_get_registry() + device_registry = dr.async_get(self.hass) devices = {} @@ -196,31 +219,41 @@ async def async_create_devices(self): service_type=ServicesTypes.ACCESSORY_INFORMATION, ) - device_info = { - "identifiers": { + serial_number = info.value(CharacteristicsTypes.SERIAL_NUMBER) + + if valid_serial_number(serial_number): + identifiers = {(DOMAIN, IDENTIFIER_SERIAL_NUMBER, serial_number)} + else: + # Some accessories do not have a serial number + identifiers = { ( DOMAIN, - "serial-number", - info.value(CharacteristicsTypes.SERIAL_NUMBER), + IDENTIFIER_ACCESSORY_ID, + f"{self.unique_id}_{accessory.aid}", ) - }, - "name": info.value(CharacteristicsTypes.NAME), - "manufacturer": info.value(CharacteristicsTypes.MANUFACTURER, ""), - "model": info.value(CharacteristicsTypes.MODEL, ""), - "sw_version": info.value(CharacteristicsTypes.FIRMWARE_REVISION, ""), - } + } if accessory.aid == 1: # Accessory 1 is the root device (sometimes the only device, sometimes a bridge) # Link the root device to the pairing id for the connection. - device_info["identifiers"].add((DOMAIN, "accessory-id", self.unique_id)) - else: + identifiers.add((DOMAIN, IDENTIFIER_ACCESSORY_ID, self.unique_id)) + + device_info = DeviceInfo( + identifiers=identifiers, + name=info.value(CharacteristicsTypes.NAME), + manufacturer=info.value(CharacteristicsTypes.MANUFACTURER, ""), + model=info.value(CharacteristicsTypes.MODEL, ""), + sw_version=info.value(CharacteristicsTypes.FIRMWARE_REVISION, ""), + hw_version=info.value(CharacteristicsTypes.HARDWARE_REVISION, ""), + ) + + if accessory.aid != 1: # Every pairing has an accessory 1 # It *doesn't* have a via_device, as it is the device we are connecting to # Every other accessory should use it as its via device. - device_info["via_device"] = ( + device_info[ATTR_VIA_DEVICE] = ( DOMAIN, - "serial-number", + IDENTIFIER_SERIAL_NUMBER, self.connection_info["serial-number"], ) @@ -248,7 +281,7 @@ async def async_process_entity_map(self): await self.async_load_platforms() - await self.async_create_devices() + self.async_create_devices() # Load any triggers for this config entry await async_setup_triggers_for_entry(self.hass, self.config_entry) @@ -257,17 +290,17 @@ async def async_process_entity_map(self): if self.watchable_characteristics: await self.pairing.subscribe(self.watchable_characteristics) + if not self.pairing.connection.is_connected: + return await self.async_update() - return True - async def async_unload(self): """Stop interacting with device and prepare for removal from hass.""" if self._polling_interval_remover: self._polling_interval_remover() - await self.pairing.unsubscribe(self.watchable_characteristics) + await self.pairing.close() return await self.hass.config_entries.async_unload_platforms( self.config_entry, self.platforms @@ -365,40 +398,51 @@ async def async_load_platform(self, platform): async def async_load_platforms(self): """Load any platforms needed by this HomeKit device.""" + tasks = [] for accessory in self.accessories: for service in accessory["services"]: stype = ServicesTypes.get_short(service["type"].upper()) if stype in HOMEKIT_ACCESSORY_DISPATCH: platform = HOMEKIT_ACCESSORY_DISPATCH[stype] - await self.async_load_platform(platform) + if platform not in self.platforms: + tasks.append(self.async_load_platform(platform)) for char in service["characteristics"]: if char["type"].upper() in CHARACTERISTIC_PLATFORMS: platform = CHARACTERISTIC_PLATFORMS[char["type"].upper()] - await self.async_load_platform(platform) + if platform not in self.platforms: + tasks.append(self.async_load_platform(platform)) + + if tasks: + await asyncio.gather(*tasks) async def async_update(self, now=None): """Poll state of all entities attached to this bridge/accessory.""" if not self.pollable_characteristics: - _LOGGER.debug("HomeKit connection not polling any characteristics") + self.async_set_available_state(self.pairing.connection.is_connected) + _LOGGER.debug( + "HomeKit connection not polling any characteristics: %s", self.unique_id + ) return if self._polling_lock.locked(): if not self._polling_lock_warned: _LOGGER.warning( - "HomeKit controller update skipped as previous poll still in flight" + "HomeKit controller update skipped as previous poll still in flight: %s", + self.unique_id, ) self._polling_lock_warned = True return if self._polling_lock_warned: _LOGGER.info( - "HomeKit controller no longer detecting back pressure - not skipping poll" + "HomeKit controller no longer detecting back pressure - not skipping poll: %s", + self.unique_id, ) self._polling_lock_warned = False async with self._polling_lock: - _LOGGER.debug("Starting HomeKit controller update") + _LOGGER.debug("Starting HomeKit controller update: %s", self.unique_id) try: new_values_dict = await self.get_characteristics( @@ -407,20 +451,24 @@ async def async_update(self, now=None): except AccessoryNotFoundError: # Not only did the connection fail, but also the accessory is not # visible on the network. - self.async_set_unavailable() + self.async_set_available_state(False) return except (AccessoryDisconnectedError, EncryptionError): - # Temporary connection failure. Device is still available but our - # connection was dropped. + # Temporary connection failure. Device may still available but our + # connection was dropped or we are reconnecting + self._poll_failures += 1 + if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE: + self.async_set_available_state(False) return + self._poll_failures = 0 self.process_new_events(new_values_dict) - _LOGGER.debug("Finished HomeKit controller update") + _LOGGER.debug("Finished HomeKit controller update: %s", self.unique_id) def process_new_events(self, new_values_dict): """Process events from accessory into HA state.""" - self.available = True + self.async_set_available_state(True) # Process any stateless events (via device_triggers) async_fire_triggers(self, new_values_dict) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index a3f7a9b792120..271834f2e924c 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -11,6 +11,10 @@ HOMEKIT_DIR = ".homekit" PAIRING_FILE = "pairing.json" +IDENTIFIER_SERIAL_NUMBER = "serial-number" +IDENTIFIER_ACCESSORY_ID = "accessory-id" + + # Mapping from Homekit type to component. HOMEKIT_ACCESSORY_DISPATCH = { "lightbulb": "light", @@ -36,7 +40,6 @@ "leak": "binary_sensor", "fan": "fan", "fanv2": "fan", - "air-quality": "air_quality", "occupancy": "binary_sensor", "television": "media_player", "valve": "switch", @@ -44,5 +47,39 @@ } CHARACTERISTIC_PLATFORMS = { + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_WATT: "sensor", + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_AMPS: "sensor", + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_AMPS_20: "sensor", + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_KW_HOUR: "sensor", + CharacteristicsTypes.Vendor.AQARA_GATEWAY_VOLUME: "number", + CharacteristicsTypes.Vendor.AQARA_E1_GATEWAY_VOLUME: "number", + CharacteristicsTypes.Vendor.AQARA_PAIRING_MODE: "switch", + CharacteristicsTypes.Vendor.AQARA_E1_PAIRING_MODE: "switch", + CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: "sensor", + CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: "sensor", + CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: "number", + CharacteristicsTypes.Vendor.HAA_SETUP: "button", + CharacteristicsTypes.Vendor.HAA_UPDATE: "button", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", + CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", + CharacteristicsTypes.TEMPERATURE_CURRENT: "sensor", + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: "sensor", + CharacteristicsTypes.AIR_QUALITY: "sensor", + CharacteristicsTypes.DENSITY_PM25: "sensor", + CharacteristicsTypes.DENSITY_PM10: "sensor", + CharacteristicsTypes.DENSITY_OZONE: "sensor", + CharacteristicsTypes.DENSITY_NO2: "sensor", + CharacteristicsTypes.DENSITY_SO2: "sensor", + CharacteristicsTypes.DENSITY_VOC: "sensor", } + +# For legacy reasons, "built-in" characteristic types are in their short form +# And vendor types don't have a short form +# This means long and short forms get mixed up in this dict, and comparisons +# don't work! +# We call get_uuid on *every* type to normalise them to the long form +# Eventually aiohomekit will use the long form exclusively amd this can be removed. +for k, v in list(CHARACTERISTIC_PLATFORMS.items()): + value = CHARACTERISTIC_PLATFORMS.pop(k) + CHARACTERISTIC_PLATFORMS[CharacteristicsTypes.get_uuid(k)] = value diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index dd25e32b3c460..ee736bd2c4889 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -41,8 +41,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(service): - entity_class = ENTITY_TYPES.get(service.short_type) - if not entity_class: + if not (entity_class := ENTITY_TYPES.get(service.short_type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} async_add_entities([entity_class(conn, info)], True) @@ -73,7 +72,7 @@ def supported_features(self): return SUPPORT_OPEN | SUPPORT_CLOSE @property - def state(self): + def _state(self): """Return the current state of the garage door.""" value = self.service.value(CharacteristicsTypes.DOOR_STATE_CURRENT) return CURRENT_GARAGE_STATE_MAP[value] @@ -81,17 +80,17 @@ def state(self): @property def is_closed(self): """Return true if cover is closed, else False.""" - return self.state == STATE_CLOSED + return self._state == STATE_CLOSED @property def is_closing(self): """Return if the cover is closing or not.""" - return self.state == STATE_CLOSING + return self._state == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self.state == STATE_OPENING + return self._state == STATE_OPENING async def async_open_cover(self, **kwargs): """Send open command.""" diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index c9cd771edf602..5bb7d6346265f 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -1,14 +1,19 @@ """Provides device automations for homekit devices.""" from __future__ import annotations +from typing import Any + from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import InputEventValues from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char import voluptuous as vol -from homeassistant.components.automation import AutomationActionType -from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.typing import ConfigType @@ -16,6 +21,7 @@ from .const import DOMAIN, KNOWN_DEVICES, TRIGGERS TRIGGER_TYPES = { + "doorbell", "button1", "button2", "button3", @@ -32,7 +38,7 @@ CONF_IID = "iid" CONF_SUBTYPE = "subtype" -TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Required(CONF_SUBTYPE): vol.In(TRIGGER_SUBTYPES), @@ -72,16 +78,16 @@ async def async_attach_trigger( self, config: TRIGGER_SCHEMA, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_id = automation_info.get("trigger_id") if automation_info else None + trigger_data = automation_info["trigger_data"] def event_handler(char): if config[CONF_SUBTYPE] != HK_TO_HA_INPUT_EVENT_VALUES[char["value"]]: return self._hass.async_create_task( - action({"trigger": {**config, "id": trigger_id}}) + action({"trigger": {**trigger_data, **config}}) ) trigger = self._triggers[config[CONF_TYPE], config[CONF_SUBTYPE]] @@ -229,7 +235,9 @@ def async_fire_triggers(conn, events): source.fire(iid, ev) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for homekit devices.""" if device_id not in hass.data.get(TRIGGERS, {}): @@ -253,7 +261,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device_id = config[CONF_DEVICE_ID] diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 591050f5fd9c1..0c0c9ccda9c67 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -84,7 +84,8 @@ def supported_features(self): def speed_count(self): """Speed count for the fan.""" return round( - 100 / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) + min(self.service[CharacteristicsTypes.ROTATION_SPEED].maxValue or 100, 100) + / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) ) async def async_set_direction(self, direction): @@ -153,8 +154,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(service): - entity_class = ENTITY_TYPES.get(service.short_type) - if not entity_class: + if not (entity_class := ENTITY_TYPES.get(service.short_type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} async_add_entities([entity_class(conn, info)], True) diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index 227174d00e98d..2defa27317536 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -4,10 +4,8 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity from homeassistant.components.humidifier.const import ( - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, MODE_AUTO, MODE_NORMAL, SUPPORT_MODES, @@ -35,21 +33,17 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): """Representation of a HomeKit Controller Humidifier.""" + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, ] - @property - def device_class(self) -> str: - """Return the device class of the device.""" - return DEVICE_CLASS_HUMIDIFIER - @property def supported_features(self): """Return the list of supported features.""" @@ -140,22 +134,18 @@ def max_humidity(self) -> int: class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): """Representation of a HomeKit Controller Humidifier.""" + _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER + def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD, ] - @property - def device_class(self) -> str: - """Return the device class of the device.""" - return DEVICE_CLASS_DEHUMIDIFIER - @property def supported_features(self): """Return the list of supported features.""" diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 09c02ce0ff97b..3b6fb41f3a8b4 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -2,18 +2,28 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from homeassistant.components.lock import LockEntity -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import STATE_JAMMED, LockEntity +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + STATE_LOCKED, + STATE_UNKNOWN, + STATE_UNLOCKED, +) from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity -STATE_JAMMED = "jammed" - -CURRENT_STATE_MAP = {0: STATE_UNLOCKED, 1: STATE_LOCKED, 2: STATE_JAMMED, 3: None} +CURRENT_STATE_MAP = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: STATE_UNKNOWN, +} TARGET_STATE_MAP = {STATE_UNLOCKED: 0, STATE_LOCKED: 1} +REVERSED_TARGET_STATE_MAP = {v: k for k, v in TARGET_STATE_MAP.items()} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit lock.""" @@ -46,8 +56,44 @@ def get_characteristic_types(self): def is_locked(self): """Return true if device is locked.""" value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + if CURRENT_STATE_MAP[value] == STATE_UNKNOWN: + return None return CURRENT_STATE_MAP[value] == STATE_LOCKED + @property + def is_locking(self): + """Return true if device is locking.""" + current_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE + ) + target_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE + ) + return ( + CURRENT_STATE_MAP[current_value] == STATE_UNLOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_LOCKED + ) + + @property + def is_unlocking(self): + """Return true if device is unlocking.""" + current_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE + ) + target_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE + ) + return ( + CURRENT_STATE_MAP[current_value] == STATE_LOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_UNLOCKED + ) + + @property + def is_jammed(self): + """Return true if device is jammed.""" + value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + return CURRENT_STATE_MAP[value] == STATE_JAMMED + async def async_lock(self, **kwargs): """Lock the device.""" await self._set_lock_state(STATE_LOCKED) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index cb248fcaa5f07..4b4b971b3b694 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,9 +3,9 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.2.61"], + "requirements": ["aiohomekit==0.6.10"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], - "codeowners": ["@Jc2k"], + "codeowners": ["@Jc2k", "@bdraco"], "iot_class": "local_push" } diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 71bde5f0af9b1..e22e9db7dc77d 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -10,7 +10,10 @@ from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char -from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerEntity +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, +) from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, SUPPORT_PLAY, @@ -57,6 +60,8 @@ def async_add_service(service): class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): """Representation of a HomeKit Controller Television.""" + _attr_device_class = MediaPlayerDeviceClass.TV + def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ @@ -70,11 +75,6 @@ def get_characteristic_types(self): CharacteristicsTypes.IDENTIFIER, ] - @property - def device_class(self): - """Define the device class for a HomeKit enabled TV.""" - return DEVICE_CLASS_TV - @property def supported_features(self): """Flag media player features that are supported.""" diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py new file mode 100644 index 0000000000000..26ec7e6b9e02d --- /dev/null +++ b/homeassistant/components/homekit_controller/number.py @@ -0,0 +1,112 @@ +""" +Support for Homekit number ranges. + +These are mostly used where a HomeKit accessory exposes additional non-standard +characteristics that don't map to a Home Assistant feature. +""" +from __future__ import annotations + +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityCategory + +from . import KNOWN_DEVICES, CharacteristicEntity + +NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: NumberEntityDescription( + key=CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL, + name="Spray Quantity", + icon="mdi:water", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: NumberEntityDescription( + key=CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION, + name="Elevation", + icon="mdi:elevation-rise", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.Vendor.AQARA_GATEWAY_VOLUME: NumberEntityDescription( + key=CharacteristicsTypes.Vendor.AQARA_GATEWAY_VOLUME, + name="Volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.Vendor.AQARA_E1_GATEWAY_VOLUME: NumberEntityDescription( + key=CharacteristicsTypes.Vendor.AQARA_E1_GATEWAY_VOLUME, + name="Volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit numbers.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_characteristic(char: Characteristic): + if not (description := NUMBER_ENTITIES.get(char.type)): + return False + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + async_add_entities([HomeKitNumber(conn, info, char, description)], True) + return True + + conn.add_char_factory(async_add_characteristic) + + +class HomeKitNumber(CharacteristicEntity, NumberEntity): + """Representation of a Number control on a homekit accessory.""" + + def __init__( + self, + conn, + info, + char, + description: NumberEntityDescription, + ): + """Initialise a HomeKit number control.""" + self.entity_description = description + super().__init__(conn, info, char) + + @property + def name(self) -> str: + """Return the name of the device if any.""" + if prefix := super().name: + return f"{prefix} {self.entity_description.name}" + return self.entity_description.name + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._char.minValue + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._char.maxValue + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + return self._char.minStep + + @property + def value(self) -> float: + """Return the current characteristic value.""" + return self._char.value + + async def async_set_value(self, value: float): + """Set the characteristic to this value.""" + await self.async_put_characteristics( + { + self._char.type: value, + } + ) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 2ae264fabb92c..0ee74525dce2c 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,74 +1,203 @@ """Support for Homekit sensors.""" -from aiohomekit.model.characteristics import CharacteristicsTypes +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, + POWER_WATT, + PRESSURE_HPA, TEMP_CELSIUS, ) from homeassistant.core import callback from . import KNOWN_DEVICES, CharacteristicEntity, HomeKitEntity -HUMIDITY_ICON = "mdi:water-percent" -TEMP_C_ICON = "mdi:thermometer" -BRIGHTNESS_ICON = "mdi:brightness-6" CO2_ICON = "mdi:molecule-co2" -SIMPLE_SENSOR = { - CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: { - "name": "Real Time Energy", - "device_class": DEVICE_CLASS_POWER, - "unit": "watts", - "icon": "mdi:chart-line", - }, - CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: { - "name": "Real Time Energy", - "device_class": DEVICE_CLASS_POWER, - "unit": "watts", - "icon": "mdi:chart-line", - }, +@dataclass +class HomeKitSensorEntityDescription(SensorEntityDescription): + """Describes Homekit sensor.""" + + probe: Callable[[Characteristic], bool] | None = None + + +SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_WATT: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_WATT, + name="Real Time Energy", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_AMPS: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_AMPS, + name="Real Time Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_AMPS_20: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_AMPS_20, + name="Real Time Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_KW_HOUR: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.CONNECTSENSE_ENERGY_KW_HOUR, + name="Energy kWh", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.EVE_ENERGY_WATT, + name="Real Time Energy", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY, + name="Real Time Energy", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2, + name="Real Time Energy", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE, + name="Air Pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PRESSURE_HPA, + ), + CharacteristicsTypes.TEMPERATURE_CURRENT: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.TEMPERATURE_CURRENT, + name="Current Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + # This sensor is only for temperature characteristics that are not part + # of a temperature sensor service. + probe=( + lambda char: char.service.type + != ServicesTypes.get_uuid(ServicesTypes.TEMPERATURE_SENSOR) + ), + ), + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, + name="Current Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + # This sensor is only for humidity characteristics that are not part + # of a humidity sensor service. + probe=( + lambda char: char.service.type + != ServicesTypes.get_uuid(ServicesTypes.HUMIDITY_SENSOR) + ), + ), + CharacteristicsTypes.AIR_QUALITY: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.AIR_QUALITY, + name="Air Quality", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + ), + CharacteristicsTypes.DENSITY_PM25: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.DENSITY_PM25, + name="PM2.5 Density", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CharacteristicsTypes.DENSITY_PM10: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.DENSITY_PM10, + name="PM10 Density", + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CharacteristicsTypes.DENSITY_OZONE: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.DENSITY_OZONE, + name="Ozone Density", + device_class=SensorDeviceClass.OZONE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CharacteristicsTypes.DENSITY_NO2: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.DENSITY_NO2, + name="Nitrogen Dioxide Density", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CharacteristicsTypes.DENSITY_SO2: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.DENSITY_SO2, + name="Sulphur Dioxide Density", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CharacteristicsTypes.DENSITY_VOC: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.DENSITY_VOC, + name="Volatile Organic Compound Density", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), } +# For legacy reasons, "built-in" characteristic types are in their short form +# And vendor types don't have a short form +# This means long and short forms get mixed up in this dict, and comparisons +# don't work! +# We call get_uuid on *every* type to normalise them to the long form +# Eventually aiohomekit will use the long form exclusively amd this can be removed. +for k, v in list(SIMPLE_SENSOR.items()): + SIMPLE_SENSOR[CharacteristicsTypes.get_uuid(k)] = SIMPLE_SENSOR.pop(k) + class HomeKitHumiditySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit humidity sensor.""" + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_native_unit_of_measurement = PERCENTAGE + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT] - @property - def device_class(self) -> str: - """Return the device class of the sensor.""" - return DEVICE_CLASS_HUMIDITY - @property def name(self): """Return the name of the device.""" return f"{super().name} Humidity" @property - def icon(self): - """Return the sensor icon.""" - return HUMIDITY_ICON - - @property - def unit_of_measurement(self): - """Return units for the sensor.""" - return PERCENTAGE - - @property - def state(self): + def native_value(self): """Return the current humidity.""" return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) @@ -76,32 +205,20 @@ def state(self): class HomeKitTemperatureSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit temperature sensor.""" + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.TEMPERATURE_CURRENT] - @property - def device_class(self) -> str: - """Return the device class of the sensor.""" - return DEVICE_CLASS_TEMPERATURE - @property def name(self): """Return the name of the device.""" return f"{super().name} Temperature" @property - def icon(self): - """Return the sensor icon.""" - return TEMP_C_ICON - - @property - def unit_of_measurement(self): - """Return units for the sensor.""" - return TEMP_CELSIUS - - @property - def state(self): + def native_value(self): """Return the current temperature in Celsius.""" return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) @@ -109,32 +226,20 @@ def state(self): class HomeKitLightSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit light level sensor.""" + _attr_device_class = SensorDeviceClass.ILLUMINANCE + _attr_native_unit_of_measurement = LIGHT_LUX + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT] - @property - def device_class(self) -> str: - """Return the device class of the sensor.""" - return DEVICE_CLASS_ILLUMINANCE - @property def name(self): """Return the name of the device.""" return f"{super().name} Light Level" @property - def icon(self): - """Return the sensor icon.""" - return BRIGHTNESS_ICON - - @property - def unit_of_measurement(self): - """Return units for the sensor.""" - return LIGHT_LUX - - @property - def state(self): + def native_value(self): """Return the current light level in lux.""" return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT) @@ -142,6 +247,9 @@ def state(self): class HomeKitCarbonDioxideSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit Carbon Dioxide sensor.""" + _attr_icon = CO2_ICON + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.CARBON_DIOXIDE_LEVEL] @@ -152,17 +260,7 @@ def name(self): return f"{super().name} CO2" @property - def icon(self): - """Return the sensor icon.""" - return CO2_ICON - - @property - def unit_of_measurement(self): - """Return units for the sensor.""" - return CONCENTRATION_PARTS_PER_MILLION - - @property - def state(self): + def native_value(self): """Return the current CO2 level in ppm.""" return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL) @@ -170,6 +268,9 @@ def state(self): class HomeKitBatterySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit battery sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [ @@ -178,11 +279,6 @@ def get_characteristic_types(self): CharacteristicsTypes.CHARGING_STATE, ] - @property - def device_class(self) -> str: - """Return the device class of the sensor.""" - return DEVICE_CLASS_BATTERY - @property def name(self): """Return the name of the device.""" @@ -210,11 +306,6 @@ def icon(self): return icon - @property - def unit_of_measurement(self): - """Return units for the sensor.""" - return PERCENTAGE - @property def is_low_battery(self): """Return true if battery level is low.""" @@ -229,7 +320,7 @@ def is_charging(self): return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1 @property - def state(self): + def native_value(self): """Return the current battery level percentage.""" return self.service.value(CharacteristicsTypes.BATTERY_LEVEL) @@ -245,51 +336,30 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): be multiple entities per HomeKit service (this was not previously the case). """ + entity_description: HomeKitSensorEntityDescription + def __init__( self, conn, info, char, - device_class=None, - unit=None, - icon=None, - name=None, + description: HomeKitSensorEntityDescription, ): """Initialise a secondary HomeKit characteristic sensor.""" - self._device_class = device_class - self._unit = unit - self._icon = icon - self._name = name - self._char = char - - super().__init__(conn, info) + self.entity_description = description + super().__init__(conn, info, char) def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [self._char.type] - @property - def device_class(self): - """Return units for the sensor.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return units for the sensor.""" - return self._unit - - @property - def icon(self): - """Return the sensor icon.""" - return self._icon - @property def name(self) -> str: """Return the name of the device if any.""" - return f"{super().name} - {self._name}" + return f"{super().name} - {self.entity_description.name}" @property - def state(self): + def native_value(self): """Return the current sensor value.""" return self._char.value @@ -310,8 +380,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(service): - entity_class = ENTITY_TYPES.get(service.short_type) - if not entity_class: + if not (entity_class := ENTITY_TYPES.get(service.short_type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} async_add_entities([entity_class(conn, info)], True) @@ -320,12 +389,13 @@ def async_add_service(service): conn.add_listener(async_add_service) @callback - def async_add_characteristic(char): - kwargs = SIMPLE_SENSOR.get(char.type) - if not kwargs: + def async_add_characteristic(char: Characteristic): + if not (description := SIMPLE_SENSOR.get(char.type)): + return False + if description.probe and not description.probe(char): return False info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - async_add_entities([SimpleSensor(conn, info, char, **kwargs)], True) + async_add_entities([SimpleSensor(conn, info, char, description)], True) return True diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py index ffc5bdc23818b..4d512fbbc5dde 100644 --- a/homeassistant/components/homekit_controller/storage.py +++ b/homeassistant/components/homekit_controller/storage.py @@ -34,8 +34,7 @@ def __init__(self, hass): async def async_initialize(self): """Get the pairing cache data.""" - raw_storage = await self.store.async_load() - if not raw_storage: + if not (raw_storage := await self.store.async_load()): # There is no cached data about HomeKit devices yet return diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index d170693bb6fce..7ad868db3fc1c 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -14,7 +14,8 @@ "title": "Pair with a device via HomeKit Accessory Protocol", "description": "HomeKit Controller communicates with {name} over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", "data": { - "pairing_code": "Pairing Code" + "pairing_code": "Pairing Code", + "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." } }, "protocol_error": { @@ -31,6 +32,7 @@ } }, "error": { + "insecure_setup_code": "The requested setup code is insecure because of its trivial nature. This accessory fails to meet basic security requirements.", "unable_to_pair": "Unable to pair, please try again.", "unknown_error": "Device reported an unknown error. Pairing failed.", "authentication_error": "Incorrect HomeKit code. Please check it and try again.", diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 36ed379bc805b..1f128bd4d26d2 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -1,15 +1,21 @@ """Support for Homekit switches.""" +from __future__ import annotations + +from dataclasses import dataclass + from aiohomekit.model.characteristics import ( + Characteristic, CharacteristicsTypes, InUseValues, IsConfiguredValues, ) from aiohomekit.model.services import ServicesTypes -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import callback +from homeassistant.helpers.entity import EntityCategory -from . import KNOWN_DEVICES, HomeKitEntity +from . import KNOWN_DEVICES, CharacteristicEntity, HomeKitEntity OUTLET_IN_USE = "outlet_in_use" @@ -18,6 +24,30 @@ ATTR_REMAINING_DURATION = "remaining_duration" +@dataclass +class DeclarativeSwitchEntityDescription(SwitchEntityDescription): + """Describes Homekit button.""" + + true_value: bool = True + false_value: bool = False + + +SWITCH_ENTITIES: dict[str, DeclarativeSwitchEntityDescription] = { + CharacteristicsTypes.Vendor.AQARA_PAIRING_MODE: DeclarativeSwitchEntityDescription( + key=CharacteristicsTypes.Vendor.AQARA_PAIRING_MODE, + name="Pairing Mode", + icon="mdi:lock-open", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.Vendor.AQARA_E1_PAIRING_MODE: DeclarativeSwitchEntityDescription( + key=CharacteristicsTypes.Vendor.AQARA_E1_PAIRING_MODE, + name="Pairing Mode", + icon="mdi:lock-open", + entity_category=EntityCategory.CONFIG, + ), +} + + class HomeKitSwitch(HomeKitEntity, SwitchEntity): """Representation of a Homekit switch.""" @@ -96,6 +126,49 @@ def extra_state_attributes(self): return attrs +class DeclarativeCharacteristicSwitch(CharacteristicEntity, SwitchEntity): + """Representation of a Homekit switch backed by a single characteristic.""" + + def __init__( + self, + conn, + info, + char, + description: DeclarativeSwitchEntityDescription, + ): + """Initialise a HomeKit switch.""" + self.entity_description = description + super().__init__(conn, info, char) + + @property + def name(self) -> str: + """Return the name of the device if any.""" + if prefix := super().name: + return f"{prefix} {self.entity_description.name}" + return self.entity_description.name + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [self._char.type] + + @property + def is_on(self): + """Return true if device is on.""" + return self._char.value == self.entity_description.true_value + + async def async_turn_on(self, **kwargs): + """Turn the specified switch on.""" + await self.async_put_characteristics( + {self._char.type: self.entity_description.true_value} + ) + + async def async_turn_off(self, **kwargs): + """Turn the specified switch off.""" + await self.async_put_characteristics( + {self._char.type: self.entity_description.false_value} + ) + + ENTITY_TYPES = { ServicesTypes.SWITCH: HomeKitSwitch, ServicesTypes.OUTLET: HomeKitSwitch, @@ -110,11 +183,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(service): - entity_class = ENTITY_TYPES.get(service.short_type) - if not entity_class: + if not (entity_class := ENTITY_TYPES.get(service.short_type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} async_add_entities([entity_class(conn, info)], True) return True conn.add_listener(async_add_service) + + @callback + def async_add_characteristic(char: Characteristic): + if not (description := SWITCH_ENTITIES.get(char.type)): + return False + + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + async_add_entities( + [DeclarativeCharacteristicSwitch(conn, info, char, description)], True + ) + return True + + conn.add_char_factory(async_add_characteristic) diff --git a/homeassistant/components/homekit_controller/translations/bg.json b/homeassistant/components/homekit_controller/translations/bg.json index 014398897342a..4bedc7bcb27f6 100644 --- a/homeassistant/components/homekit_controller/translations/bg.json +++ b/homeassistant/components/homekit_controller/translations/bg.json @@ -45,7 +45,8 @@ "button6": "\u0411\u0443\u0442\u043e\u043d 6", "button7": "\u0411\u0443\u0442\u043e\u043d 7", "button8": "\u0411\u0443\u0442\u043e\u043d 8", - "button9": "\u0411\u0443\u0442\u043e\u043d 9" + "button9": "\u0411\u0443\u0442\u043e\u043d 9", + "doorbell": "\u0417\u0432\u044a\u043d\u0435\u0446 \u043d\u0430 \u0432\u0440\u0430\u0442\u0430\u0442\u0430" } }, "title": "HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" diff --git a/homeassistant/components/homekit_controller/translations/ca.json b/homeassistant/components/homekit_controller/translations/ca.json index 0ce5c2b2ba3c1..ff9f180c94362 100644 --- a/homeassistant/components/homekit_controller/translations/ca.json +++ b/homeassistant/components/homekit_controller/translations/ca.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Codi HomeKit incorrecte. Verifica'l i torna-ho a provar.", + "insecure_setup_code": "El codi de configuraci\u00f3 sol\u00b7licitat no \u00e9s segur per naturalesa. Aquest accessori no compleix els requisits b\u00e0sics de seguretat.", "max_peers_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 no t\u00e9 suficient espai lliure.", "pairing_failed": "S'ha produ\u00eft un error mentre s'intentava la vinculaci\u00f3 amb aquest dispositiu. Pot ser que sigui un error temporal o pot ser que el teu dispositiu encara no sigui compatible.", "unable_to_pair": "No s'ha pogut vincular, torna-ho a provar.", "unknown_error": "El dispositiu ha em\u00e8s un error desconegut. Vinculaci\u00f3 fallida." }, - "flow_title": "{name} a trav\u00e9s de HomeKit Accessory Protocol", + "flow_title": "{name}", "step": { "busy_error": { "description": "Atura la vinculaci\u00f3 a tots els controladors o prova de reiniciar el dispositiu, despr\u00e9s, segueix amb la vinculaci\u00f3.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Permet la vinculaci\u00f3 amb codis de configuraci\u00f3 insegurs.", "pairing_code": "Codi de vinculaci\u00f3" }, "description": "El controlador HomeKit es comunica amb {name} a trav\u00e9s de la xarxa d'\u00e0rea local utilitzant una connexi\u00f3 segura encriptada sense un HomeKit o iCloud separats. Introdueix el codi de vinculaci\u00f3 de HomeKit (en format XXX-XX-XXX) per utilitzar aquest accessori. Aquest codi es troba normalment en el propi dispositiu o en la seva caixa.", diff --git a/homeassistant/components/homekit_controller/translations/de.json b/homeassistant/components/homekit_controller/translations/de.json index 120e9a63e66ec..248d3871b3ec3 100644 --- a/homeassistant/components/homekit_controller/translations/de.json +++ b/homeassistant/components/homekit_controller/translations/de.json @@ -12,36 +12,39 @@ }, "error": { "authentication_error": "Ung\u00fcltiger HomeKit Code, \u00fcberpr\u00fcfe bitte den Code und versuche es erneut.", + "insecure_setup_code": "Der angeforderte Setup-Code ist unsicher, da er zu trivial ist. Dieses Zubeh\u00f6r erf\u00fcllt nicht die grundlegenden Sicherheitsanforderungen.", "max_peers_error": "Das Ger\u00e4t weigerte sich, die Kopplung durchzuf\u00fchren, da es keinen freien Kopplungs-Speicher hat.", "pairing_failed": "Beim Versuch dieses Ger\u00e4t zu koppeln ist ein Fehler aufgetreten. Dies kann ein vor\u00fcbergehender Fehler sein oder das Ger\u00e4t wird derzeit m\u00f6glicherweise nicht unterst\u00fctzt.", "unable_to_pair": "Koppeln fehltgeschlagen, bitte versuche es erneut", "unknown_error": "Das Ger\u00e4t meldete einen unbekannten Fehler. Die Kopplung ist fehlgeschlagen." }, - "flow_title": "HomeKit-Zubeh\u00f6r: {name}", + "flow_title": "{name}", "step": { "busy_error": { - "description": "Brechen Sie das Pairing auf allen Controllern ab oder versuchen Sie, das Ger\u00e4t neu zu starten, und fahren Sie dann fort, das Pairing fortzusetzen.", + "description": "Breche das Pairing auf allen Controllern ab oder versuche, das Ger\u00e4t neu zu starten, und fahre dann fort, das Pairing fortzusetzen.", "title": "Das Ger\u00e4t wird bereits mit einem anderen Controller gekoppelt" }, "max_tries_error": { - "description": "Das Ger\u00e4t hat mehr als 100 erfolglose Authentifizierungsversuche erhalten. Versuchen Sie, das Ger\u00e4t neu zu starten, und fahren Sie dann fort, die Kopplung fortzusetzen.", + "description": "Das Ger\u00e4t hat mehr als 100 erfolglose Authentifizierungsversuche erhalten. Versuche, das Ger\u00e4t neu zu starten, und fahre dann fort, die Kopplung fortzusetzen.", "title": "Maximale Authentifizierungsversuche \u00fcberschritten" }, "pair": { "data": { + "allow_insecure_setup_codes": "Pairing mit unsicheren Setup-Codes zulassen.", "pairing_code": "Kopplungscode" }, - "description": "Gib deinen HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", + "description": "HomeKit Controller kommuniziert mit {name} \u00fcber das lokale Netzwerk mit einer sicheren verschl\u00fcsselten Verbindung ohne separaten HomeKit Controller oder iCloud. Gib deinen HomeKit-Kopplungscode (im Format XXX-XX-XXX) ein, um dieses Zubeh\u00f6r zu verwenden. Dieser Code befindet sich in der Regel auf dem Ger\u00e4t selbst oder in der Verpackung.", "title": "Mit HomeKit Zubeh\u00f6r koppeln" }, "protocol_error": { + "description": "Das Ger\u00e4t befindet sich m\u00f6glicherweise nicht im Pairing-Modus und erfordert einen physischen oder virtuellen Tastendruck. Stelle sicher, dass sich das Ger\u00e4t im Pairing-Modus befindet, oder versuche, das Ger\u00e4t neu zu starten und fahre dann das Pairing fort.", "title": "Fehler bei der Kommunikation mit dem Zubeh\u00f6r" }, "user": { "data": { "device": "Ger\u00e4t" }, - "description": "W\u00e4hle das Ger\u00e4t aus, mit dem du die Kopplung herstellen m\u00f6chtest", + "description": "HomeKit Controller kommuniziert \u00fcber das lokale Netzwerk mit einer sicheren verschl\u00fcsselten Verbindung ohne separaten HomeKit Controller oder iCloud. W\u00e4hle das Ger\u00e4t aus, mit dem du die Kopplung herstellen m\u00f6chtest", "title": "Ger\u00e4teauswahl" } } diff --git a/homeassistant/components/homekit_controller/translations/en.json b/homeassistant/components/homekit_controller/translations/en.json index 67409ed02cf1a..5de3a6c53344b 100644 --- a/homeassistant/components/homekit_controller/translations/en.json +++ b/homeassistant/components/homekit_controller/translations/en.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "insecure_setup_code": "The requested setup code is insecure because of its trivial nature. This accessory fails to meet basic security requirements.", "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", "pairing_failed": "An unhandled error occurred while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.", "unable_to_pair": "Unable to pair, please try again.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Allow pairing with insecure setup codes.", "pairing_code": "Pairing Code" }, "description": "HomeKit Controller communicates with {name} over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", diff --git a/homeassistant/components/homekit_controller/translations/es.json b/homeassistant/components/homekit_controller/translations/es.json index 9f5f40bd19974..52b295ecf2143 100644 --- a/homeassistant/components/homekit_controller/translations/es.json +++ b/homeassistant/components/homekit_controller/translations/es.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.", + "insecure_setup_code": "El c\u00f3digo de configuraci\u00f3n solicitado es inseguro debido a su naturaleza trivial. Este accesorio no cumple con los requisitos b\u00e1sicos de seguridad.", "max_peers_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que no tiene almacenamiento de emparejamientos libres.", "pairing_failed": "Se ha producido un error no controlado al intentar emparejarse con este dispositivo. Esto puede ser un fallo temporal o que tu dispositivo no est\u00e9 admitido en este momento.", "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Permitir el emparejamiento con c\u00f3digos de configuraci\u00f3n inseguros.", "pairing_code": "C\u00f3digo de vinculaci\u00f3n" }, "description": "El controlador de HomeKit se comunica con {name} a trav\u00e9s de la red de \u00e1rea local usando una conexi\u00f3n encriptada segura sin un controlador HomeKit separado o iCloud. Introduce el c\u00f3digo de vinculaci\u00f3n de tu HomeKit (con el formato XXX-XX-XXX) para usar este accesorio. Este c\u00f3digo suele encontrarse en el propio dispositivo o en el embalaje.", diff --git a/homeassistant/components/homekit_controller/translations/et.json b/homeassistant/components/homekit_controller/translations/et.json index 6df49751478c2..c658213eea00a 100644 --- a/homeassistant/components/homekit_controller/translations/et.json +++ b/homeassistant/components/homekit_controller/translations/et.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Vale HomeKiti kood. Kontrolli seda ja proovi uuesti.", + "insecure_setup_code": "Taotletud salas\u00f5na on ebaturvaline, sest see on liiga lihtne ning ei vasta p\u00f5hilistele turvan\u00f5uetele.", "max_peers_error": "Seade keeldus sidumist lisamast kuna puudub piisav salvestusruum.", "pairing_failed": "Selle seadmega sidumise katsel ilmnes tundmatu t\u00f5rge. See v\u00f5ib olla ajutine t\u00f5rge v\u00f5i seadet ei toetata praegu.", "unable_to_pair": "Ei saa siduda, proovi uuesti.", "unknown_error": "Seade teatas tundmatust t\u00f5rkest. Sidumine nurjus." }, - "flow_title": "{name} HomeKitAccessory Protocol abil", + "flow_title": "{name}", "step": { "busy_error": { "description": "Katkesta sidumine k\u00f5igis kontrollerites v\u00f5i proovi seade taask\u00e4ivitada ja j\u00e4tka sidumist.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Luba sidumist ebaturvalise salas\u00f5naga.", "pairing_code": "Sidumiskood" }, "description": "HomeKiti kontroller suhtleb seadmega {name} kohtv\u00f5rgu kaudu, kasutades turvalist kr\u00fcpteeritud \u00fchendust ilma eraldi HomeKiti kontrolleri v\u00f5i iCloudita. Selle lisaseadme kasutamiseks sisesta oma HomeKiti sidumiskood (vormingus XXX-XX-XXX). See kood on tavaliselt seadmel v\u00f5i pakendil.", diff --git a/homeassistant/components/homekit_controller/translations/fr.json b/homeassistant/components/homekit_controller/translations/fr.json index 5ae7a0faafd25..18f3e82aa76a4 100644 --- a/homeassistant/components/homekit_controller/translations/fr.json +++ b/homeassistant/components/homekit_controller/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.", "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "already_paired": "Cet accessoire est d\u00e9j\u00e0 associ\u00e9 \u00e0 un autre appareil. R\u00e9initialisez l\u2019accessoire et r\u00e9essayez.", "ignored_model": "La prise en charge de HomeKit pour ce mod\u00e8le est bloqu\u00e9e car une int\u00e9gration native plus compl\u00e8te est disponible.", "invalid_config_entry": "Cet appareil est pr\u00eat \u00e0 \u00eatre coupl\u00e9, mais il existe d\u00e9j\u00e0 une entr\u00e9e de configuration en conflit dans Home Assistant \u00e0 supprimer.", @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Code HomeKit incorrect. S'il vous pla\u00eet v\u00e9rifier et essayez \u00e0 nouveau.", + "insecure_setup_code": "Le code de configuration demand\u00e9 n'est pas s\u00e9curis\u00e9 en raison de sa nature triviale. Cet accessoire ne r\u00e9pond pas aux exigences de s\u00e9curit\u00e9 de base.", "max_peers_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il ne dispose pas de stockage de couplage libre.", "pairing_failed": "Une erreur non g\u00e9r\u00e9e s'est produite lors de la tentative d'appairage avec cet appareil. Il se peut qu'il s'agisse d'une panne temporaire ou que votre appareil ne soit pas pris en charge actuellement.", "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.", "unknown_error": "L'appareil a signal\u00e9 une erreur inconnue. L'appairage a \u00e9chou\u00e9." }, - "flow_title": "{name} via le protocole accessoire HomeKit", + "flow_title": "{name}", "step": { "busy_error": { "description": "Annulez l'association sur tous les contr\u00f4leurs ou essayez de red\u00e9marrer l'appareil, puis continuez \u00e0 reprendre l'association.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Autoriser le jumelage avec des codes de configuration non s\u00e9curis\u00e9s.", "pairing_code": "Code d\u2019appairage" }, "description": "Le contr\u00f4leur HomeKit communique avec {name} sur le r\u00e9seau local en utilisant une connexion crypt\u00e9e s\u00e9curis\u00e9e sans contr\u00f4leur HomeKit s\u00e9par\u00e9 ou iCloud. Entrez votre code d'appariement HomeKit (au format XXX-XX-XXX) pour utiliser cet accessoire. Ce code se trouve g\u00e9n\u00e9ralement sur l'appareil lui-m\u00eame ou dans l'emballage.", diff --git a/homeassistant/components/homekit_controller/translations/he.json b/homeassistant/components/homekit_controller/translations/he.json new file mode 100644 index 0000000000000..9593bbd90e462 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "device": "\u05d4\u05ea\u05e7\u05df" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index 90e2405ed64f8..6fad9050a200d 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -3,33 +3,48 @@ "abort": { "accessory_not_found_error": "Nem adhat\u00f3 hozz\u00e1 p\u00e1ros\u00edt\u00e1s, mert az eszk\u00f6z m\u00e1r nem tal\u00e1lhat\u00f3.", "already_configured": "A tartoz\u00e9k m\u00e1r konfigur\u00e1lva van ezzel a vez\u00e9rl\u0151vel.", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "already_paired": "Ez a tartoz\u00e9k m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik eszk\u00f6zzel. \u00c1ll\u00edtsa alaphelyzetbe a tartoz\u00e9kot, majd pr\u00f3b\u00e1lkozzon \u00fajra.", "ignored_model": "A HomeKit t\u00e1mogat\u00e1sa e modelln\u00e9l blokkolva van, mivel a szolg\u00e1ltat\u00e1shoz teljes nat\u00edv integr\u00e1ci\u00f3 \u00e9rhet\u0151 el.", - "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s a Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s Home Assistantban, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "invalid_properties": "Az eszk\u00f6z \u00e1ltal bejelentett \u00e9rv\u00e9nytelen tulajdons\u00e1gok.", "no_devices": "Nem tal\u00e1lhat\u00f3 nem p\u00e1ros\u00edtott eszk\u00f6z" }, "error": { "authentication_error": "Helytelen HomeKit k\u00f3d. K\u00e9rj\u00fck, ellen\u0151rizze, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", - "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs ingyenes p\u00e1ros\u00edt\u00e1si t\u00e1rhely.", + "insecure_setup_code": "A k\u00e9rt telep\u00edt\u00e9si k\u00f3d trivi\u00e1lis jellege miatt nem biztons\u00e1gos. Ez a tartoz\u00e9k nem felel meg az alapvet\u0151 biztons\u00e1gi k\u00f6vetelm\u00e9nyeknek.", + "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs szabad p\u00e1ros\u00edt\u00e1si t\u00e1rhelye.", "pairing_failed": "Nem kezelt hiba t\u00f6rt\u00e9nt az eszk\u00f6zzel val\u00f3 p\u00e1ros\u00edt\u00e1s sor\u00e1n. Lehet, hogy ez \u00e1tmeneti hiba, vagy az eszk\u00f6z jelenleg m\u00e9g nem t\u00e1mogatott.", "unable_to_pair": "Nem siker\u00fclt p\u00e1ros\u00edtani, pr\u00f3b\u00e1ld \u00fajra.", "unknown_error": "Az eszk\u00f6z ismeretlen hib\u00e1t jelentett. A p\u00e1ros\u00edt\u00e1s sikertelen." }, - "flow_title": "HomeKit tartoz\u00e9k: {name}", + "flow_title": "{name}", "step": { + "busy_error": { + "description": "Sz\u00fcntesse meg a p\u00e1ros\u00edt\u00e1st az \u00f6sszes vez\u00e9rl\u0151n, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", + "title": "Az eszk\u00f6z m\u00e1r p\u00e1rosul egy m\u00e1sik vez\u00e9rl\u0151vel" + }, + "max_tries_error": { + "description": "Az eszk\u00f6z t\u00f6bb mint 100 sikertelen hiteles\u00edt\u00e9si k\u00eds\u00e9rletet kapott. Ind\u00edtsa \u00fajra az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1s folytat\u00e1s\u00e1t.", + "title": "T\u00fall\u00e9pte a maxim\u00e1lis hiteles\u00edt\u00e9si k\u00eds\u00e9rleteket" + }, "pair": { "data": { + "allow_insecure_setup_codes": "P\u00e1ros\u00edt\u00e1s enged\u00e9lyez\u00e9se a nem biztons\u00e1gos be\u00e1ll\u00edt\u00e1si k\u00f3dokkal.", "pairing_code": "P\u00e1ros\u00edt\u00e1si k\u00f3d" }, - "description": "\u00cdrja be a HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban) a kieg\u00e9sz\u00edt\u0151 haszn\u00e1lat\u00e1hoz", - "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" + "description": "A HomeKit Controller {name} n\u00e9vvel kommunik\u00e1l a helyi h\u00e1l\u00f3zaton kereszt\u00fcl, biztons\u00e1gos titkos\u00edtott kapcsolaton kereszt\u00fcl, k\u00fcl\u00f6n HomeKit vez\u00e9rl\u0151 vagy iCloud n\u00e9lk\u00fcl. A tartoz\u00e9k haszn\u00e1lat\u00e1hoz adja meg HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban). Ez a k\u00f3d \u00e1ltal\u00e1ban mag\u00e1ban az eszk\u00f6z\u00f6n vagy a csomagol\u00e1sban tal\u00e1lhat\u00f3.", + "title": "P\u00e1ros\u00edt\u00e1s egy eszk\u00f6zzel a HomeKit Accessory Protocol protokollon seg\u00edts\u00e9g\u00e9vel" + }, + "protocol_error": { + "description": "El\u0151fordulhat, hogy a k\u00e9sz\u00fcl\u00e9k nincs p\u00e1ros\u00edt\u00e1si m\u00f3dban, \u00e9s sz\u00fcks\u00e9g lehet fizikai vagy virtu\u00e1lis gombnyom\u00e1sra. Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy az eszk\u00f6z p\u00e1ros\u00edt\u00e1si m\u00f3dban van, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", + "title": "Hiba t\u00f6rt\u00e9nt a tartoz\u00e9kkal val\u00f3 kommunik\u00e1ci\u00f3 sor\u00e1n" }, "user": { "data": { "device": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki azt az eszk\u00f6zt, amelyet p\u00e1ros\u00edtani szeretne", + "description": "A HomeKit Controller biztons\u00e1gos titkos\u00edtott kapcsolaton kereszt\u00fcl kommunik\u00e1l a helyi h\u00e1l\u00f3zaton kereszt\u00fcl, k\u00fcl\u00f6n HomeKit vez\u00e9rl\u0151 vagy iCloud n\u00e9lk\u00fcl. V\u00e1lassza ki a p\u00e1ros\u00edtani k\u00edv\u00e1nt eszk\u00f6zt:", "title": "Eszk\u00f6z kiv\u00e1laszt\u00e1sa" } } diff --git a/homeassistant/components/homekit_controller/translations/id.json b/homeassistant/components/homekit_controller/translations/id.json index 49a37d3b3fb68..57754bea50b68 100644 --- a/homeassistant/components/homekit_controller/translations/id.json +++ b/homeassistant/components/homekit_controller/translations/id.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Kode HomeKit salah. Periksa dan coba lagi.", + "insecure_setup_code": "Kode penyiapan yang diminta tidak aman karena sifatnya yang sepele. Aksesori ini gagal memenuhi persyaratan keamanan dasar.", "max_peers_error": "Perangkat menolak untuk menambahkan pemasangan karena tidak memiliki penyimpanan pemasangan yang tersedia.", "pairing_failed": "Terjadi kesalahan yang tidak tertangani saat mencoba memasangkan dengan perangkat ini. Ini mungkin kegagalan sementara atau perangkat Anda mungkin tidak didukung saat ini.", "unable_to_pair": "Gagal memasangkan, coba lagi.", "unknown_error": "Perangkat melaporkan kesalahan yang tidak diketahui. Pemasangan gagal." }, - "flow_title": "{name} lewat HomeKit Accessory Protocol", + "flow_title": "{name}", "step": { "busy_error": { "description": "Batalkan pemasangan di semua pengontrol, atau coba mulai ulang perangkat, lalu lanjutkan untuk melanjutkan pemasangan.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Izinkan pemasangan dengan kode penyiapan yang tidak aman.", "pairing_code": "Kode Pemasangan" }, "description": "Pengontrol HomeKit berkomunikasi dengan {name} melalui jaringan area lokal menggunakan koneksi terenkripsi yang aman tanpa pengontrol HomeKit atau iCloud terpisah. Masukkan kode pemasangan HomeKit Anda (dalam format XXX-XX-XXX) untuk menggunakan aksesori ini. Kode ini biasanya ditemukan pada perangkat itu sendiri atau dalam kemasan.", diff --git a/homeassistant/components/homekit_controller/translations/it.json b/homeassistant/components/homekit_controller/translations/it.json index 38a3bfccd1098..d95eff05cea94 100644 --- a/homeassistant/components/homekit_controller/translations/it.json +++ b/homeassistant/components/homekit_controller/translations/it.json @@ -4,7 +4,7 @@ "accessory_not_found_error": "Impossibile aggiungere l'abbinamento in quanto non \u00e8 pi\u00f9 possibile trovare il dispositivo.", "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller.", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", - "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Si prega di resettare l'accessorio e riprovare.", + "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Ripristina l'accessorio e riprova.", "ignored_model": "Il supporto di HomeKit per questo modello \u00e8 bloccato poich\u00e9 \u00e8 disponibile un'integrazione nativa con pi\u00f9 funzionalit\u00e0.", "invalid_config_entry": "Questo dispositivo viene visualizzato come pronto per l'associazione, ma c'\u00e8 gi\u00e0 una voce di configurazione in conflitto in Home Assistant che prima deve essere rimossa.", "invalid_properties": "Propriet\u00e0 non valide annunciate dal dispositivo.", @@ -12,15 +12,16 @@ }, "error": { "authentication_error": "Codice HomeKit errato. Per favore, controllate e riprovate.", + "insecure_setup_code": "Il codice di installazione richiesto non \u00e8 sicuro a causa della sua natura banale. Questo accessorio non soddisfa i requisiti di sicurezza di base.", "max_peers_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto non dispone di una memoria libera per esso.", "pairing_failed": "Si \u00e8 verificato un errore non gestito durante il tentativo di abbinamento con questo dispositivo. Potrebbe trattarsi di un errore temporaneo o il dispositivo potrebbe non essere attualmente supportato.", - "unable_to_pair": "Impossibile abbinare, per favore riprova.", + "unable_to_pair": "Impossibile abbinare, riprova.", "unknown_error": "Il dispositivo ha riportato un errore sconosciuto. L'abbinamento non \u00e8 riuscito." }, - "flow_title": "{name} tramite il Protocollo degli Accessori HomeKit", + "flow_title": "{name}", "step": { "busy_error": { - "description": "Interrompere l'associazione su tutti i controller o provare a riavviare il dispositivo, quindi continuare a riprendere l'associazione.", + "description": "Interrompi l'associazione su tutti i controller o provare a riavviare il dispositivo, quindi continua a riprendere l'associazione.", "title": "Il dispositivo \u00e8 gi\u00e0 associato a un altro controller" }, "max_tries_error": { @@ -29,9 +30,10 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Consenti l'associazione con codici di installazione non sicuri.", "pairing_code": "Codice di abbinamento" }, - "description": "Il controller HomeKit comunica con {name} sulla rete locale utilizzando una connessione crittografata sicura senza un controller HomeKit separato o iCloud. Inserisci il tuo codice di associazione HomeKit (nel formato XXX-XX-XXX) per utilizzare questo accessorio. Questo codice si trova solitamente sul dispositivo stesso o nella confezione.", + "description": "Il controller HomeKit comunica con {name} sulla rete locale utilizzando una connessione cifrata sicura senza un controller HomeKit separato o iCloud. Inserisci il tuo codice di associazione HomeKit (nel formato XXX-XX-XXX) per utilizzare questo accessorio. Questo codice si trova solitamente sul dispositivo stesso o nella confezione.", "title": "Associazione con un dispositivo tramite il Protocollo degli Accessori HomeKit" }, "protocol_error": { @@ -42,7 +44,7 @@ "data": { "device": "Dispositivo" }, - "description": "Il controller HomeKit comunica sulla rete locale utilizzando una connessione crittografata sicura senza un controller HomeKit separato o iCloud. Seleziona il dispositivo che desideri associare:", + "description": "Il controller HomeKit comunica sulla rete locale utilizzando una connessione cifrata sicura senza un controller HomeKit separato o iCloud. Seleziona il dispositivo che desideri associare:", "title": "Selezione del dispositivo" } } diff --git a/homeassistant/components/homekit_controller/translations/ja.json b/homeassistant/components/homekit_controller/translations/ja.json new file mode 100644 index 0000000000000..8e1e35c60e252 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/ja.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u3089\u306a\u3044\u305f\u3081\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002", + "already_configured": "\u30a2\u30af\u30bb\u30b5\u30ea\u306f\u3001\u3053\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u3067\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "already_paired": "\u3053\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u306f\u3001\u3059\u3067\u306b\u4ed6\u306e\u30c7\u30d0\u30a4\u30b9\u3068\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "ignored_model": "\u3053\u306e\u30e2\u30c7\u30eb\u306eHomeKit\u3067\u306e\u5bfe\u5fdc\u306f\u3001\u3088\u308a\u5b8c\u5168\u3067\u30cd\u30a4\u30c6\u30a3\u30d6\u306a\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u4f7f\u7528\u53ef\u80fd\u306a\u305f\u3081\u3001\u30d6\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "invalid_config_entry": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u306e\u6e96\u5099\u304c\u3067\u304d\u3066\u3044\u308b\u3068\u8868\u793a\u3055\u308c\u3066\u3044\u307e\u3059\u304c\u3001Home Assistant\u306b\u306f\u3059\u3067\u306b\u7af6\u5408\u3059\u308b\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u304c\u3042\u308b\u305f\u3081\u3001\u5148\u306b\u3053\u308c\u3092\u524a\u9664\u3057\u3066\u304a\u304f\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "invalid_properties": "\u7121\u52b9\u306a\u30d7\u30ed\u30d1\u30c6\u30a3\u304c\u30c7\u30d0\u30a4\u30b9\u306b\u3088\u3063\u3066\u77e5\u3089\u3055\u308c\u307e\u3057\u305f\u3002", + "no_devices": "\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u306a\u3044\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "error": { + "authentication_error": "HomeKit\u30b3\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002\u78ba\u8a8d\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "insecure_setup_code": "\u8981\u6c42\u3055\u308c\u305f\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u30b3\u30fc\u30c9\u306f\u3001\u5358\u7d14\u3059\u304e\u308b\u306e\u3067\u5b89\u5168\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u306f\u3001\u57fa\u672c\u7684\u306a\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u8981\u4ef6\u3092\u6e80\u305f\u3057\u3066\u3044\u307e\u305b\u3093\u3002", + "max_peers_error": "\u30c7\u30d0\u30a4\u30b9\u306b\u306f\u7121\u6599\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u30b9\u30c8\u30ec\u30fc\u30b8\u304c\u306a\u3044\u305f\u3081\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u306e\u8ffd\u52a0\u3092\u62d2\u5426\u3057\u307e\u3057\u305f\u3002", + "pairing_failed": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u3068\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u4e2d\u306b\u3001\u672a\u51e6\u7406\u306e\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u3053\u308c\u306f\u4e00\u6642\u7684\u306a\u969c\u5bb3\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u73fe\u5728\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002", + "unable_to_pair": "\u30da\u30a2\u30ea\u30f3\u30b0\u3067\u304d\u307e\u305b\u3093\u3002\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown_error": "\u30c7\u30d0\u30a4\u30b9\u304c\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u3092\u5831\u544a\u3057\u307e\u3057\u305f\u3002\u30da\u30a2\u30ea\u30f3\u30b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" + }, + "flow_title": "{name}", + "step": { + "busy_error": { + "description": "\u3059\u3079\u3066\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u3067\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u4e2d\u6b62\u3059\u308b\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u518d\u958b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u65e2\u306b\u4ed6\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u3068\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "max_tries_error": { + "description": "\u30c7\u30d0\u30a4\u30b9\u306f\u3001100\u56de\u3092\u8d85\u3048\u308b\u8a8d\u8a3c\u8a66\u884c\u3092\u53d7\u4fe1\u3057\u307e\u3057\u305f\u3002\u30c7\u30d0\u30a4\u30b9\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u518d\u958b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u8a8d\u8a3c\u306e\u6700\u5927\u8a66\u884c\u56de\u6570\u3092\u8d85\u3048\u307e\u3057\u305f" + }, + "pair": { + "data": { + "allow_insecure_setup_codes": "\u5b89\u5168\u3067\u306a\u3044\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u30b3\u30fc\u30c9\u3068\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u8a31\u53ef\u3059\u308b\u3002", + "pairing_code": "\u30da\u30a2\u30ea\u30f3\u30b0\u30b3\u30fc\u30c9" + }, + "description": "HomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306f\u3001\u5225\u306eHomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u3084iCloud\u3092\u4f7f\u7528\u305b\u305a\u306b\u3001\u30bb\u30ad\u30e5\u30a2\u306a\u6697\u53f7\u5316\u63a5\u7d9a\u3092\u4f7f\u7528\u3057\u3066\u30ed\u30fc\u30ab\u30eb\u30a8\u30ea\u30a2\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u3067 {name} \u3068\u901a\u4fe1\u3057\u307e\u3059\u3002\u3053\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u3092\u4f7f\u7528\u3059\u308b\u306b\u306f\u3001HomeKit \u306e\u30da\u30a2\u30ea\u30f3\u30b0\u30b3\u30fc\u30c9(XXX-XX-XXX \u306e\u5f62\u5f0f)\u5165\u529b\u3057\u307e\u3059\u3002\u3053\u306e\u30b3\u30fc\u30c9\u306f\u901a\u5e38\u3001\u30c7\u30d0\u30a4\u30b9\u672c\u4f53\u307e\u305f\u306f\u30d1\u30c3\u30b1\u30fc\u30b8\u306b\u8a18\u8f09\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "title": "HomeKit Accessory Protocol\u3092\u4ecb\u3057\u3066\u30c7\u30d0\u30a4\u30b9\u3068\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "protocol_error": { + "description": "\u30c7\u30d0\u30a4\u30b9\u304c\u30da\u30a2\u30ea\u30f3\u30b0\u30e2\u30fc\u30c9\u306b\u306a\u3063\u3066\u3044\u306a\u3044\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u306e\u3067\u3001\u7269\u7406\u307e\u305f\u306f\u4eee\u60f3\u7684\u306a\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u304c\u30da\u30a2\u30ea\u30f3\u30b0\u30e2\u30fc\u30c9\u306b\u306a\u3063\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3059\u308b\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u518d\u958b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30a2\u30af\u30bb\u30b5\u30ea\u3068\u306e\u901a\u4fe1\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f" + }, + "user": { + "data": { + "device": "\u30c7\u30d0\u30a4\u30b9" + }, + "description": "HomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306f\u3001\u5225\u306eHomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u3084iCloud\u3092\u4f7f\u7528\u305b\u305a\u306b\u3001\u30bb\u30ad\u30e5\u30a2\u306a\u6697\u53f7\u5316\u63a5\u7d9a\u3092\u4f7f\u7528\u3057\u3066\u30ed\u30fc\u30ab\u30eb\u30a8\u30ea\u30a2\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u7d4c\u7531\u3067\u901a\u4fe1\u3057\u307e\u3059\u3002\u30da\u30a2\u30ea\u30f3\u30b0\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u9078\u629e" + } + } + }, + "device_automation": { + "trigger_subtype": { + "button1": "\u30dc\u30bf\u30f31", + "button10": "\u30dc\u30bf\u30f310", + "button2": "\u30dc\u30bf\u30f32", + "button3": "\u30dc\u30bf\u30f33", + "button4": "\u30dc\u30bf\u30f34", + "button5": "\u30dc\u30bf\u30f35", + "button6": "\u30dc\u30bf\u30f36", + "button7": "\u30dc\u30bf\u30f37", + "button8": "\u30dc\u30bf\u30f38", + "button9": "\u30dc\u30bf\u30f39", + "doorbell": "\u30c9\u30a2\u30d9\u30eb" + }, + "trigger_type": { + "double_press": "\"{subtype}\" \u30922\u56de\u62bc\u3059", + "long_press": "\"{subtype}\" \u304c\u3001\u62bc\u3055\u308c\u305f\u307e\u307e", + "single_press": "\"{subtype}\" \u304c\u3001\u62bc\u3055\u308c\u307e\u3057\u305f" + } + }, + "title": "HomeKit Controller" +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/lt.json b/homeassistant/components/homekit_controller/translations/lt.json new file mode 100644 index 0000000000000..965b32b366d59 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/lt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u012erenginio pasirinkimas" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/nl.json b/homeassistant/components/homekit_controller/translations/nl.json index 57692426ce0ff..4312fdc033c74 100644 --- a/homeassistant/components/homekit_controller/translations/nl.json +++ b/homeassistant/components/homekit_controller/translations/nl.json @@ -12,15 +12,16 @@ }, "error": { "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.", + "insecure_setup_code": "De gevraagde setup-code is onveilig vanwege de triviale aard ervan. Dit accessoire voldoet niet aan de basisbeveiligingsvereisten.", "max_peers_error": "Apparaat heeft geweigerd om koppelingen toe te voegen omdat het geen vrije koppelingsopslag heeft.", "pairing_failed": "Er deed zich een fout voor tijdens het koppelen met dit apparaat. Dit kan een tijdelijke storing zijn of uw apparaat wordt mogelijk momenteel niet ondersteund.", "unable_to_pair": "Kan niet koppelen, probeer het opnieuw.", "unknown_error": "Apparaat meldde een onbekende fout. Koppelen mislukt." }, - "flow_title": "{name} via HomeKit-accessoireprotocol", + "flow_title": "{name}", "step": { "busy_error": { - "description": "Onderbreek het koppelen op alle controllers, of probeer het apparaat opnieuw op te starten, en ga dan verder om het koppelen te hervatten.", + "description": "Onderbreek het koppelen op alle controllers, of probeer het apparaat opnieuw op te starten, ga dan verder om het koppelen te hervatten.", "title": "Het apparaat is al aan het koppelen met een andere controller" }, "max_tries_error": { @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Koppelen met onveilige setup-codes toestaan.", "pairing_code": "Koppelingscode" }, "description": "HomeKit Controller communiceert met {name} via het lokale netwerk met behulp van een beveiligde versleutelde verbinding zonder een aparte HomeKit-controller of iCloud. Voer uw HomeKit-koppelcode in (in de indeling XXX-XX-XXX) om dit accessoire te gebruiken. Deze code is meestal te vinden op het apparaat zelf of in de verpakking.", diff --git a/homeassistant/components/homekit_controller/translations/no.json b/homeassistant/components/homekit_controller/translations/no.json index b0680ea5ba0ca..c383dddb763e3 100644 --- a/homeassistant/components/homekit_controller/translations/no.json +++ b/homeassistant/components/homekit_controller/translations/no.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Ugyldig HomeKit kode. Vennligst sjekk den og pr\u00f8v igjen.", + "insecure_setup_code": "Den forespurte installasjonskoden er usikker p\u00e5 grunn av triviell natur. Dette tilbeh\u00f8ret oppfyller ikke grunnleggende sikkerhetskrav.", "max_peers_error": "Enheten nekter \u00e5 sammenkoble da den ikke har ledig sammenkoblingslagring.", "pairing_failed": "En uh\u00e5ndtert feil oppstod under fors\u00f8k p\u00e5 \u00e5 koble til denne enheten. Dette kan v\u00e6re en midlertidig feil, eller at enheten din kan ikke st\u00f8ttes for \u00f8yeblikket.", "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.", "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes." }, - "flow_title": "{name} via HomeKit tilbeh\u00f8rsprotokoll", + "flow_title": "{name}", "step": { "busy_error": { "description": "Avbryt sammenkobling p\u00e5 alle kontrollere, eller pr\u00f8v \u00e5 starte enheten p\u00e5 nytt, og fortsett deretter med \u00e5 fortsette sammenkoblingen.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Tillat sammenkobling med usikre oppsettkoder.", "pairing_code": "Sammenkoblingskode" }, "description": "HomeKit Controller kommuniserer med {name} over lokalnettverket ved hjelp av en sikker kryptert tilkobling uten en separat HomeKit-kontroller eller iCloud. Skriv inn HomeKit-paringskoden (i formatet XXX-XX-XXX) for \u00e5 bruke dette tilbeh\u00f8ret. Denne koden finnes vanligvis p\u00e5 selve enheten eller i emballasjen.", diff --git a/homeassistant/components/homekit_controller/translations/pl.json b/homeassistant/components/homekit_controller/translations/pl.json index 3ccdfe452e559..d7b5cc69cf331 100644 --- a/homeassistant/components/homekit_controller/translations/pl.json +++ b/homeassistant/components/homekit_controller/translations/pl.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", + "insecure_setup_code": "\u017b\u0105dany kod instalacyjny jest niezabezpieczony ze wzgl\u0119du na jego trywialny charakter. To akcesorium nie spe\u0142nia podstawowych wymaga\u0144 bezpiecze\u0144stwa.", "max_peers_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c nie ma wolnej pami\u0119ci parowania", "pairing_failed": "Wyst\u0105pi\u0142 nieobs\u0142ugiwany b\u0142\u0105d podczas pr\u00f3by sparowania z tym urz\u0105dzeniem. Mo\u017ce to by\u0107 tymczasowa awaria lub urz\u0105dzenie mo\u017ce nie by\u0107 obecnie obs\u0142ugiwane.", "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie", "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119." }, - "flow_title": "{name} poprzez akcesorium HomeKit", + "flow_title": "{name}", "step": { "busy_error": { "description": "Przerwij parowanie we wszystkich kontrolerach lub spr\u00f3buj ponownie uruchomi\u0107 urz\u0105dzenie, a nast\u0119pnie wzn\u00f3w parowanie", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Zezwalaj na parowanie z niezabezpieczonymi kodami konfiguracji.", "pairing_code": "Kod parowania" }, "description": "Kontroler HomeKit komunikuje si\u0119 z {name} poprzez sie\u0107 lokaln\u0105 za pomoc\u0105 bezpiecznego, szyfrowanego po\u0142\u0105czenia bez oddzielnego kontrolera HomeKit lub iCloud. Wprowad\u017a kod parowania (w formacie XXX-XX-XXX), aby u\u017cy\u0107 tego akcesorium. Ten kod zazwyczaj znajduje si\u0119 na samym urz\u0105dzeniu lub w jego opakowaniu.", diff --git a/homeassistant/components/homekit_controller/translations/ru.json b/homeassistant/components/homekit_controller/translations/ru.json index 16e4c611efee5..d4ab6771ee5ca 100644 --- a/homeassistant/components/homekit_controller/translations/ru.json +++ b/homeassistant/components/homekit_controller/translations/ru.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 HomeKit. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u0434 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "insecure_setup_code": "\u0417\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0439 \u043a\u043e\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d \u0438\u0437-\u0437\u0430 \u0441\u0432\u043e\u0435\u0439 \u0442\u0440\u0438\u0432\u0438\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438. \u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u043d\u0435 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u043e\u0441\u043d\u043e\u0432\u043d\u044b\u043c \u0442\u0440\u0435\u0431\u043e\u0432\u0430\u043d\u0438\u044f\u043c \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438.", "max_peers_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0438\u0437-\u0437\u0430 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u044f \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u0430.", "pairing_failed": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0439 \u0441\u0431\u043e\u0439 \u0438\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u0435\u0449\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", "unable_to_pair": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u043e\u043e\u0431\u0449\u0438\u043b\u043e \u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435. \u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c." }, - "flow_title": "{name} \u0447\u0435\u0440\u0435\u0437 \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u0432 HomeKit", + "flow_title": "{name}", "step": { "busy_error": { "description": "\u041e\u0442\u043c\u0435\u043d\u0438\u0442\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 \u0432\u0441\u0435\u0445 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430\u0445 \u0438\u043b\u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u0437\u0430\u0442\u0435\u043c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u043c\u0438 \u043a\u043e\u0434\u0430\u043c\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438.", "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" }, "description": "HomeKit Controller \u043e\u0431\u043c\u0435\u043d\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u043c\u0438 \u0441 {name} \u043f\u043e \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0435\u0442\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0435 \u0437\u0430\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0431\u0435\u0437 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430 HomeKit \u0438\u043b\u0438 iCloud. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit (\u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 XXX-XX-XXX), \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440. \u042d\u0442\u043e\u0442 \u043a\u043e\u0434 \u043e\u0431\u044b\u0447\u043d\u043e \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u043d\u0430 \u0441\u0430\u043c\u043e\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435 \u0438\u043b\u0438 \u043d\u0430 \u0443\u043f\u0430\u043a\u043e\u0432\u043a\u0435.", diff --git a/homeassistant/components/homekit_controller/translations/tr.json b/homeassistant/components/homekit_controller/translations/tr.json index 9d72049ba2192..7ddb32ade8ee0 100644 --- a/homeassistant/components/homekit_controller/translations/tr.json +++ b/homeassistant/components/homekit_controller/translations/tr.json @@ -1,19 +1,51 @@ { "config": { "abort": { + "accessory_not_found_error": "Cihaz art\u0131k bulunamad\u0131\u011f\u0131ndan e\u015fle\u015ftirme eklenemiyor.", "already_configured": "Aksesuar zaten bu denetleyici ile yap\u0131land\u0131r\u0131lm\u0131\u015ft\u0131r.", - "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "already_paired": "Bu aksesuar zaten ba\u015fka bir cihazla e\u015fle\u015ftirilmi\u015f. L\u00fctfen aksesuar\u0131 s\u0131f\u0131rlay\u0131n ve tekrar deneyin.", + "ignored_model": "Daha \u00f6zellikli tam yerel entegrasyon kullan\u0131labilir oldu\u011fundan, bu model i\u00e7in HomeKit deste\u011fi engellendi.", + "invalid_config_entry": "Bu ayg\u0131t e\u015fle\u015fmeye haz\u0131r olarak g\u00f6steriliyor, ancak ev asistan\u0131nda ilk olarak kald\u0131r\u0131lmas\u0131 gereken \u00e7ak\u0131\u015fan bir yap\u0131land\u0131rma girdisi zaten var.", + "invalid_properties": "Cihaz taraf\u0131ndan a\u00e7\u0131klanan ge\u00e7ersiz \u00f6zellikler.", + "no_devices": "E\u015flenmemi\u015f cihaz bulunamad\u0131" }, "error": { "authentication_error": "Yanl\u0131\u015f HomeKit kodu. L\u00fctfen kontrol edip tekrar deneyin.", + "insecure_setup_code": "\u0130stenen kurulum kodu, \u00f6nemsiz do\u011fas\u0131 nedeniyle g\u00fcvenli de\u011fil. Bu aksesuar, temel g\u00fcvenlik gereksinimlerini kar\u015f\u0131lam\u0131yor.", + "max_peers_error": "Cihaz, \u00fccretsiz e\u015fle\u015ftirme depolama alan\u0131 olmad\u0131\u011f\u0131 i\u00e7in e\u015fle\u015ftirme eklemeyi reddetti.", + "pairing_failed": "Bu cihazla e\u015fle\u015fmeye \u00e7al\u0131\u015f\u0131l\u0131rken i\u015flenmeyen bir hata olu\u015ftu. Bu ge\u00e7ici bir hata olabilir veya cihaz\u0131n\u0131z \u015fu anda desteklenmiyor olabilir.", + "unable_to_pair": "E\u015fle\u015ftirilemiyor, l\u00fctfen tekrar deneyin.", "unknown_error": "Cihaz bilinmeyen bir hata bildirdi. E\u015fle\u015ftirme ba\u015far\u0131s\u0131z oldu." }, + "flow_title": "{name}", "step": { "busy_error": { + "description": "T\u00fcm denetleyicilerde e\u015fle\u015ftirmeyi durdurun veya cihaz\u0131 yeniden ba\u015flatmay\u0131 deneyin, ard\u0131ndan e\u015fle\u015ftirmeye devam edin.", "title": "Cihaz zaten ba\u015fka bir oyun kumandas\u0131yla e\u015fle\u015fiyor" }, "max_tries_error": { + "description": "Cihaz, 100'den fazla ba\u015far\u0131s\u0131z kimlik do\u011frulama giri\u015fimi ald\u0131. Cihaz\u0131 yeniden ba\u015flatmay\u0131 deneyin, ard\u0131ndan e\u015fle\u015ftirmeye devam edin.", "title": "Maksimum kimlik do\u011frulama giri\u015fimi a\u015f\u0131ld\u0131" + }, + "pair": { + "data": { + "allow_insecure_setup_codes": "G\u00fcvenli olmayan kurulum kodlar\u0131yla e\u015fle\u015ftirmeye izin verin.", + "pairing_code": "E\u015fle\u015ftirme Kodu" + }, + "description": "HomeKit Denetleyici, ayr\u0131 bir HomeKit denetleyicisi veya iCloud olmadan g\u00fcvenli bir \u015fifreli ba\u011flant\u0131 kullanarak yerel alan a\u011f\u0131 \u00fczerinden {name} ile ileti\u015fim kurar. Bu aksesuar\u0131 kullanmak i\u00e7in HomeKit e\u015fle\u015ftirme kodunuzu (XXX-XX-XXX bi\u00e7iminde) girin. Bu kod genellikle cihaz\u0131n kendisinde veya ambalaj\u0131nda bulunur.", + "title": "HomeKit Aksesuar Protokol\u00fc arac\u0131l\u0131\u011f\u0131yla bir cihazla e\u015fle\u015ftirin" + }, + "protocol_error": { + "description": "Cihaz e\u015fle\u015ftirme modunda olmayabilir ve fiziksel veya sanal bir d\u00fc\u011fmeye bas\u0131lmas\u0131n\u0131 gerektirebilir. Cihaz\u0131n e\u015fle\u015ftirme modunda oldu\u011fundan emin olun veya cihaz\u0131 yeniden ba\u015flatmay\u0131 deneyin, ard\u0131ndan e\u015fle\u015ftirmeye devam edin.", + "title": "Aksesuarla ileti\u015fim kurma hatas\u0131" + }, + "user": { + "data": { + "device": "Cihaz" + }, + "description": "HomeKit Denetleyici, ayr\u0131 bir HomeKit denetleyicisi veya iCloud olmadan g\u00fcvenli bir \u015fifreli ba\u011flant\u0131 kullanarak yerel alan a\u011f\u0131 \u00fczerinden ileti\u015fim kurar. E\u015fle\u015ftirmek istedi\u011finiz cihaz\u0131 se\u00e7in:", + "title": "Cihaz se\u00e7imi" } } }, @@ -30,6 +62,12 @@ "button8": "D\u00fc\u011fme 8", "button9": "D\u00fc\u011fme 9", "doorbell": "Kap\u0131 zili" + }, + "trigger_type": { + "double_press": "\" {subtype} \" iki kez bas\u0131ld\u0131", + "long_press": "\" {subtype} \" bas\u0131l\u0131 tutuldu", + "single_press": "\" {subtype} \" bas\u0131ld\u0131" } - } + }, + "title": "HomeKit Denetleyicisi" } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/zh-Hans.json b/homeassistant/components/homekit_controller/translations/zh-Hans.json index 624050e71466c..a5f57e2f57615 100644 --- a/homeassistant/components/homekit_controller/translations/zh-Hans.json +++ b/homeassistant/components/homekit_controller/translations/zh-Hans.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "insecure_setup_code": "\u8bf7\u6c42\u7684\u8bbe\u7f6e\u4ee3\u7801\u7531\u4e8e\u8fc7\u4e8e\u7b80\u5355\u800c\u4e0d\u5b89\u5168\u3002\u6b64\u914d\u4ef6\u4e0d\u7b26\u5408\u57fa\u672c\u5b89\u5168\u8981\u6c42\u3002", "max_peers_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u6ca1\u6709\u7a7a\u95f2\u7684\u914d\u5bf9\u5b58\u50a8\u7a7a\u95f4\u3002", "pairing_failed": "\u5c1d\u8bd5\u4e0e\u6b64\u8bbe\u5907\u914d\u5bf9\u65f6\u53d1\u751f\u672a\u5904\u7406\u7684\u9519\u8bef\u3002\u8fd9\u53ef\u80fd\u662f\u6682\u65f6\u6027\u6545\u969c\uff0c\u4e5f\u53ef\u80fd\u662f\u60a8\u7684\u8bbe\u5907\u76ee\u524d\u4e0d\u88ab\u652f\u6301\u3002", "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "\u5141\u8bb8\u4f7f\u7528\u4e0d\u5b89\u5168\u7684\u8bbe\u7f6e\u4ee3\u7801\u914d\u5bf9\u3002", "pairing_code": "\u914d\u5bf9\u4ee3\u7801" }, "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\uff08\u683c\u5f0f\u4e3a XXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", @@ -42,7 +44,7 @@ "data": { "device": "\u8bbe\u5907" }, - "description": "\u9009\u62e9\u60a8\u8981\u914d\u5bf9\u7684\u8bbe\u5907", + "description": "HomeKit \u63a7\u5236\u5668\u4f7f\u7528\u5b89\u5168\u7684\u52a0\u5bc6\u8fde\u63a5\uff0c\u901a\u8fc7\u5c40\u57df\u7f51\u76f4\u63a5\u8fdb\u884c\u901a\u4fe1\uff0c\u65e0\u9700\u5355\u72ec\u7684 HomeKit \u63a7\u5236\u5668\u6216 iCloud\u3002\u8bf7\u9009\u62e9\u8981\u914d\u5bf9\u7684\u8bbe\u5907\uff1a", "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" } } diff --git a/homeassistant/components/homekit_controller/translations/zh-Hant.json b/homeassistant/components/homekit_controller/translations/zh-Hant.json index 6490904a32e7f..e13b69bd0fd40 100644 --- a/homeassistant/components/homekit_controller/translations/zh-Hant.json +++ b/homeassistant/components/homekit_controller/translations/zh-Hant.json @@ -12,12 +12,13 @@ }, "error": { "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "insecure_setup_code": "\u7531\u65bc\u5176\u7463\u788e\u7279\u6027\u3001\u6240\u8acb\u6c42\u7684\u8a2d\u5b9a\u4ee3\u78bc\u4e0d\u5b89\u5168\u3002\u6b64\u914d\u4ef6\u7121\u6cd5\u9054\u5230\u6700\u4f4e\u5b89\u5168\u9700\u6c42\u3002", "max_peers_error": "\u88dd\u7f6e\u5df2\u7121\u5269\u9918\u914d\u5c0d\u7a7a\u9593\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", "pairing_failed": "\u7576\u8a66\u5716\u8207\u88dd\u7f6e\u914d\u5c0d\u6642\u767c\u751f\u7121\u6cd5\u8655\u7406\u932f\u8aa4\uff0c\u53ef\u80fd\u50c5\u70ba\u66ab\u6642\u5931\u6548\u3001\u6216\u8005\u88dd\u7f6e\u76ee\u524d\u4e0d\u652f\u63f4\u3002", "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", "unknown_error": "\u88dd\u7f6e\u56de\u5831\u672a\u77e5\u932f\u8aa4\u3002\u914d\u5c0d\u5931\u6557\u3002" }, - "flow_title": "{name} \u4f7f\u7528 HomeKit \u914d\u4ef6\u901a\u8a0a\u5354\u5b9a", + "flow_title": "{name}", "step": { "busy_error": { "description": "\u53d6\u6d88\u6240\u6709\u63a7\u5236\u5668\u914d\u5c0d\uff0c\u6216\u8005\u91cd\u555f\u88dd\u7f6e\u3001\u7136\u5f8c\u518d\u7e7c\u7e8c\u914d\u5c0d\u3002", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "\u5141\u8a31\u8207\u4e0d\u5b89\u5168\u8a2d\u5b9a\u4ee3\u78bc\u9032\u884c\u914d\u5c0d\u3002", "pairing_code": "\u8a2d\u5b9a\u4ee3\u78bc" }, "description": "\u4f7f\u7528 {name} \u4e4b HomeKit \u63a7\u5236\u5668\u901a\u8a0a\u4f7f\u7528\u52a0\u5bc6\u9023\u7dda\uff0c\u4e26\u4e0d\u9700\u8981\u984d\u5916\u7684 HomeKit \u63a7\u5236\u5668\u6216 iCloud \u9023\u7dda\u3002\u8f38\u5165\u914d\u4ef6 Homekit \u8a2d\u5b9a\u914d\u5c0d\u4ee3\u78bc\uff08\u683c\u5f0f\uff1aXXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6\u3002\u4ee3\u78bc\u901a\u5e38\u53ef\u4ee5\u65bc\u88dd\u7f6e\u6216\u8005\u5305\u88dd\u4e0a\u627e\u5230\u3002", diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py index 286c7372fd2d8..442e4c8e43450 100644 --- a/homeassistant/components/homematic/binary_sensor.py +++ b/homeassistant/components/homematic/binary_sensor.py @@ -1,10 +1,6 @@ """Support for HomeMatic binary sensors.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_PRESENCE, - DEVICE_CLASS_SMOKE, + BinarySensorDeviceClass, BinarySensorEntity, ) @@ -12,22 +8,24 @@ from .entity import HMDevice SENSOR_TYPES_CLASS = { - "IPShutterContact": DEVICE_CLASS_OPENING, - "IPShutterContactSabotage": DEVICE_CLASS_OPENING, - "MaxShutterContact": DEVICE_CLASS_OPENING, - "Motion": DEVICE_CLASS_MOTION, - "MotionV2": DEVICE_CLASS_MOTION, - "PresenceIP": DEVICE_CLASS_PRESENCE, + "IPShutterContact": BinarySensorDeviceClass.OPENING, + "IPShutterContactSabotage": BinarySensorDeviceClass.OPENING, + "MaxShutterContact": BinarySensorDeviceClass.OPENING, + "Motion": BinarySensorDeviceClass.MOTION, + "MotionV2": BinarySensorDeviceClass.MOTION, + "PresenceIP": BinarySensorDeviceClass.MOTION, "Remote": None, "RemoteMotion": None, - "ShutterContact": DEVICE_CLASS_OPENING, - "Smoke": DEVICE_CLASS_SMOKE, - "SmokeV2": DEVICE_CLASS_SMOKE, + "ShutterContact": BinarySensorDeviceClass.OPENING, + "Smoke": BinarySensorDeviceClass.SMOKE, + "SmokeV2": BinarySensorDeviceClass.SMOKE, "TiltSensor": None, "WeatherSensor": None, - "IPContact": DEVICE_CLASS_OPENING, - "MotionIPV2": DEVICE_CLASS_MOTION, - "IPRemoteMotionV2": DEVICE_CLASS_MOTION, + "IPContact": BinarySensorDeviceClass.OPENING, + "MotionIP": BinarySensorDeviceClass.MOTION, + "MotionIPV2": BinarySensorDeviceClass.MOTION, + "MotionIPContactSabotage": BinarySensorDeviceClass.MOTION, + "IPRemoteMotionV2": BinarySensorDeviceClass.MOTION, } @@ -61,7 +59,7 @@ def device_class(self): """Return the class of this sensor from DEVICE_CLASSES.""" # If state is MOTION (Only RemoteMotion working) if self._state == "MOTION": - return DEVICE_CLASS_MOTION + return BinarySensorDeviceClass.MOTION return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__) def _init_data_struct(self): @@ -74,10 +72,7 @@ def _init_data_struct(self): class HMBatterySensor(HMDevice, BinarySensorEntity): """Representation of an HomeMatic low battery sensor.""" - @property - def device_class(self): - """Return battery as a device class.""" - return DEVICE_CLASS_BATTERY + _attr_device_class = BinarySensorDeviceClass.BATTERY @property def is_on(self): diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index aa5fb4a8e4459..84c722db1df13 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -133,8 +133,7 @@ def target_temperature(self): def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return None self._hmdevice.writeNodeData(self._state, float(temperature)) diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index 864441c2aa68f..427a4ccb7aa26 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -47,6 +47,7 @@ "IOSwitch", "IOSwitchNoInhibit", "IPSwitch", + "IPSwitchRssiDevice", "RFSiren", "IPSwitchPowermeter", "HMWIOSwitch", @@ -59,8 +60,12 @@ "IPMultiIO", "IPWSwitch", "IOSwitchWireless", + "IPSwitchRssiDevice", "IPWIODevice", "IPSwitchBattery", + "IPMultiIOPCB", + "IPGarageSwitch", + "IPWHS2", ], DISCOVER_LIGHTS: [ "Dimmer", @@ -76,6 +81,8 @@ "SwitchPowermeter", "Motion", "MotionV2", + "MotionIPV2", + "MotionIPContactSabotage", "RemoteMotion", "MotionIP", "ThermostatWall", @@ -110,7 +117,6 @@ "IPBrightnessSensor", "IPGarage", "UniversalSensor", - "MotionIPV2", "IPMultiIO", "IPThermostatWall2", "IPRemoteMotionV2", @@ -122,6 +128,10 @@ "IPKeyBlindTilt", "IPLanRouter", "TempModuleSTE2", + "IPMultiIOPCB", + "ValveBoxW", + "CO2SensorIP", + "IPLockDLD", ], DISCOVER_CLIMATE: [ "Thermostat", @@ -134,14 +144,18 @@ "ThermostatGroup", "IPThermostatWall230V", "IPThermostatWall2", + "IPWThermostatWall", ], DISCOVER_BINARY_SENSORS: [ "ShutterContact", "Smoke", "SmokeV2", + "SmokeV2Team", "Motion", "MotionV2", "MotionIP", + "MotionIPV2", + "MotionIPContactSabotage", "RemoteMotion", "WeatherSensor", "TiltSensor", @@ -155,7 +169,6 @@ "IPPassageSensor", "SmartwareMotion", "IPWeatherSensorPlus", - "MotionIPV2", "WaterIP", "IPMultiIO", "TiltIP", @@ -167,6 +180,8 @@ "IPAlarmSensor", "IPRainSensor", "IPLanRouter", + "IPMultiIOPCB", + "IPWHS2", ], DISCOVER_COVER: [ "Blind", @@ -214,6 +229,10 @@ "OPERATING_VOLTAGE": ["voltage", {}], "WORKING": ["working", {0: "No", 1: "Yes"}], "STATE_UNCERTAIN": ["state_uncertain", {}], + "SENDERID": ["last_senderid", {}], + "SENDERADDRESS": ["last_senderaddress", {}], + "ERROR_ALARM_TEST": ["error_alarm_test", {0: "No", 1: "Yes"}], + "ERROR_SMOKE_CHAMBER": ["error_smoke_chamber", {0: "No", 1: "Yes"}], } HM_PRESS_EVENTS = [ diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index e9f2943b53b58..b92c3e5b4d7f5 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -2,7 +2,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DEVICE_CLASS_GARAGE, + CoverDeviceClass, CoverEntity, ) @@ -112,6 +112,8 @@ def stop_cover_tilt(self, **kwargs): class HMGarage(HMCover): """Represents a Homematic Garage cover. Homematic garage covers do not support position attributes.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def current_cover_position(self): """ @@ -127,11 +129,6 @@ def is_closed(self): """Return whether the cover is closed.""" return self._hmdevice.is_closed(self._hm_get_state()) - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_GARAGE - def _init_data_struct(self): """Generate a data dictionary (self._data) from metadata.""" self._state = "DOOR_STATE" diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 50b9bcb2bfcd7..8e83484505b59 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -1,11 +1,16 @@ """Homematic base entity.""" +from __future__ import annotations + from abc import abstractmethod from datetime import timedelta import logging +from pyhomematic import HMConnection +from pyhomematic.devicetypes.generic import HMGeneric + from homeassistant.const import ATTR_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from .const import ( ATTR_ADDRESS, @@ -27,7 +32,14 @@ class HMDevice(Entity): """The HomeMatic device base object.""" - def __init__(self, config): + _homematic: HMConnection + _hmdevice: HMGeneric + + def __init__( + self, + config: dict[str, str], + entity_description: EntityDescription | None = None, + ) -> None: """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) @@ -35,12 +47,13 @@ def __init__(self, config): self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._unique_id = config.get(ATTR_UNIQUE_ID) - self._data = {} - self._homematic = None - self._hmdevice = None + self._data: dict[str, str] = {} self._connected = False self._available = False - self._channel_map = set() + self._channel_map: set[str] = set() + + if entity_description is not None: + self.entity_description = entity_description # Set parameter to uppercase if self._state: @@ -238,7 +251,7 @@ def extra_state_attributes(self): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return "mdi:gradient" + return "mdi:gradient-vertical" def _update_hub(self, now): """Retrieve latest state.""" diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index ce192bc38084a..896470f5a42f2 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,7 +2,7 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.72"], + "requirements": ["pyhomematic==0.1.76"], "codeowners": ["@pvizeli", "@danielperna84"], "iot_class": "local_push" } diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 964ba15cd0abb..3585510beb7d7 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -1,13 +1,21 @@ """Support for HomeMatic sensors.""" +from __future__ import annotations + +from copy import copy import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import ( + ATTR_NAME, + CONCENTRATION_PARTS_PER_MILLION, DEGREE, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_MILLIMETERS, @@ -17,11 +25,10 @@ PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - VOLT, VOLUME_CUBIC_METERS, ) -from .const import ATTR_DISCOVER_DEVICES +from .const import ATTR_DISCOVER_DEVICES, ATTR_PARAM from .entity import HMDevice _LOGGER = logging.getLogger(__name__) @@ -39,52 +46,177 @@ 2: "allsens_armed", 3: "alarm_blocked", }, + "IPLockDLD": {0: None, 1: "locked", 2: "unlocked"}, } -HM_UNIT_HA_CAST = { - "HUMIDITY": PERCENTAGE, - "TEMPERATURE": TEMP_CELSIUS, - "ACTUAL_TEMPERATURE": TEMP_CELSIUS, - "BRIGHTNESS": "#", - "POWER": POWER_WATT, - "CURRENT": "mA", - "VOLTAGE": VOLT, - "ENERGY_COUNTER": ENERGY_WATT_HOUR, - "GAS_POWER": VOLUME_CUBIC_METERS, - "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, - "LUX": LIGHT_LUX, - "ILLUMINATION": LIGHT_LUX, - "CURRENT_ILLUMINATION": LIGHT_LUX, - "AVERAGE_ILLUMINATION": LIGHT_LUX, - "LOWEST_ILLUMINATION": LIGHT_LUX, - "HIGHEST_ILLUMINATION": LIGHT_LUX, - "RAIN_COUNTER": LENGTH_MILLIMETERS, - "WIND_SPEED": SPEED_KILOMETERS_PER_HOUR, - "WIND_DIRECTION": DEGREE, - "WIND_DIRECTION_RANGE": DEGREE, - "SUNSHINEDURATION": "#", - "AIR_PRESSURE": PRESSURE_HPA, - "FREQUENCY": FREQUENCY_HERTZ, - "VALUE": "#", - "VALVE_STATE": PERCENTAGE, - "CARRIER_SENSE_LEVEL": PERCENTAGE, - "DUTY_CYCLE_LEVEL": PERCENTAGE, -} -HM_DEVICE_CLASS_HA_CAST = { - "HUMIDITY": DEVICE_CLASS_HUMIDITY, - "TEMPERATURE": DEVICE_CLASS_TEMPERATURE, - "ACTUAL_TEMPERATURE": DEVICE_CLASS_TEMPERATURE, - "LUX": DEVICE_CLASS_ILLUMINANCE, - "CURRENT_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "AVERAGE_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "LOWEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "HIGHEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "POWER": DEVICE_CLASS_POWER, - "CURRENT": DEVICE_CLASS_POWER, +SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { + "HUMIDITY": SensorEntityDescription( + key="HUMIDITY", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + "ACTUAL_TEMPERATURE": SensorEntityDescription( + key="ACTUAL_TEMPERATURE", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + "TEMPERATURE": SensorEntityDescription( + key="TEMPERATURE", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + "LUX": SensorEntityDescription( + key="LUX", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "CURRENT_ILLUMINATION": SensorEntityDescription( + key="CURRENT_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "ILLUMINATION": SensorEntityDescription( + key="ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "AVERAGE_ILLUMINATION": SensorEntityDescription( + key="AVERAGE_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "LOWEST_ILLUMINATION": SensorEntityDescription( + key="LOWEST_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "HIGHEST_ILLUMINATION": SensorEntityDescription( + key="HIGHEST_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "POWER": SensorEntityDescription( + key="POWER", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "IEC_POWER": SensorEntityDescription( + key="IEC_POWER", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "CURRENT": SensorEntityDescription( + key="CURRENT", + native_unit_of_measurement=ELECTRIC_CURRENT_MILLIAMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "CONCENTRATION": SensorEntityDescription( + key="CONCENTRATION", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + "ENERGY_COUNTER": SensorEntityDescription( + key="ENERGY_COUNTER", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "IEC_ENERGY_COUNTER": SensorEntityDescription( + key="IEC_ENERGY_COUNTER", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "VOLTAGE": SensorEntityDescription( + key="VOLTAGE", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "GAS_POWER": SensorEntityDescription( + key="GAS_POWER", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.MEASUREMENT, + ), + "GAS_ENERGY_COUNTER": SensorEntityDescription( + key="GAS_ENERGY_COUNTER", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "RAIN_COUNTER": SensorEntityDescription( + key="RAIN_COUNTER", + native_unit_of_measurement=LENGTH_MILLIMETERS, + ), + "WIND_SPEED": SensorEntityDescription( + key="WIND_SPEED", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + "WIND_DIRECTION": SensorEntityDescription( + key="WIND_DIRECTION", + native_unit_of_measurement=DEGREE, + ), + "WIND_DIRECTION_RANGE": SensorEntityDescription( + key="WIND_DIRECTION_RANGE", + native_unit_of_measurement=DEGREE, + ), + "SUNSHINEDURATION": SensorEntityDescription( + key="SUNSHINEDURATION", + native_unit_of_measurement="#", + ), + "AIR_PRESSURE": SensorEntityDescription( + key="AIR_PRESSURE", + native_unit_of_measurement=PRESSURE_HPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + "FREQUENCY": SensorEntityDescription( + key="FREQUENCY", + native_unit_of_measurement=FREQUENCY_HERTZ, + ), + "VALUE": SensorEntityDescription( + key="VALUE", + native_unit_of_measurement="#", + ), + "VALVE_STATE": SensorEntityDescription( + key="VALVE_STATE", + native_unit_of_measurement=PERCENTAGE, + ), + "CARRIER_SENSE_LEVEL": SensorEntityDescription( + key="CARRIER_SENSE_LEVEL", + native_unit_of_measurement=PERCENTAGE, + ), + "DUTY_CYCLE_LEVEL": SensorEntityDescription( + key="DUTY_CYCLE_LEVEL", + native_unit_of_measurement=PERCENTAGE, + ), + "BRIGHTNESS": SensorEntityDescription( + key="BRIGHTNESS", + native_unit_of_measurement="#", + icon="mdi:invert-colors", + ), } -HM_ICON_HA_CAST = {"WIND_SPEED": "mdi:weather-windy", "BRIGHTNESS": "mdi:invert-colors"} +DEFAULT_SENSOR_DESCRIPTION = SensorEntityDescription( + key="", + entity_registry_enabled_default=True, +) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -94,7 +226,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(conf) + state = conf.get(ATTR_PARAM) + entity_desc = SENSOR_DESCRIPTIONS.get(state) + if entity_desc is None: + name = conf.get(ATTR_NAME) + _LOGGER.warning( + "Sensor (%s) entity description is missing. Sensor state (%s) needs to be maintained", + name, + state, + ) + entity_desc = copy(DEFAULT_SENSOR_DESCRIPTION) + + new_device = HMSensor(conf, entity_desc) devices.append(new_device) add_entities(devices, True) @@ -104,7 +247,7 @@ class HMSensor(HMDevice, SensorEntity): """Representation of a HomeMatic sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" # Does a cast exist for this class? name = self._hmdevice.__class__.__name__ @@ -114,21 +257,6 @@ def state(self): # No cast, return original value return self._hm_get_state() - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return HM_UNIT_HA_CAST.get(self._state) - - @property - def device_class(self): - """Return the device class to use in the frontend, if any.""" - return HM_DEVICE_CLASS_HA_CAST.get(self._state) - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return HM_ICON_HA_CAST.get(self._state) - def _init_data_struct(self): """Generate a data dictionary (self._data) from metadata.""" if self._state: diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml index 2dc850330f0e2..15099c790fbf8 100644 --- a/homeassistant/components/homematic/services.yaml +++ b/homeassistant/components/homematic/services.yaml @@ -1,87 +1,188 @@ # Describes the format for available component services virtualkey: + name: Virtual key description: Press a virtual key from CCU/Homegear or simulate keypress. fields: address: + name: Address description: Address of homematic device or BidCoS-RF for virtual remote. + required: true example: BidCoS-RF + selector: + text: channel: + name: Channel description: Channel for calling a keypress. - example: 1 + required: true + selector: + number: + min: 1 + max: 6 param: + name: Param description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + required: true example: PRESS_LONG + selector: + text: interface: - description: (Optional) for set an interface value. + name: Interface + description: Set an interface value. example: Interfaces name from config + selector: + text: set_variable_value: + name: Set variable value description: Set the name of a node. fields: entity_id: + name: Entity description: Name(s) of homematic central to set value. - example: "homematic.ccu2" + selector: + entity: + domain: homematic name: + name: Name description: Name of the variable to set. + required: true example: "testvariable" + selector: + text: value: + name: Value description: New value + required: true example: 1 + selector: + text: set_device_value: + name: Set device value description: Set a device property on RPC XML interface. fields: address: + name: Address description: Address of homematic device or BidCoS-RF for virtual remote + required: true example: BidCoS-RF + selector: + text: channel: + name: Channel description: Channel for calling a keypress - example: 1 + required: true + selector: + number: + min: 1 + max: 6 param: + name: Param description: Event to send i.e. PRESS_LONG, PRESS_SHORT + required: true example: PRESS_LONG + selector: + text: interface: - description: (Optional) for set an interface value + name: Interface + description: Set an interface value example: Interfaces name from config + selector: + text: value: + name: Value description: New value + required: true example: 1 + selector: + text: + value_type: + name: Value type + description: Type for new value + selector: + select: + options: + - 'boolean' + - 'dateTime.iso8601' + - 'double' + - 'int' + - 'string' reconnect: + name: Reconnect description: Reconnect to all Homematic Hubs. set_install_mode: + name: Set install mode description: Set a RPC XML interface into installation mode. fields: interface: + name: Interface description: Select the given interface into install mode + required: true example: Interfaces name from config + selector: + text: mode: - description: (Default 1) 1= Normal mode / 2= Remove exists old links - example: 1 + name: Mode + description: 1= Normal mode / 2= Remove exists old links + default: 1 + selector: + number: + min: 1 + max: 2 time: - description: (Default 60) Time in seconds to run in install mode - example: 1 + name: Time + description: Time to run in install mode + default: 60 + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds address: - description: (Optional) Address of homematic device or BidCoS-RF to learn + name: Address + description: Address of homematic device or BidCoS-RF to learn example: LEQ3948571 + selector: + text: put_paramset: + name: Put paramset description: Call to putParamset in the RPC XML interface fields: interface: + name: Interface description: The interfaces name from the config + required: true example: wireless + selector: + text: address: + name: Address description: Address of Homematic device + required: true example: LEQ3948571:0 + selector: + text: paramset_key: + name: Paramset key description: The paramset_key argument to putParamset + required: true example: MASTER + selector: + text: paramset: + name: Paramset description: A paramset dictionary + required: true example: '{"WEEK_PROGRAM_POINTER": 1}' + selector: + object: rx_mode: + name: RX mode description: The receive mode used. example: BURST + selector: + text: diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 00604bbc8a6c1..2f7d8d86012ad 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -4,7 +4,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import async_entries_for_config_entry @@ -52,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entry.data[HMIPC_HAPID] for entry in hass.config_entries.async_entries(DOMAIN) }: - hass.async_add_job( + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -85,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False await async_setup_services(hass) - await async_remove_obsolete_entities(hass, entry, hap) + _async_remove_obsolete_entities(hass, entry, hap) # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection hap.reset_connection_listener = hass.bus.async_listen_once( @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Register hap as device in registry. - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) home = hap.home hapname = home.label if home.label != entry.unique_id else f"Home-{home.label}" @@ -118,7 +118,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hap.async_reset() -async def async_remove_obsolete_entities( +@callback +def _async_remove_obsolete_entities( hass: HomeAssistant, entry: ConfigEntry, hap: HomematicipHAP ): """Remove obsolete entities from entity registry.""" @@ -126,7 +127,7 @@ async def async_remove_obsolete_entities( if hap.home.currentAPVersion < "2.2.12": return - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) er_entries = async_entries_for_config_entry(entity_registry, entry.entry_id) for er_entry in er_entries: if er_entry.unique_id.startswith("HomematicipAccesspointStatus"): diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 87a8056b4b600..d2dee3c3744ea 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN as HMIPC_DOMAIN -from .hap import HomematicipHAP +from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -41,19 +41,19 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" - self._home = hap.home + self._home: AsyncHome = hap.home _LOGGER.info("Setting up %s", self.name) @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "identifiers": {(HMIPC_DOMAIN, f"ACP {self._home.id}")}, - "name": self.name, - "manufacturer": "eQ-3", - "model": CONST_ALARM_CONTROL_PANEL_NAME, - "via_device": (HMIPC_DOMAIN, self._home.id), - } + return DeviceInfo( + identifiers={(HMIPC_DOMAIN, f"ACP {self._home.id}")}, + manufacturer="eQ-3", + model=CONST_ALARM_CONTROL_PANEL_NAME, + name=self.name, + via_device=(HMIPC_DOMAIN, self._home.id), + ) @property def state(self) -> str: diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 673dd6e9ea36d..0d00d5139b3f0 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -30,17 +30,7 @@ from homematicip.base.enums import SmokeDetectorAlarmType, WindowState from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_LIGHT, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_MOVING, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESENCE, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -85,7 +75,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [HomematicipCloudConnectionSensor(hap)] + entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: if isinstance(device, AsyncAccelerationSensor): entities.append(HomematicipAccelerationSensor(hap, device)) @@ -172,12 +162,12 @@ def name(self) -> str: def device_info(self) -> DeviceInfo: """Return device specific attributes.""" # Adds a sensor to the existing HAP device - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers of Homematic IP device (HMIPC_DOMAIN, self._home.id) } - } + ) @property def icon(self) -> str: @@ -205,7 +195,7 @@ class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity): @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_MOVING + return BinarySensorDeviceClass.MOVING @property def is_on(self) -> bool: @@ -218,8 +208,7 @@ def extra_state_attributes(self) -> dict[str, Any]: state_attr = super().extra_state_attributes for attr, attr_key in SAM_DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value return state_attr @@ -251,10 +240,10 @@ def __init__( @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_OPENING + return BinarySensorDeviceClass.OPENING @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" if self._device.functionalChannels[self._channel].windowState is None: return None @@ -285,7 +274,7 @@ def __init__( @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_DOOR + return BinarySensorDeviceClass.DOOR @property def extra_state_attributes(self) -> dict[str, Any]: @@ -306,7 +295,7 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_MOTION + return BinarySensorDeviceClass.MOTION @property def is_on(self) -> bool: @@ -320,7 +309,7 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_PRESENCE + return BinarySensorDeviceClass.PRESENCE @property def is_on(self) -> bool: @@ -334,7 +323,7 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_SMOKE + return BinarySensorDeviceClass.SMOKE @property def is_on(self) -> bool: @@ -353,7 +342,7 @@ class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_MOISTURE + return BinarySensorDeviceClass.MOISTURE @property def is_on(self) -> bool: @@ -389,7 +378,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_MOISTURE + return BinarySensorDeviceClass.MOISTURE @property def is_on(self) -> bool: @@ -407,7 +396,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_LIGHT + return BinarySensorDeviceClass.LIGHT @property def is_on(self) -> bool: @@ -436,7 +425,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_BATTERY + return BinarySensorDeviceClass.BATTERY @property def is_on(self) -> bool: @@ -456,7 +445,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_POWER + return BinarySensorDeviceClass.POWER @property def is_on(self) -> bool: @@ -475,7 +464,7 @@ def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> N @property def device_class(self) -> str: """Return the class of this sensor.""" - return DEVICE_CLASS_SAFETY + return BinarySensorDeviceClass.SAFETY @property def available(self) -> bool: @@ -490,8 +479,7 @@ def extra_state_attributes(self) -> dict[str, Any]: state_attr = super().extra_state_attributes for attr, attr_key in GROUP_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value window_state = getattr(self._device, "windowState", None) @@ -544,8 +532,8 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def is_on(self) -> bool: """Return true if safety issue detected.""" - parent_is_on = super().is_on - if parent_is_on: + if super().is_on: + # parent is on return True if ( diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 7ba90e0a9e4bc..ed91559e489e5 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -76,13 +76,13 @@ def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "identifiers": {(HMIPC_DOMAIN, self._device.id)}, - "name": self._device.label, - "manufacturer": "eQ-3", - "model": self._device.modelType, - "via_device": (HMIPC_DOMAIN, self._device.homeId), - } + return DeviceInfo( + identifiers={(HMIPC_DOMAIN, self._device.id)}, + manufacturer="eQ-3", + model=self._device.modelType, + name=self._device.label, + via_device=(HMIPC_DOMAIN, self._device.homeId), + ) @property def temperature_unit(self) -> str: @@ -207,8 +207,7 @@ def max_temp(self) -> float: async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return if self.min_temp <= temperature <= self.max_temp: @@ -262,7 +261,7 @@ def _indoor_climate(self) -> IndoorClimateHome: return self._home.get_functionalHome(IndoorClimateHome) @property - def _device_profiles(self) -> list[str]: + def _device_profiles(self) -> list[Any]: """Return the relevant profiles.""" return [ profile @@ -301,10 +300,10 @@ def _disabled_by_cooling_mode(self) -> bool: ) @property - def _relevant_profile_group(self) -> list[str]: + def _relevant_profile_group(self) -> dict[str, int]: """Return the relevant profile groups.""" if self._disabled_by_cooling_mode: - return [] + return {} return HEATING_PROFILES if self._heat_mode_enabled else COOLING_PROFILES diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 2baa99068ce82..6cf6335b874c6 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -23,9 +23,10 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): VERSION = 1 + auth: HomematicipAuth + def __init__(self) -> None: """Initialize HomematicIP Cloud config flow.""" - self.auth = None async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initialized by the user.""" diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 31ccb8b9bc724..a0f1c84015f7c 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,30 +1,21 @@ """Constants for the HomematicIP Cloud component.""" import logging -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.const import Platform _LOGGER = logging.getLogger(".") DOMAIN = "homematicip_cloud" PLATFORMS = [ - ALARM_CONTROL_PANEL_DOMAIN, - BINARY_SENSOR_DOMAIN, - CLIMATE_DOMAIN, - COVER_DOMAIN, - LIGHT_DOMAIN, - SENSOR_DOMAIN, - SWITCH_DOMAIN, - WEATHER_DOMAIN, + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + Platform.WEATHER, ] CONF_ACCESSPOINT = "accesspoint" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 2d3e1ea518ccc..937e36a15fc1f 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -15,6 +15,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, + CoverDeviceClass, CoverEntity, ) from homeassistant.config_entries import ConfigEntry @@ -34,7 +35,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP cover from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBlindModule): entities.append(HomematicipBlindModule(hap, device)) @@ -64,14 +65,19 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP blind module.""" @property - def current_cover_position(self) -> int: + def device_class(self) -> str: + """Return the class of the cover.""" + return CoverDeviceClass.BLIND + + @property + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.primaryShadingLevel is not None: return int((1 - self._device.primaryShadingLevel) * 100) return None @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.secondaryShadingLevel is not None: return int((1 - self._device.secondaryShadingLevel) * 100) @@ -152,7 +158,12 @@ def __init__( ) @property - def current_cover_position(self) -> int: + def device_class(self) -> str: + """Return the class of the cover.""" + return CoverDeviceClass.SHUTTER + + @property + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.functionalChannels[self._channel].shutterLevel is not None: return int( @@ -214,7 +225,7 @@ def __init__( ) @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.functionalChannels[self._channel].slatsLevel is not None: return int( @@ -254,7 +265,7 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP Garage Door Module.""" @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" door_state_to_position = { DoorState.CLOSED: 0, @@ -264,6 +275,11 @@ def current_cover_position(self) -> int: } return door_state_to_position.get(self._device.doorState) + @property + def device_class(self) -> str: + """Return the class of the cover.""" + return CoverDeviceClass.GARAGE + @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" @@ -291,14 +307,19 @@ def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> N super().__init__(hap, device, post, is_multi_channel=False) @property - def current_cover_position(self) -> int: + def device_class(self) -> str: + """Return the class of the cover.""" + return CoverDeviceClass.SHUTTER + + @property + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.shutterLevel is not None: return int((1 - self._device.shutterLevel) * 100) return None @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.slatsLevel is not None: return int((1 - self._device.slatsLevel) * 100) diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index b9dd46d49d723..8bcea5d14351c 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN as HMIPC_DOMAIN -from .hap import HomematicipHAP +from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,7 @@ def __init__( ) -> None: """Initialize the generic entity.""" self._hap = hap - self._home = hap.home + self._home: AsyncHome = hap.home self._device = device self._post = post self._channel = channel @@ -92,22 +92,22 @@ def __init__( _LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType) @property - def device_info(self) -> DeviceInfo: + def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" # Only physical devices should be HA devices. if isinstance(self._device, AsyncDevice): - return { - "identifiers": { + return DeviceInfo( + identifiers={ # Serial numbers of Homematic IP device (HMIPC_DOMAIN, self._device.id) }, - "name": self._device.label, - "manufacturer": self._device.oem, - "model": self._device.modelType, - "sw_version": self._device.firmwareVersion, + manufacturer=self._device.oem, + model=self._device.modelType, + name=self._device.label, + sw_version=self._device.firmwareVersion, # Link to the homematic ip access point. - "via_device": (HMIPC_DOMAIN, self._device.homeId), - } + via_device=(HMIPC_DOMAIN, self._device.homeId), + ) return None async def async_added_to_hass(self) -> None: @@ -139,13 +139,13 @@ async def async_will_remove_from_hass(self) -> None: if self.hmip_device_removed: try: del self._hap.hmip_device_by_entity_id[self.entity_id] - await self.async_remove_from_registries() + self.async_remove_from_registries() except KeyError as err: _LOGGER.debug("Error removing HMIP device from registry: %s", err) - async def async_remove_from_registries(self) -> None: + @callback + def async_remove_from_registries(self) -> None: """Remove entity/device from registry.""" - # Remove callback from device. self._device.remove_callback(self._async_device_changed) self._device.remove_callback(self._async_device_removed) @@ -153,19 +153,17 @@ async def async_remove_from_registries(self) -> None: if not self.registry_entry: return - device_id = self.registry_entry.device_id - if device_id: + if device_id := self.registry_entry.device_id: # Remove from device registry. - device_registry = await dr.async_get_registry(self.hass) + device_registry = dr.async_get(self.hass) if device_id in device_registry.devices: # This will also remove associated entities from entity registry. device_registry.async_remove_device(device_id) else: # Remove from entity registry. # Only relevant for entities that do not belong to a device. - entity_id = self.registry_entry.entity_id - if entity_id: - entity_registry = await er.async_get_registry(self.hass) + if entity_id := self.registry_entry.entity_id: + entity_registry = er.async_get(self.hass) if entity_id in entity_registry.entities: entity_registry.async_remove(entity_id) @@ -240,16 +238,14 @@ def extra_state_attributes(self) -> dict[str, Any]: if isinstance(self._device, AsyncDevice): for attr, attr_key in DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = False if isinstance(self._device, AsyncGroup): for attr, attr_key in GROUP_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = True diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index ad641c0f46de2..a3537bff31b0d 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,6 +1,10 @@ """Access point for the HomematicIP Cloud component.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable import logging +from typing import Any from homematicip.aio.auth import AsyncAuth from homematicip.aio.home import AsyncHome @@ -21,11 +25,12 @@ class HomematicipAuth: """Manages HomematicIP client registration.""" + auth: AsyncAuth + def __init__(self, hass, config) -> None: """Initialize HomematicIP Cloud client registration.""" self.hass = hass self.config = config - self.auth = None async def async_setup(self) -> bool: """Connect to HomematicIP for registration.""" @@ -55,6 +60,7 @@ async def async_register(self): async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" + # pylint: disable=no-self-use auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) try: await auth.init(hapid) @@ -69,18 +75,19 @@ async def get_auth(self, hass: HomeAssistant, hapid, pin): class HomematicipHAP: """Manages HomematicIP HTTP and WebSocket connection.""" + home: AsyncHome + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry - self.home = None self._ws_close_requested = False - self._retry_task = None + self._retry_task: asyncio.Task | None = None self._tries = 0 self._accesspoint_connected = True - self.hmip_device_by_entity_id = {} - self.reset_connection_listener = None + self.hmip_device_by_entity_id: dict[str, Any] = {} + self.reset_connection_listener: Callable | None = None async def async_setup(self, tries: int = 0) -> bool: """Initialize connection.""" @@ -228,7 +235,11 @@ def shutdown(self, event) -> None: ) async def get_hap( - self, hass: HomeAssistant, hapid: str, authtoken: str, name: str + self, + hass: HomeAssistant, + hapid: str | None, + authtoken: str | None, + name: str | None, ) -> AsyncHome: """Create a HomematicIP access point object.""" home = AsyncHome(hass.loop, async_get_clientsession(hass)) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index a2f2a6aea53ac..52ca9de2fe46f 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -40,7 +40,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): entities.append(HomematicipLightMeasuring(hap, device)) @@ -174,14 +174,14 @@ def __init__(self, hap: HomematicipHAP, device, channel: int) -> None: hap, device, post="Bottom", channel=channel, is_multi_channel=True ) - self._color_switcher = { - RGBColorState.WHITE: [0.0, 0.0], - RGBColorState.RED: [0.0, 100.0], - RGBColorState.YELLOW: [60.0, 100.0], - RGBColorState.GREEN: [120.0, 100.0], - RGBColorState.TURQUOISE: [180.0, 100.0], - RGBColorState.BLUE: [240.0, 100.0], - RGBColorState.PURPLE: [300.0, 100.0], + self._color_switcher: dict[str, tuple[float, float]] = { + RGBColorState.WHITE: (0.0, 0.0), + RGBColorState.RED: (0.0, 100.0), + RGBColorState.YELLOW: (60.0, 100.0), + RGBColorState.GREEN: (120.0, 100.0), + RGBColorState.TURQUOISE: (180.0, 100.0), + RGBColorState.BLUE: (240.0, 100.0), + RGBColorState.PURPLE: (300.0, 100.0), } @property @@ -202,10 +202,10 @@ def brightness(self) -> int: return int((self._func_channel.dimLevel or 0.0) * 255) @property - def hs_color(self) -> tuple: + def hs_color(self) -> tuple[float, float]: """Return the hue and saturation color value [float, float].""" simple_rgb_color = self._func_channel.simpleRGBColorState - return self._color_switcher.get(simple_rgb_color, [0.0, 0.0]) + return self._color_switcher.get(simple_rgb_color, (0.0, 0.0)) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index f82e2c199962c..b41c7b06c74da 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -3,7 +3,7 @@ "name": "HomematicIP Cloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", - "requirements": ["homematicip==0.13.1"], + "requirements": ["homematicip==1.0.1"], "codeowners": [], "quality_scale": "platinum", "iot_class": "cloud_push" diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 475df8ec2afa3..323d462dbf8b1 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -26,13 +26,14 @@ ) from homematicip.base.enums import ValveState -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_MILLIMETERS, LIGHT_LUX, PERCENTAGE, @@ -66,7 +67,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncHomeControlAccessPoint): entities.append(HomematicipAccesspointDutyCycle(hap, device)) @@ -111,6 +112,7 @@ async def async_setup_entry( ), ): entities.append(HomematicipPowerSensor(hap, device)) + entities.append(HomematicipEnergySensor(hap, device)) if isinstance( device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) ): @@ -127,6 +129,8 @@ async def async_setup_entry( class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): """Representation of then HomeMaticIP access point.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize access point status entity.""" super().__init__(hap, device, post="Duty Cycle") @@ -137,12 +141,12 @@ def icon(self) -> str: return "mdi:access-point-network" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the access point.""" return self._device.dutyCycleLevel @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -155,7 +159,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: super().__init__(hap, device, post="Heating") @property - def icon(self) -> str: + def icon(self) -> str | None: """Return the icon.""" if super().icon: return super().icon @@ -164,14 +168,14 @@ def icon(self) -> str: return "mdi:radiator" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the radiator valve.""" if self._device.valveState != ValveState.ADAPTION_DONE: return self._device.valveState return round(self._device.valvePosition * 100) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -179,6 +183,8 @@ def unit_of_measurement(self) -> str: class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP humidity sensor.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" super().__init__(hap, device, post="Humidity") @@ -186,15 +192,15 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the device class of the sensor.""" - return DEVICE_CLASS_HUMIDITY + return SensorDeviceClass.HUMIDITY @property - def state(self) -> int: + def native_value(self) -> int: """Return the state.""" return self._device.humidity @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -202,6 +208,8 @@ def unit_of_measurement(self) -> str: class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP thermometer.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" super().__init__(hap, device, post="Temperature") @@ -209,10 +217,10 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the device class of the sensor.""" - return DEVICE_CLASS_TEMPERATURE + return SensorDeviceClass.TEMPERATURE @property - def state(self) -> float: + def native_value(self) -> float: """Return the state.""" if hasattr(self._device, "valveActualTemperature"): return self._device.valveActualTemperature @@ -220,7 +228,7 @@ def state(self) -> float: return self._device.actualTemperature @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TEMP_CELSIUS @@ -239,6 +247,8 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP Illuminance sensor.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" super().__init__(hap, device, post="Illuminance") @@ -246,10 +256,10 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the device class of the sensor.""" - return DEVICE_CLASS_ILLUMINANCE + return SensorDeviceClass.ILLUMINANCE @property - def state(self) -> float: + def native_value(self) -> float: """Return the state.""" if hasattr(self._device, "averageIllumination"): return self._device.averageIllumination @@ -257,7 +267,7 @@ def state(self) -> float: return self._device.illumination @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return LIGHT_LUX @@ -267,8 +277,7 @@ def extra_state_attributes(self) -> dict[str, Any]: state_attr = super().extra_state_attributes for attr, attr_key in ILLUMINATION_DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value return state_attr @@ -277,6 +286,8 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP power measuring sensor.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" super().__init__(hap, device, post="Power") @@ -284,19 +295,44 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def device_class(self) -> str: """Return the device class of the sensor.""" - return DEVICE_CLASS_POWER + return SensorDeviceClass.POWER @property - def state(self) -> float: + def native_value(self) -> float: """Return the power consumption value.""" return self._device.currentPowerConsumption @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return POWER_WATT +class HomematicipEnergySensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP energy measuring sensor.""" + + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the device.""" + super().__init__(hap, device, post="Energy") + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return SensorDeviceClass.ENERGY + + @property + def native_value(self) -> float: + """Return the energy counter value.""" + return self._device.energyCounter + + @property + def native_unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return ENERGY_KILO_WATT_HOUR + + class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP wind speed sensor.""" @@ -305,12 +341,12 @@ def __init__(self, hap: HomematicipHAP, device) -> None: super().__init__(hap, device, post="Windspeed") @property - def state(self) -> float: + def native_value(self) -> float: """Return the wind speed value.""" return self._device.windSpeed @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return SPEED_KILOMETERS_PER_HOUR @@ -338,12 +374,12 @@ def __init__(self, hap: HomematicipHAP, device) -> None: super().__init__(hap, device, post="Today Rain") @property - def state(self) -> float: + def native_value(self) -> float: """Return the today's rain value.""" return round(self._device.todayRainCounter, 2) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return LENGTH_MILLIMETERS @@ -352,7 +388,7 @@ class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEnt """Representation of the HomematicIP passage detector delta counter.""" @property - def state(self) -> int: + def native_value(self) -> int: """Return the passage detector delta counter value.""" return self._device.leftRightCounterDelta diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index bafe7599f06b1..88c14c648d8ff 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -208,11 +208,9 @@ async def _async_activate_eco_mode_with_duration( ) -> None: """Service to activate eco mode with duration.""" duration = service.data[ATTR_DURATION] - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - if hapid: - home = _get_home(hass, hapid) - if home: + if hapid := service.data.get(ATTR_ACCESSPOINT_ID): + if home := _get_home(hass, hapid): await home.activate_absence_with_duration(duration) else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -224,11 +222,9 @@ async def _async_activate_eco_mode_with_period( ) -> None: """Service to activate eco mode with period.""" endtime = service.data[ATTR_ENDTIME] - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - if hapid: - home = _get_home(hass, hapid) - if home: + if hapid := service.data.get(ATTR_ACCESSPOINT_ID): + if home := _get_home(hass, hapid): await home.activate_absence_with_period(endtime) else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -239,11 +235,9 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> """Service to activate vacation.""" endtime = service.data[ATTR_ENDTIME] temperature = service.data[ATTR_TEMPERATURE] - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - if hapid: - home = _get_home(hass, hapid) - if home: + if hapid := service.data.get(ATTR_ACCESSPOINT_ID): + if home := _get_home(hass, hapid): await home.activate_vacation(endtime, temperature) else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -252,11 +246,8 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate eco mode.""" - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hass, hapid) - if home: + if hapid := service.data.get(ATTR_ACCESSPOINT_ID): + if home := _get_home(hass, hapid): await home.deactivate_absence() else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -265,11 +256,8 @@ async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate vacation.""" - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hass, hapid) - if home: + if hapid := service.data.get(ATTR_ACCESSPOINT_ID): + if home := _get_home(hass, hapid): await home.deactivate_vacation() else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -297,7 +285,9 @@ async def _set_active_climate_profile( async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: """Service to dump the configuration of a Homematic IP Access Point.""" - config_path = service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + config_path: str = ( + service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir or "." + ) config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] @@ -335,8 +325,7 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall) def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None: """Return a HmIP home.""" - hap = hass.data[HMIPC_DOMAIN].get(hapid) - if hap: + if hap := hass.data[HMIPC_DOMAIN].get(hapid): return hap.home _LOGGER.info("No matching access point found for access point id %s", hapid) diff --git a/homeassistant/components/homematicip_cloud/services.yaml b/homeassistant/components/homematicip_cloud/services.yaml index 20447e496f700..ae8a6f3404912 100644 --- a/homeassistant/components/homematicip_cloud/services.yaml +++ b/homeassistant/components/homematicip_cloud/services.yaml @@ -1,78 +1,146 @@ # Describes the format for available component services activate_eco_mode_with_duration: + name: Activate eco mode with duration description: Activate eco mode with period. fields: duration: + name: Duration description: The duration of eco mode in minutes. - example: 60 + required: true + selector: + number: + min: 1 + max: 1440 + unit_of_measurement: "minutes" accesspoint_id: - description: The ID of the Homematic IP Access Point (optional) + name: Accesspoint ID + description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx + selector: + text: activate_eco_mode_with_period: + name: Activate eco more with period description: Activate eco mode with period. fields: endtime: + name: Endtime description: The time when the eco mode should automatically be disabled. + required: true example: 2019-02-17 14:00 + selector: + text: accesspoint_id: - description: The ID of the Homematic IP Access Point (optional) + name: Accesspoint ID + description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx + selector: + text: activate_vacation: + name: Activate vacation description: Activates the vacation mode until the given time. fields: endtime: + name: Endtime description: The time when the vacation mode should automatically be disabled. + required: true example: 2019-09-17 14:00 + selector: + text: temperature: + name: Temperature description: the set temperature during the vacation mode. - example: 18.5 + required: true + default: 18 + selector: + number: + min: 0 + max: 55 + step: 0.5 + unit_of_measurement: '°' accesspoint_id: - description: The ID of the Homematic IP Access Point (optional) + name: Accesspoint ID + description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx + selector: + text: deactivate_eco_mode: + name: Deactivate eco mode description: Deactivates the eco mode immediately. fields: accesspoint_id: - description: The ID of the Homematic IP Access Point (optional) + name: Accesspoint ID + description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx + selector: + text: deactivate_vacation: + name: Deactivate vacation description: Deactivates the vacation mode immediately. fields: accesspoint_id: - description: The ID of the Homematic IP Access Point (optional) + name: Accesspoint ID + description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx + selector: + text: set_active_climate_profile: + name: Set active climate profile description: Set the active climate profile index. fields: entity_id: - description: The ID of the climte entity. Use 'all' keyword to switch the profile for all entities. + name: Entity + description: The ID of the climate entity. Use 'all' keyword to switch the profile for all entities. + required: true example: climate.livingroom + selector: + text: climate_profile_index: - description: The index of the climate profile (1 based) - example: 1 + name: Climate profile index + description: The index of the climate profile. + required: true + selector: + number: + min: 1 + max: 100 dump_hap_config: + name: Dump hap config description: Dump the configuration of the Homematic IP Access Point(s). fields: config_output_path: + name: Config output path description: (Default is 'Your home-assistant config directory') Path where to store the config. example: "/config" + selector: + text: config_output_file_prefix: - description: (Default is 'hmip-config') Name of the config file. The SGTIN of the AP will always be appended. + name: Config output file prefix + description: Name of the config file. The SGTIN of the AP will always be appended. example: "hmip-config" + default: "hmip-config" + selector: + text: anonymize: - description: (Default is True) Should the Configuration be anonymized? - example: true + name: Anonymize + description: Should the Configuration be anonymized? + default: true + selector: + boolean: reset_energy_counter: + name: Reset energy counter description: Reset the energy counter of a measuring entity. fields: entity_id: + name: Entity description: The ID of the measuring entity. Use 'all' keyword to reset all energy counters. + required: true example: switch.livingroom + selector: + text: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 3ea52c9fb8990..90188fd03227a 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -34,7 +34,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP switch from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): # BrandSwitchMeasuring inherits PlugableSwitchMeasuring diff --git a/homeassistant/components/homematicip_cloud/translations/ar.json b/homeassistant/components/homematicip_cloud/translations/ar.json new file mode 100644 index 0000000000000..5685fa738c7b6 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/ar.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "register_failed": "\u0641\u0634\u0644 \u0627\u0644\u062a\u0633\u062c\u064a\u0644 \u060c \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/de.json b/homeassistant/components/homematicip_cloud/translations/de.json index 1da1e06c0fb8d..3cb74491c7fa7 100644 --- a/homeassistant/components/homematicip_cloud/translations/de.json +++ b/homeassistant/components/homematicip_cloud/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Der Accesspoint ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "connection_aborted": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler" }, diff --git a/homeassistant/components/homematicip_cloud/translations/fi.json b/homeassistant/components/homematicip_cloud/translations/fi.json index 9fcaacf4ba102..70e6fbe6b3c38 100644 --- a/homeassistant/components/homematicip_cloud/translations/fi.json +++ b/homeassistant/components/homematicip_cloud/translations/fi.json @@ -1,11 +1,27 @@ { "config": { "abort": { + "already_configured": "Laite on jo m\u00e4\u00e4ritetty", + "connection_aborted": "Yhdist\u00e4minen ep\u00e4onnistui", "unknown": "Tapahtui tuntematon virhe." }, "error": { "invalid_sgtin_or_pin": "Virheellinen PIN-koodi, yrit\u00e4 uudelleen.", - "press_the_button": "Paina sinist\u00e4 painiketta." + "press_the_button": "Paina sinist\u00e4 painiketta.", + "register_failed": "Rekister\u00f6inti ep\u00e4onnistui, yrit\u00e4 uudelleen.", + "timeout_button": "Sinisen painikkeen painalluksen aikakatkaisu, yrit\u00e4 uudelleen." + }, + "step": { + "init": { + "data": { + "pin": "PIN-koodi" + }, + "title": "Valitse HomematicIP-tukiasema" + }, + "link": { + "description": "Rekister\u00f6i HomematicIP Home Assistantiin painamalla tukiaseman sinist\u00e4 painiketta ja l\u00e4hetyspainiketta. \n\n ![Painikkeen sijainti sillalla](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Linkit\u00e4 tukiasema" + } } } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/fr.json b/homeassistant/components/homematicip_cloud/translations/fr.json index 212206bb298ed..106ff6225d547 100644 --- a/homeassistant/components/homematicip_cloud/translations/fr.json +++ b/homeassistant/components/homematicip_cloud/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", - "connection_aborted": "Impossible de se connecter au serveur HMIP", - "unknown": "Une erreur inconnue s'est produite." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "connection_aborted": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" }, "error": { "invalid_sgtin_or_pin": "Code SGTIN ou PIN invalide, veuillez r\u00e9essayer.", @@ -16,7 +16,7 @@ "data": { "hapid": "ID du point d'acc\u00e8s (SGTIN)", "name": "Nom (facultatif, utilis\u00e9 comme pr\u00e9fixe de nom pour tous les p\u00e9riph\u00e9riques)", - "pin": "Code PIN (facultatif)" + "pin": "Code PIN" }, "title": "Choisissez le point d'acc\u00e8s HomematicIP" }, diff --git a/homeassistant/components/homematicip_cloud/translations/he.json b/homeassistant/components/homematicip_cloud/translations/he.json index f07db79a1c5f3..8e6f13544b9c9 100644 --- a/homeassistant/components/homematicip_cloud/translations/he.json +++ b/homeassistant/components/homematicip_cloud/translations/he.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d2\u05d9\u05e9\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05ea", - "connection_aborted": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e8\u05ea HMIP", - "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "connection_aborted": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { - "invalid_sgtin_or_pin": "PIN \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "invalid_sgtin_or_pin": "SGTIN \u05d0\u05d5 \u05e7\u05d5\u05d3 PIN \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd, \u05e0\u05d0 \u05dc\u05e0\u05e1\u05d5\u05ea \u05e9\u05d5\u05d1.", "press_the_button": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc.", "register_failed": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", "timeout_button": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1" @@ -15,14 +15,14 @@ "init": { "data": { "hapid": "\u05de\u05d6\u05d4\u05d4 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 (SGTIN)", - "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9, \u05de\u05e9\u05de\u05e9 \u05db\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e2\u05d1\u05d5\u05e8 \u05db\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd)", - "pin": "\u05e7\u05d5\u05d3 PIN (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9, \u05de\u05e9\u05de\u05e9 \u05db\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e2\u05d1\u05d5\u05e8 \u05db\u05dc \u05d4\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd)", + "pin": "\u05e7\u05d5\u05d3 PIN" }, "title": "\u05d1\u05d7\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 HomematicIP" }, "link": { - "description": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc \u05d1\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d5\u05e2\u05dc \u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05e9\u05dc\u05d9\u05d7\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d7\u05d1\u05e8 \u05d0\u05ea HomematicIP \u05e2\u05ddHome Assistant.\n\n![\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d1\u05de\u05d2\u05e9\u05e8](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "\u05d7\u05d1\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4" + "description": "\u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc \u05d1\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d5\u05e2\u05dc \u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05e9\u05dc\u05d9\u05d7\u05d4 \u05db\u05d3\u05d9 \u05dc\u05e8\u05e9\u05d5\u05dd \u05d0\u05ea HomematIP \u05e2\u05dd Home Assistant.\n\n![\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d1\u05de\u05d2\u05e9\u05e8](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05dc\u05e7\u05d9\u05e9\u05d5\u05e8" } } } diff --git a/homeassistant/components/homematicip_cloud/translations/hu.json b/homeassistant/components/homematicip_cloud/translations/hu.json index eaa8d8834c36d..2915d442a373f 100644 --- a/homeassistant/components/homematicip_cloud/translations/hu.json +++ b/homeassistant/components/homematicip_cloud/translations/hu.json @@ -18,9 +18,10 @@ "name": "N\u00e9v (opcion\u00e1lis, minden eszk\u00f6z n\u00e9vel\u0151tagjak\u00e9nt haszn\u00e1latos)", "pin": "PIN-k\u00f3d" }, - "title": "V\u00e1lassz HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" + "title": "V\u00e1lasszon HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" }, "link": { + "description": "A HomematicIP regisztr\u00e1l\u00e1s\u00e1hoz a Home Assistant alkalmaz\u00e1sban nyomja meg a hozz\u00e1f\u00e9r\u00e9si pont k\u00e9k gombj\u00e1t \u00e9s a bek\u00fcld\u00e9s gombot. \n\n ! [A gomb helye a h\u00eddon] (/ static / images / config_flows / config_homematicip_cloud.png)", "title": "Link Hozz\u00e1f\u00e9r\u00e9si pont" } } diff --git a/homeassistant/components/homematicip_cloud/translations/it.json b/homeassistant/components/homematicip_cloud/translations/it.json index 80c766becf145..55c26532fb559 100644 --- a/homeassistant/components/homematicip_cloud/translations/it.json +++ b/homeassistant/components/homematicip_cloud/translations/it.json @@ -7,8 +7,8 @@ }, "error": { "invalid_sgtin_or_pin": "SGTIN o Codice PIN non valido, si prega di riprovare.", - "press_the_button": "Si prega di premere il pulsante blu.", - "register_failed": "Registrazione fallita, si prega di riprovare.", + "press_the_button": "Premi il pulsante blu.", + "register_failed": "Registrazione non riuscita, riprova.", "timeout_button": "Timeout della pressione del pulsante blu, riprovare." }, "step": { diff --git a/homeassistant/components/homematicip_cloud/translations/ja.json b/homeassistant/components/homematicip_cloud/translations/ja.json index b26b247a66cb3..cbaf3c122a105 100644 --- a/homeassistant/components/homematicip_cloud/translations/ja.json +++ b/homeassistant/components/homematicip_cloud/translations/ja.json @@ -1,12 +1,29 @@ { "config": { "abort": { - "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", - "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "connection_aborted": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" }, "error": { - "invalid_sgtin_or_pin": "PIN\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", - "press_the_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + "invalid_sgtin_or_pin": "SGTIN\u3001\u307e\u305f\u306fPIN\u30b3\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "press_the_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "register_failed": "\u767b\u9332\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "timeout_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u3068\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "data": { + "hapid": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8ID (SGTIN)", + "name": "\u540d\u524d(\u30aa\u30d7\u30b7\u30e7\u30f3\u3001\u5168\u30c7\u30d0\u30a4\u30b9\u306e\u540d\u524d\u306e\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u3068\u3057\u3066\u4f7f\u7528)", + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "title": "HomematicIP Access point\u3092\u9078\u629e" + }, + "link": { + "description": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306e\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u3001\u9001\u4fe1(submit)\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u3068\u3001Home Assistant\u306bHomematicIP\u304c\u767b\u9332\u3055\u308c\u307e\u3059\u3002\n\n![\u30d6\u30ea\u30c3\u30b8\u306e\u30dc\u30bf\u30f3\u306e\u4f4d\u7f6e](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u30ea\u30f3\u30af \u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8" + } } } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/lt.json b/homeassistant/components/homematicip_cloud/translations/lt.json new file mode 100644 index 0000000000000..a270a8acbc233 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "pin": "PIN kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/pl.json b/homeassistant/components/homematicip_cloud/translations/pl.json index 7b7cdc05cab36..9446fe2687b39 100644 --- a/homeassistant/components/homematicip_cloud/translations/pl.json +++ b/homeassistant/components/homematicip_cloud/translations/pl.json @@ -21,7 +21,7 @@ "title": "Wybierz punkt dost\u0119pu HomematicIP" }, "link": { - "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk przesy\u0142ania, aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n![Umiejscowienie przycisku na mostku](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk \"Zatwierd\u017a\", aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n![Umiejscowienie przycisku na mostku](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Po\u0142\u0105czenie z punktem dost\u0119pu" } } diff --git a/homeassistant/components/homematicip_cloud/translations/tr.json b/homeassistant/components/homematicip_cloud/translations/tr.json index 72f139217cace..3654357064e0d 100644 --- a/homeassistant/components/homematicip_cloud/translations/tr.json +++ b/homeassistant/components/homematicip_cloud/translations/tr.json @@ -4,6 +4,26 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "connection_aborted": "Ba\u011flanma hatas\u0131", "unknown": "Beklenmeyen hata" + }, + "error": { + "invalid_sgtin_or_pin": "Ge\u00e7ersiz SGTIN veya PIN Kodu , l\u00fctfen tekrar deneyin.", + "press_the_button": "L\u00fctfen mavi d\u00fc\u011fmeye bas\u0131n.", + "register_failed": "Kay\u0131t ba\u015far\u0131s\u0131z oldu, l\u00fctfen tekrar deneyin.", + "timeout_button": "Mavi d\u00fc\u011fmeye basma zaman a\u015f\u0131m\u0131, l\u00fctfen tekrar deneyin." + }, + "step": { + "init": { + "data": { + "hapid": "Eri\u015fim noktas\u0131 kimli\u011fi (SGTIN)", + "name": "Ad (iste\u011fe ba\u011fl\u0131, t\u00fcm cihazlar i\u00e7in ad \u00f6neki olarak kullan\u0131l\u0131r)", + "pin": "PIN Kodu" + }, + "title": "HomematicIP Eri\u015fim noktas\u0131 se\u00e7in" + }, + "link": { + "description": "HomematicIP'i Home Assistant ile kaydetmek i\u00e7in eri\u015fim noktas\u0131ndaki mavi d\u00fc\u011fmeye ve g\u00f6nder d\u00fc\u011fmesine bas\u0131n. \n\n ![K\u00f6pr\u00fcdeki d\u00fc\u011fmenin konumu](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Ba\u011flant\u0131 Eri\u015fim noktas\u0131" + } } } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index dcd8ff4dff741..d371e305d87ac 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -1,4 +1,6 @@ """Support for HomematicIP Cloud weather devices.""" +from __future__ import annotations + from homematicip.aio.device import ( AsyncWeatherSensor, AsyncWeatherSensorPlus, @@ -50,7 +52,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncWeatherSensorPro): entities.append(HomematicipWeatherSensorPro(hap, device)) @@ -170,6 +172,6 @@ def attribution(self) -> str: return "Powered by Homematic IP" @property - def condition(self) -> str: + def condition(self) -> str | None: """Return the current condition.""" return HOME_WEATHER_CONDITION.get(self._device.weather.weatherCondition) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 57176c9acf807..4f3f27360f8e8 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1 +1,164 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" +import asyncio +from datetime import timedelta + +import somecomfort + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN + +UPDATE_LOOP_SLEEP_TIME = 5 +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) +PLATFORMS = [Platform.CLIMATE] + + +async def async_setup_entry(hass, config): + """Set up the Honeywell thermostat.""" + username = config.data[CONF_USERNAME] + password = config.data[CONF_PASSWORD] + + client = await hass.async_add_executor_job( + get_somecomfort_client, username, password + ) + + if client is None: + return False + + loc_id = config.data.get(CONF_LOC_ID) + dev_id = config.data.get(CONF_DEV_ID) + + devices = {} + + for location in client.locations_by_id.values(): + if not loc_id or location.locationid == loc_id: + for device in location.devices_by_id.values(): + if not dev_id or device.deviceid == dev_id: + devices[device.deviceid] = device + + if len(devices) == 0: + _LOGGER.debug("No devices found") + return False + + data = HoneywellData(hass, config, client, username, password, devices) + await data.async_update() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config.entry_id] = data + hass.config_entries.async_setup_platforms(config, PLATFORMS) + + config.async_on_unload(config.add_update_listener(update_listener)) + + return True + + +async def update_listener(hass, config) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config.entry_id) + + +async def async_unload_entry(hass, config): + """Unload the config config and platforms.""" + unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) + if unload_ok: + hass.data.pop(DOMAIN) + return unload_ok + + +def get_somecomfort_client(username, password): + """Initialize the somecomfort client.""" + try: + return somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", username) + return None + except somecomfort.SomeComfortError as ex: + raise ConfigEntryNotReady( + "Failed to initialize the Honeywell client: " + "Check your configuration (username, password), " + "or maybe you have exceeded the API rate limit?" + ) from ex + + +class HoneywellData: + """Get the latest data and update.""" + + def __init__(self, hass, config, client, username, password, devices): + """Initialize the data object.""" + self._hass = hass + self._config = config + self._client = client + self._username = username + self._password = password + self.devices = devices + + async def _retry(self) -> bool: + """Recreate a new somecomfort client. + + When we got an error, the best way to be sure that the next query + will succeed, is to recreate a new somecomfort client. + """ + self._client = await self._hass.async_add_executor_job( + get_somecomfort_client, self._username, self._password + ) + + if self._client is None: + return False + + refreshed_devices = [ + device + for location in self._client.locations_by_id.values() + for device in location.devices_by_id.values() + ] + + if len(refreshed_devices) == 0: + _LOGGER.error("Failed to find any devices after retry") + return False + + for updated_device in refreshed_devices: + if updated_device.deviceid in self.devices: + self.devices[updated_device.deviceid] = updated_device + else: + _LOGGER.info( + "New device with ID %s detected, reload the honeywell integration if you want to access it in Home Assistant" + ) + + await self._hass.config_entries.async_reload(self._config.entry_id) + return True + + async def _refresh_devices(self): + """Refresh each enabled device.""" + for device in self.devices.values(): + await self._hass.async_add_executor_job(device.refresh) + await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self) -> None: + """Update the state.""" + retries = 3 + while retries > 0: + try: + await self._refresh_devices() + break + except ( + somecomfort.client.APIRateLimited, + somecomfort.client.ConnectionError, + somecomfort.client.ConnectionTimeout, + OSError, + ) as exp: + retries -= 1 + if retries == 0: + _LOGGER.error( + "Ran out of retry attempts (3 attempts allocated). Error: %s", + exp, + ) + raise exp + + result = await self._retry() + + if not result: + _LOGGER.error("Retry result was empty. Error: %s", exp) + raise exp + + _LOGGER.info("SomeComfort update failed, retrying. Error: %s", exp) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 8053ad8550227..57ce0125c9337 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -2,14 +2,11 @@ from __future__ import annotations import datetime -import logging from typing import Any -import requests import somecomfort -import voluptuous as vol -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -33,48 +30,20 @@ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_REGION, - CONF_USERNAME, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -_LOGGER = logging.getLogger(__name__) +from .const import ( + _LOGGER, + CONF_COOL_AWAY_TEMPERATURE, + CONF_HEAT_AWAY_TEMPERATURE, + DOMAIN, +) ATTR_FAN_ACTION = "fan_action" -CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" -CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" -CONF_DEV_ID = "thermostat" -CONF_LOC_ID = "location" - -DEFAULT_COOL_AWAY_TEMPERATURE = 88 -DEFAULT_HEAT_AWAY_TEMPERATURE = 61 - ATTR_PERMANENT_HOLD = "permanent_hold" -PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_REGION), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_COOL_AWAY_TEMPERATURE, default=DEFAULT_COOL_AWAY_TEMPERATURE - ): vol.Coerce(int), - vol.Optional( - CONF_HEAT_AWAY_TEMPERATURE, default=DEFAULT_HEAT_AWAY_TEMPERATURE - ): vol.Coerce(int), - vol.Optional(CONF_REGION): cv.string, - vol.Optional(CONF_DEV_ID): cv.string, - vol.Optional(CONF_LOC_ID): cv.string, - } - ), -) +PRESET_HOLD = "Hold" HVAC_MODE_TO_HW_MODE = { "SwitchOffAllowed": {HVAC_MODE_OFF: "off"}, @@ -107,46 +76,20 @@ "follow schedule": FAN_AUTO, } +PARALLEL_UPDATES = 1 + -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): """Set up the Honeywell thermostat.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - try: - client = somecomfort.SomeComfort(username, password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", username) - return - except somecomfort.SomeComfortError: - _LOGGER.error( - "Failed to initialize the Honeywell client: " - "Check your configuration (username, password), " - "or maybe you have exceeded the API rate limit?" - ) - return + cool_away_temp = config.data.get(CONF_COOL_AWAY_TEMPERATURE) + heat_away_temp = config.data.get(CONF_HEAT_AWAY_TEMPERATURE) - dev_id = config.get(CONF_DEV_ID) - loc_id = config.get(CONF_LOC_ID) - cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE) - heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE) + data = hass.data[DOMAIN][config.entry_id] - add_entities( + async_add_entities( [ - HoneywellUSThermostat( - client, - device, - cool_away_temp, - heat_away_temp, - username, - password, - ) - for location in client.locations_by_id.values() - for device in location.devices_by_id.values() - if ( - (not loc_id or location.locationid == loc_id) - and (not dev_id or device.deviceid == dev_id) - ) + HoneywellUSThermostat(data, device, cool_away_temp, heat_away_temp) + for device in data.devices.values() ] ) @@ -154,35 +97,38 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class HoneywellUSThermostat(ClimateEntity): """Representation of a Honeywell US Thermostat.""" - def __init__( - self, client, device, cool_away_temp, heat_away_temp, username, password - ): + def __init__(self, data, device, cool_away_temp, heat_away_temp): """Initialize the thermostat.""" - self._client = client + self._data = data self._device = device self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False - self._username = username - self._password = password - _LOGGER.debug("latestData = %s ", device._data) + self._attr_unique_id = device.deviceid + self._attr_name = device.name + self._attr_temperature_unit = ( + TEMP_CELSIUS if device.temperature_unit == "C" else TEMP_FAHRENHEIT + ) + self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY, PRESET_HOLD] + self._attr_is_aux_heat = device.system_mode == "emheat" # not all honeywell HVACs support all modes mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} + self._attr_hvac_modes = list(self._hvac_mode_map) - self._supported_features = ( + self._attr_supported_features = ( SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE ) if device._data["canControlHumidification"]: - self._supported_features |= SUPPORT_TARGET_HUMIDITY + self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: - self._supported_features |= SUPPORT_AUX_HEAT + self._attr_supported_features |= SUPPORT_AUX_HEAT if not device._data["hasFan"]: return @@ -191,12 +137,9 @@ def __init__( mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} - self._supported_features |= SUPPORT_FAN_MODE + self._attr_fan_modes = list(self._fan_mode_map) - @property - def name(self) -> str | None: - """Return the name of the honeywell, if any.""" - return self._device.name + self._attr_supported_features |= SUPPORT_FAN_MODE @property def extra_state_attributes(self) -> dict[str, Any]: @@ -208,11 +151,6 @@ def extra_state_attributes(self) -> dict[str, Any]: data["dr_phase"] = self._device.raw_dr_data.get("Phase") return data - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return self._supported_features - @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -231,11 +169,6 @@ def max_temp(self) -> float: return self._device.raw_ui_data["HeatUpperSetptLimit"] return None - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return TEMP_CELSIUS if self._device.temperature_unit == "C" else TEMP_FAHRENHEIT - @property def current_humidity(self) -> int | None: """Return the current humidity.""" @@ -246,11 +179,6 @@ def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" return HW_MODE_TO_HVAC_MODE[self._device.system_mode] - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return list(self._hvac_mode_map) - @property def hvac_action(self) -> str | None: """Return the current running hvac operation if supported.""" @@ -289,28 +217,18 @@ def target_temperature_low(self) -> float | None: @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return PRESET_AWAY if self._away else None - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return [PRESET_NONE, PRESET_AWAY] + if self._away: + return PRESET_AWAY + if self._is_permanent_hold(): + return PRESET_HOLD - @property - def is_aux_heat(self) -> str | None: - """Return true if aux heater.""" - return self._device.system_mode == "emheat" + return None @property def fan_mode(self) -> str | None: """Return the fan setting.""" return HW_FAN_MODE_TO_HA[self._device.fan_mode] - @property - def fan_modes(self) -> list[str] | None: - """Return the list of available fan modes.""" - return list(self._fan_mode_map) - def _is_permanent_hold(self) -> bool: heat_status = self._device.raw_ui_data.get("StatusHeat", 0) cool_status = self._device.raw_ui_data.get("StatusCool", 0) @@ -318,8 +236,7 @@ def _is_permanent_hold(self) -> bool: def _set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return try: # Get current mode @@ -346,11 +263,9 @@ def set_temperature(self, **kwargs) -> None: try: if HVAC_MODE_HEAT_COOL in self._hvac_mode_map: - temperature = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if temperature: + if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH): self._device.setpoint_cool = temperature - temperature = kwargs.get(ATTR_TARGET_TEMP_LOW) - if temperature: + if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): self._device.setpoint_heat = temperature except somecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %s: %s", temperature, err) @@ -383,15 +298,35 @@ def _turn_away_mode_on(self) -> None: setattr(self._device, f"hold_{mode}", True) # Set temperature setattr( - self._device, f"setpoint_{mode}", getattr(self, f"_{mode}_away_temp") + self._device, + f"setpoint_{mode}", + getattr(self, f"_{mode}_away_temp"), ) except somecomfort.SomeComfortError: _LOGGER.error( "Temperature %.1f out of range", getattr(self, f"_{mode}_away_temp") ) + def _turn_hold_mode_on(self) -> None: + """Turn permanent hold on.""" + try: + # Get current mode + mode = self._device.system_mode + except somecomfort.SomeComfortError: + _LOGGER.error("Can not get system mode") + return + # Check that we got a valid mode back + if mode in HW_MODE_TO_HVAC_MODE: + try: + # Set permanent hold + setattr(self._device, f"hold_{mode}", True) + except somecomfort.SomeComfortError: + _LOGGER.error("Couldn't set permanent hold") + else: + _LOGGER.error("Invalid system mode returned: %s", mode) + def _turn_away_mode_off(self) -> None: - """Turn away off.""" + """Turn away/hold off.""" self._away = False try: # Disabling all hold modes @@ -404,6 +339,9 @@ def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if preset_mode == PRESET_AWAY: self._turn_away_mode_on() + elif preset_mode == PRESET_HOLD: + self._away = False + self._turn_hold_mode_on() else: self._turn_away_mode_off() @@ -418,54 +356,6 @@ def turn_aux_heat_off(self) -> None: else: self.set_hvac_mode(HVAC_MODE_OFF) - def _retry(self) -> bool: - """Recreate a new somecomfort client. - - When we got an error, the best way to be sure that the next query - will succeed, is to recreate a new somecomfort client. - """ - try: - self._client = somecomfort.SomeComfort(self._username, self._password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", self._username) - return False - except somecomfort.SomeComfortError as ex: - _LOGGER.error("Failed to initialize honeywell client: %s", str(ex)) - return False - - devices = [ - device - for location in self._client.locations_by_id.values() - for device in location.devices_by_id.values() - if device.name == self._device.name - ] - - if len(devices) != 1: - _LOGGER.error("Failed to find device %s", self._device.name) - return False - - self._device = devices[0] - return True - - def update(self) -> None: - """Update the state.""" - retries = 3 - while retries > 0: - try: - self._device.refresh() - break - except ( - somecomfort.client.APIRateLimited, - OSError, - requests.exceptions.ReadTimeout, - ) as exp: - retries -= 1 - if retries == 0: - raise exp - if not self._retry(): - raise exp - _LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp) - - _LOGGER.debug( - "latestData = %s ", self._device._data # pylint: disable=protected-access - ) + async def async_update(self): + """Get the latest state from the service.""" + await self._data.async_update() diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py new file mode 100644 index 0000000000000..505a49f062be8 --- /dev/null +++ b/homeassistant/components/honeywell/config_flow.py @@ -0,0 +1,44 @@ +"""Config flow to configure the honeywell integration.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from . import get_somecomfort_client +from .const import DOMAIN + + +class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a honeywell config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Create config entry. Show the setup form to the user.""" + errors = {} + + if user_input is not None: + valid = await self.is_valid(**user_input) + if valid: + return self.async_create_entry( + title=DOMAIN, + data=user_input, + ) + + errors["base"] = "invalid_auth" + + data_schema = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + return self.async_show_form( + step_id="user", data_schema=vol.Schema(data_schema), errors=errors + ) + + async def is_valid(self, **kwargs) -> bool: + """Check if login credentials are valid.""" + client = await self.hass.async_add_executor_job( + get_somecomfort_client, kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD] + ) + + return client is not None diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py new file mode 100644 index 0000000000000..2dce56046a390 --- /dev/null +++ b/homeassistant/components/honeywell/const.py @@ -0,0 +1,11 @@ +"""Support for Honeywell (US) Total Connect Comfort climate systems.""" +import logging + +DOMAIN = "honeywell" + +CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" +CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" +CONF_DEV_ID = "thermostat" +CONF_LOC_ID = "location" + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index bd0c5dfca6de9..9bf4932a95362 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -1,8 +1,9 @@ { "domain": "honeywell", "name": "Honeywell Total Connect Comfort (US)", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/honeywell", - "requirements": ["somecomfort==0.5.2"], - "codeowners": [], + "requirements": ["somecomfort==0.8.0"], + "codeowners": ["@rdfurman"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json new file mode 100644 index 0000000000000..ce76b571996ec --- /dev/null +++ b/homeassistant/components/honeywell/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "title": "Honeywell Total Connect Comfort (US)", + "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/honeywell/translations/bg.json b/homeassistant/components/honeywell/translations/bg.json new file mode 100644 index 0000000000000..e70202683112e --- /dev/null +++ b/homeassistant/components/honeywell/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/ca.json b/homeassistant/components/honeywell/translations/ca.json new file mode 100644 index 0000000000000..34da1b89f1070 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les credencials utilitzades per iniciar sessi\u00f3 a mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (EUA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/cs.json b/homeassistant/components/honeywell/translations/cs.json new file mode 100644 index 0000000000000..25ad431df4e67 --- /dev/null +++ b/homeassistant/components/honeywell/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/de.json b/homeassistant/components/honeywell/translations/de.json new file mode 100644 index 0000000000000..a146d442eef2d --- /dev/null +++ b/homeassistant/components/honeywell/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte gib die Anmeldedaten ein, mit denen du dich bei mytotalconnectcomfort.com anmeldest.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/en.json b/homeassistant/components/honeywell/translations/en.json new file mode 100644 index 0000000000000..454093c5b3ebf --- /dev/null +++ b/homeassistant/components/honeywell/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json new file mode 100644 index 0000000000000..9f6c562e88844 --- /dev/null +++ b/homeassistant/components/honeywell/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Por favor, introduzca las credenciales utilizadas para iniciar sesi\u00f3n en mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/et.json b/homeassistant/components/honeywell/translations/et.json new file mode 100644 index 0000000000000..264a1efeca5ae --- /dev/null +++ b/homeassistant/components/honeywell/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta saidile mytotalconnectcomfort.com sisenemiseks kasutatav mandaat.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/fr.json b/homeassistant/components/honeywell/translations/fr.json new file mode 100644 index 0000000000000..fbe3def3113b6 --- /dev/null +++ b/homeassistant/components/honeywell/translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Authentification invalide" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Veuillez saisir les informations d'identification utilis\u00e9es pour vous connecter \u00e0 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (\u00c9tats-Unis)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/he.json b/homeassistant/components/honeywell/translations/he.json new file mode 100644 index 0000000000000..fc7a38e2658cf --- /dev/null +++ b/homeassistant/components/honeywell/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/hu.json b/homeassistant/components/honeywell/translations/hu.json new file mode 100644 index 0000000000000..5583dc22f2ee3 --- /dev/null +++ b/homeassistant/components/honeywell/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rj\u00fck, adja meg a mytotalconnectcomfort.com webhelyre val\u00f3 bejelentkez\u00e9shez haszn\u00e1lt hiteles\u00edt\u0151 adatokat.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/id.json b/homeassistant/components/honeywell/translations/id.json new file mode 100644 index 0000000000000..62151da1481ea --- /dev/null +++ b/homeassistant/components/honeywell/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan kredensial yang digunakan untuk masuk ke mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (AS)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/it.json b/homeassistant/components/honeywell/translations/it.json new file mode 100644 index 0000000000000..52c828ddcde6b --- /dev/null +++ b/homeassistant/components/honeywell/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le credenziali utilizzate per accedere a mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/ja.json b/homeassistant/components/honeywell/translations/ja.json new file mode 100644 index 0000000000000..1d3e19750c6ca --- /dev/null +++ b/homeassistant/components/honeywell/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "mytotalconnectcomfort.com \u306b\u30ed\u30b0\u30a4\u30f3\u3059\u308b\u305f\u3081\u306b\u4f7f\u7528\u3059\u308b\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/nl.json b/homeassistant/components/honeywell/translations/nl.json new file mode 100644 index 0000000000000..0abd80fa08846 --- /dev/null +++ b/homeassistant/components/honeywell/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer de inloggegevens in die zijn gebruikt om in te loggen op mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/no.json b/homeassistant/components/honeywell/translations/no.json new file mode 100644 index 0000000000000..97d31d349616b --- /dev/null +++ b/homeassistant/components/honeywell/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn legitimasjonen som brukes for \u00e5 logge deg p\u00e5 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/pl.json b/homeassistant/components/honeywell/translations/pl.json new file mode 100644 index 0000000000000..c109565e33a80 --- /dev/null +++ b/homeassistant/components/honeywell/translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce u\u017cywane na mytotalconnectcomfort.com", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/ru.json b/homeassistant/components/honeywell/translations/ru.json new file mode 100644 index 0000000000000..1d775e6c2c718 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u043d\u0430 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (\u0421\u0428\u0410)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/tr.json b/homeassistant/components/honeywell/translations/tr.json new file mode 100644 index 0000000000000..e6eb57aca1f15 --- /dev/null +++ b/homeassistant/components/honeywell/translations/tr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen mytotalconnectcomfort.com'da oturum a\u00e7mak i\u00e7in kullan\u0131lan kimlik bilgilerini girin.", + "title": "Honeywell Toplam Ba\u011flant\u0131 Konforu (ABD)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/zh-Hans.json b/homeassistant/components/honeywell/translations/zh-Hans.json new file mode 100644 index 0000000000000..d217ccdc8429d --- /dev/null +++ b/homeassistant/components/honeywell/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/zh-Hant.json b/homeassistant/components/honeywell/translations/zh-Hant.json new file mode 100644 index 0000000000000..906506d41a50a --- /dev/null +++ b/homeassistant/components/honeywell/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165\u767b\u5165 mytotalconnectcomfort.com \u4e4b\u6191\u8b49\u3002", + "title": "Honeywell Total Connect Comfort\uff08\u7f8e\u570b\uff09" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 297bfa5264f21..5a44a2937e894 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -133,12 +133,12 @@ def name(self): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the sensor.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index b3d5a081d1b46..6c3a10757bb96 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -2,6 +2,7 @@ from contextlib import suppress from datetime import datetime, timedelta from functools import partial +from http import HTTPStatus import json import logging import time @@ -26,13 +27,7 @@ PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import ( - ATTR_NAME, - HTTP_BAD_REQUEST, - HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, - URL_ROOT, -) +from homeassistant.const import ATTR_NAME, URL_ROOT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string @@ -191,9 +186,8 @@ def websocket_appkey(hass, connection, msg): hass.http.register_view(HTML5PushCallbackView(registrations)) gcm_api_key = config.get(ATTR_GCM_API_KEY) - gcm_sender_id = config.get(ATTR_GCM_SENDER_ID) - if gcm_sender_id is not None: + if config.get(ATTR_GCM_SENDER_ID) is not None: add_manifest_json_key(ATTR_GCM_SENDER_ID, config.get(ATTR_GCM_SENDER_ID)) return HTML5NotificationService( @@ -224,11 +218,11 @@ async def post(self, request): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) try: data = REGISTER_SCHEMA(data) except vol.Invalid as ex: - return self.json_message(humanize_error(data, ex), HTTP_BAD_REQUEST) + return self.json_message(humanize_error(data, ex), HTTPStatus.BAD_REQUEST) devname = data.get(ATTR_NAME) data.pop(ATTR_NAME, None) @@ -252,7 +246,7 @@ async def post(self, request): self.registrations.pop(name) return self.json_message( - "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) def find_registration_name(self, data, suggested=None): @@ -269,7 +263,7 @@ async def delete(self, request): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) subscription = data.get(ATTR_SUBSCRIPTION) @@ -295,7 +289,7 @@ async def delete(self, request): except HomeAssistantError: self.registrations[found] = reg return self.json_message( - "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) return self.json_message("Push notification subscriber unregistered.") @@ -320,7 +314,9 @@ def decode_jwt(self, token): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, verify=False) + target_check = jwt.decode( + token, algorithms=["ES256", "HS256"], options={"verify_signature": False} + ) if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] @@ -328,18 +324,16 @@ def decode_jwt(self, token): return jwt.decode(token, key, algorithms=["ES256", "HS256"]) return self.json_message( - "No target found in JWT", status_code=HTTP_UNAUTHORIZED + "No target found in JWT", status_code=HTTPStatus.UNAUTHORIZED ) # The following is based on code from Auth0 # https://auth0.com/docs/quickstart/backend/python def check_authorization_header(self, request): """Check the authorization header.""" - - auth = request.headers.get(AUTHORIZATION) - if not auth: + if not (auth := request.headers.get(AUTHORIZATION)): return self.json_message( - "Authorization header is expected", status_code=HTTP_UNAUTHORIZED + "Authorization header is expected", status_code=HTTPStatus.UNAUTHORIZED ) parts = auth.split() @@ -347,19 +341,21 @@ def check_authorization_header(self, request): if parts[0].lower() != "bearer": return self.json_message( "Authorization header must start with Bearer", - status_code=HTTP_UNAUTHORIZED, + status_code=HTTPStatus.UNAUTHORIZED, ) if len(parts) != 2: return self.json_message( "Authorization header must be Bearer token", - status_code=HTTP_UNAUTHORIZED, + status_code=HTTPStatus.UNAUTHORIZED, ) token = parts[1] try: payload = self.decode_jwt(token) except jwt.exceptions.InvalidTokenError: - return self.json_message("token is invalid", status_code=HTTP_UNAUTHORIZED) + return self.json_message( + "token is invalid", status_code=HTTPStatus.UNAUTHORIZED + ) return payload async def post(self, request): @@ -371,7 +367,7 @@ async def post(self, request): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) event_payload = { ATTR_TAG: data.get(ATTR_TAG), @@ -464,9 +460,7 @@ def send_message(self, message="", **kwargs): ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), } - data = kwargs.get(ATTR_DATA) - - if data: + if data := kwargs.get(ATTR_DATA): # Pick out fields that should go into the notification directly vs # into the notification data dictionary. @@ -497,9 +491,8 @@ def _push_message(self, payload, **kwargs): if priority not in ["normal", "high"]: priority = DEFAULT_PRIORITY payload["timestamp"] = timestamp * 1000 # Javascript ms since epoch - targets = kwargs.get(ATTR_TARGET) - if not targets: + if not (targets := kwargs.get(ATTR_TARGET)): targets = self.registrations.keys() for target in list(targets): @@ -557,7 +550,7 @@ def add_jwt(timestamp, target, tag, jwt_secret): ATTR_TARGET: target, ATTR_TAG: tag, } - return jwt.encode(jwt_claims, jwt_secret).decode("utf-8") + return jwt.encode(jwt_claims, jwt_secret) def create_vapid_headers(vapid_email, subscription_info, vapid_private_key): diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index f3df434159405..f6b76e67cd795 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -1,9 +1,16 @@ dismiss: + name: Dismiss description: Dismiss a html5 notification. fields: target: - description: An array of targets. Optional. + name: Target + description: An array of targets. example: ["my_phone", "my_tablet"] + selector: + object: data: - description: Extended information of notification. Supports tag. Optional. + name: Data + description: Extended information of notification. Supports tag. example: '{ "tag": "tagname" }' + selector: + object: diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 8bd20e316281a..b12b6f83e3a6f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,7 +1,6 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations -from contextvars import ContextVar from ipaddress import ip_network import logging import os @@ -13,6 +12,7 @@ from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection import voluptuous as vol +from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import storage @@ -20,7 +20,6 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start -import homeassistant.util as hass_util from homeassistant.util import ssl as ssl_util from .auth import setup_auth @@ -28,7 +27,7 @@ from .const import KEY_AUTHENTICATED, KEY_HASS, KEY_HASS_USER # noqa: F401 from .cors import setup_cors from .forwarded import async_setup_forwarded -from .request_context import setup_request_context +from .request_context import current_request, setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource from .view import HomeAssistantView @@ -190,7 +189,7 @@ async def start_server(*_: Any) -> None: hass.http = server - local_ip = await hass.async_add_executor_job(hass_util.get_local_ip) + local_ip = await async_get_source_ip(hass) host = local_ip if server_host is not None: @@ -232,10 +231,7 @@ def __init__( # forwarded middleware needs to go second. setup_security_filter(app) - # Only register middleware if `use_x_forwarded_for` is enabled - # and trusted proxies are provided - if use_x_forwarded_for and trusted_proxies: - async_setup_forwarded(app, trusted_proxies) + async_setup_forwarded(app, use_x_forwarded_for, trusted_proxies) setup_request_context(app, current_request) @@ -301,21 +297,24 @@ async def redirect(request: web.Request) -> web.StreamResponse: # Should be instance of aiohttp.web_exceptions._HTTPMove. raise redirect_exc(redirect_to) # type: ignore[arg-type,misc] - self.app.router.add_route("GET", url, redirect) + self.app["allow_configured_cors"]( + self.app.router.add_route("GET", url, redirect) + ) def register_static_path( self, url_path: str, path: str, cache_headers: bool = True - ) -> web.FileResponse | None: + ) -> None: """Register a folder or file to serve as a static path.""" if os.path.isdir(path): if cache_headers: - resource: type[ - CachingStaticResource | web.StaticResource - ] = CachingStaticResource + resource: CachingStaticResource | web.StaticResource = ( + CachingStaticResource(url_path, path) + ) else: - resource = web.StaticResource - self.app.router.register_resource(resource(url_path, path)) - return None + resource = web.StaticResource(url_path, path) + self.app.router.register_resource(resource) + self.app["allow_configured_cors"](resource) + return async def serve_file(request: web.Request) -> web.FileResponse: """Serve file from disk.""" @@ -323,8 +322,9 @@ async def serve_file(request: web.Request) -> web.FileResponse: return web.FileResponse(path, headers=CACHE_HEADERS) return web.FileResponse(path) - self.app.router.add_route("GET", url_path, serve_file) - return None + self.app["allow_configured_cors"]( + self.app.router.add_route("GET", url_path, serve_file) + ) async def start(self) -> None: """Start the aiohttp server.""" @@ -400,8 +400,3 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) - - -current_request: ContextVar[web.Request | None] = ContextVar( - "current_request", default=None -) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7004b279bd0a1..19f7c429a1e41 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta +from ipaddress import ip_address import logging import secrets from typing import Final @@ -12,10 +13,13 @@ from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from homeassistant.auth.models import User from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util +from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER +from .request_context import current_request _LOGGER = logging.getLogger(__name__) @@ -29,9 +33,7 @@ def async_sign_path( hass: HomeAssistant, refresh_token_id: str, path: str, expiration: timedelta ) -> str: """Sign a path for temporary access without auth header.""" - secret = hass.data.get(DATA_SIGN_SECRET) - - if secret is None: + if (secret := hass.data.get(DATA_SIGN_SECRET)) is None: secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex() now = dt_util.utcnow() @@ -45,7 +47,43 @@ def async_sign_path( secret, algorithm="HS256", ) - return f"{path}?{SIGN_QUERY_PARAM}={encoded.decode()}" + return f"{path}?{SIGN_QUERY_PARAM}={encoded}" + + +@callback +def async_user_not_allowed_do_auth( + hass: HomeAssistant, user: User, request: Request | None = None +) -> str | None: + """Validate that user is not allowed to do auth things.""" + if not user.is_active: + return "User is not active" + + if not user.local_only: + return None + + # User is marked as local only, check if they are allowed to do auth + if request is None: + request = current_request.get() + + if not request: + return "No request available to validate local access" + + if "cloud" in hass.config.components: + # pylint: disable=import-outside-toplevel + from hass_nabucasa import remote + + if remote.is_cloud_request.get(): + return "User is local only" + + try: + remote = ip_address(request.remote) + except ValueError: + return "Invalid remote IP" + + if is_local(remote): + return None + + return "User cannot authenticate remotely" @callback @@ -74,20 +112,19 @@ async def async_validate_auth_header(request: Request) -> bool: if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True async def async_validate_signed_request(request: Request) -> bool: """Validate a signed request.""" - secret = hass.data.get(DATA_SIGN_SECRET) - - if secret is None: + if (secret := hass.data.get(DATA_SIGN_SECRET)) is None: return False - signature = request.query.get(SIGN_QUERY_PARAM) - - if signature is None: + if (signature := request.query.get(SIGN_QUERY_PARAM)) is None: return False try: diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 10776f11b0004..a1d50dbdcb567 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable from contextlib import suppress from datetime import datetime +from http import HTTPStatus from ipaddress import ip_address import logging from socket import gethostbyaddr, herror @@ -15,7 +16,6 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file -from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -89,9 +89,9 @@ def log_invalid_auth( async def handle_req( view: HomeAssistantView, request: Request, *args: Any, **kwargs: Any ) -> StreamResponse: - """Try to log failed login attempts if response status >= 400.""" + """Try to log failed login attempts if response status >= BAD_REQUEST.""" resp = await func(view, request, *args, **kwargs) - if resp.status >= HTTP_BAD_REQUEST: + if resp.status >= HTTPStatus.BAD_REQUEST: await process_wrong_login(request) return resp @@ -217,7 +217,7 @@ async def async_load_ip_bans_config(hass: HomeAssistant, path: str) -> list[IpBa def update_ip_bans_config(path: str, ip_ban: IpBan) -> None: """Update config file with new banned IP address.""" - with open(path, "a") as out: + with open(path, "a", encoding="utf8") as out: ip_ = {str(ip_ban.ip_address): {ATTR_BANNED_AT: ip_ban.banned_at.isoformat()}} out.write("\n") out.write(yaml.dump(ip_)) diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index d9310c8937fe5..97a0530b70306 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -70,7 +70,7 @@ def _allow_cors( cors.add(route, config) cors_added.add(path_str) - app["allow_cors"] = lambda route: _allow_cors( + app["allow_all_cors"] = lambda route: _allow_cors( route, { "*": aiohttp_cors.ResourceOptions( @@ -79,12 +79,7 @@ def _allow_cors( }, ) - if not origins: - return - - async def cors_startup(app: Application) -> None: - """Initialize CORS when app starts up.""" - for resource in list(app.router.resources()): - _allow_cors(resource) - - app.on_startup.append(cors_startup) + if origins: + app["allow_configured_cors"] = _allow_cors + else: + app["allow_configured_cors"] = lambda _: None diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 2768350c183a5..cc661d43fd84b 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,16 +1,15 @@ """Decorator for view methods to help with data validation.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from functools import wraps +from http import HTTPStatus import logging -from typing import Any, Callable +from typing import Any from aiohttp import web import voluptuous as vol -from homeassistant.const import HTTP_BAD_REQUEST - from .view import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -49,7 +48,7 @@ async def wrapper( except ValueError: if not self._allow_empty or (await request.content.read()) != b"": _LOGGER.error("Invalid JSON received") - return view.json_message("Invalid JSON.", HTTP_BAD_REQUEST) + return view.json_message("Invalid JSON.", HTTPStatus.BAD_REQUEST) data = {} try: @@ -57,7 +56,7 @@ async def wrapper( except vol.Invalid as err: _LOGGER.error("Data does not match schema: %s", err) return view.json_message( - f"Message format incorrect: {err}", HTTP_BAD_REQUEST + f"Message format incorrect: {err}", HTTPStatus.BAD_REQUEST ) result = await method(view, request, *args, **kwargs) diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 5c5726a259721..4cc330a85edf2 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -4,6 +4,8 @@ from collections.abc import Awaitable, Callable from ipaddress import ip_address import logging +from types import ModuleType +from typing import Literal from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware @@ -14,7 +16,9 @@ @callback -def async_setup_forwarded(app: Application, trusted_proxies: list[str]) -> None: +def async_setup_forwarded( + app: Application, use_x_forwarded_for: bool | None, trusted_proxies: list[str] +) -> None: """Create forwarded middleware for the app. Process IP addresses, proto and host information in the forwarded for headers. @@ -45,7 +49,8 @@ def async_setup_forwarded(app: Application, trusted_proxies: list[str]) -> None: Additionally: - If no X-Forwarded-For header is found, the processing of all headers is skipped. - - Log a warning when untrusted connected peer provides X-Forwarded-For headers. + - Throw HTTP 400 status when untrusted connected peer provides + X-Forwarded-For headers. - If multiple instances of X-Forwarded-For, X-Forwarded-Proto or X-Forwarded-Host are found, an HTTP 400 status code is thrown. - If malformed or invalid (IP) data in X-Forwarded-For header is found, @@ -60,12 +65,31 @@ def async_setup_forwarded(app: Application, trusted_proxies: list[str]) -> None: an HTTP 400 status code is thrown. """ + remote: Literal[False] | None | ModuleType = None + @middleware async def forwarded_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process forwarded data by a reverse proxy.""" - overrides: dict[str, str] = {} + nonlocal remote + + if remote is None: + # Initialize remote method + try: + from hass_nabucasa import ( # pylint: disable=import-outside-toplevel + remote, + ) + + # venv users might have an old version installed if they don't have cloud around anymore + if not hasattr(remote, "is_cloud_request"): + remote = False + except ImportError: + remote = False + + # Skip requests from Remote UI + if remote and remote.is_cloud_request.get(): # type: ignore + return await handler(request) # Handle X-Forwarded-For forwarded_for_headers: list[str] = request.headers.getall(X_FORWARDED_FOR, []) @@ -73,16 +97,32 @@ async def forwarded_middleware( # No forwarding headers, continue as normal return await handler(request) - # Ensure the IP of the connected peer is trusted - assert request.transport is not None + # Get connected IP + if ( + request.transport is None + or request.transport.get_extra_info("peername") is None + ): + # Connected IP isn't retrieveable from the request transport, continue + return await handler(request) + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + + # We have X-Forwarded-For, but config does not agree + if not use_x_forwarded_for: + _LOGGER.error( + "A request from a reverse proxy was received from %s, but your " + "HTTP integration is not set-up for reverse proxies", + connected_ip, + ) + raise HTTPBadRequest + + # Ensure the IP of the connected peer is trusted if not any(connected_ip in trusted_proxy for trusted_proxy in trusted_proxies): - _LOGGER.warning( - "Received X-Forwarded-For header from untrusted proxy %s, headers not processed", + _LOGGER.error( + "Received X-Forwarded-For header from an untrusted proxy %s", connected_ip, ) - # Not trusted, continue as normal - return await handler(request) + raise HTTPBadRequest # Multiple X-Forwarded-For headers if len(forwarded_for_headers) > 1: @@ -101,6 +141,8 @@ async def forwarded_middleware( ) raise HTTPBadRequest from err + overrides: dict[str, str] = {} + # Find the last trusted index in the X-Forwarded-For list forwarded_for_index = 0 for forwarded_ip in forwarded_for: diff --git a/homeassistant/components/http/request_context.py b/homeassistant/components/http/request_context.py index 032f3bfd49ed3..6e036b9cdc8db 100644 --- a/homeassistant/components/http/request_context.py +++ b/homeassistant/components/http/request_context.py @@ -8,6 +8,10 @@ from homeassistant.core import callback +current_request: ContextVar[Request | None] = ContextVar( + "current_request", default=None +) + @callback def setup_request_context( diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 9abf0914b065b..192d2d5d57b3b 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable +from http import HTTPStatus import json import logging from typing import Any @@ -18,7 +19,7 @@ import voluptuous as vol from homeassistant import exceptions -from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK, HTTP_SERVICE_UNAVAILABLE +from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, is_callback from homeassistant.helpers.json import JSONEncoder @@ -39,8 +40,7 @@ class HomeAssistantView: @staticmethod def context(request: web.Request) -> Context: """Generate a context from a request.""" - user = request.get("hass_user") - if user is None: + if (user := request.get("hass_user")) is None: return Context() return Context(user_id=user.id) @@ -48,7 +48,7 @@ def context(request: web.Request) -> Context: @staticmethod def json( result: Any, - status_code: int = HTTP_OK, + status_code: HTTPStatus | int = HTTPStatus.OK, headers: LooseHeaders | None = None, ) -> web.Response: """Return a JSON response.""" @@ -60,7 +60,7 @@ def json( response = web.Response( body=msg, content_type=CONTENT_TYPE_JSON, - status=status_code, + status=int(status_code), headers=headers, ) response.enable_compression() @@ -69,7 +69,7 @@ def json( def json_message( self, message: str, - status_code: int = HTTP_OK, + status_code: HTTPStatus | int = HTTPStatus.OK, message_code: str | None = None, headers: LooseHeaders | None = None, ) -> web.Response: @@ -86,9 +86,7 @@ def register(self, app: web.Application, router: web.UrlDispatcher) -> None: routes: list[AbstractRoute] = [] for method in ("get", "post", "delete", "put", "patch", "head", "options"): - handler = getattr(self, method, None) - - if not handler: + if not (handler := getattr(self, method, None)): continue handler = request_handler_factory(self, handler) @@ -96,11 +94,15 @@ def register(self, app: web.Application, router: web.UrlDispatcher) -> None: for url in urls: routes.append(router.add_route(method, url, handler)) - if not self.cors_allowed: - return + # Use `get` because CORS middleware is not be loaded in emulated_hue + if self.cors_allowed: + allow_cors = app.get("allow_all_cors") + else: + allow_cors = app.get("allow_configured_cors") - for route in routes: - app["allow_cors"](route) + if allow_cors: + for route in routes: + allow_cors(route) def request_handler_factory( @@ -114,7 +116,7 @@ def request_handler_factory( async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" if request.app[KEY_HASS].is_stopping: - return web.Response(status=HTTP_SERVICE_UNAVAILABLE) + return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) authenticated = request.get(KEY_AUTHENTICATED, False) @@ -144,7 +146,7 @@ async def handle(request: web.Request) -> web.StreamResponse: # The method handler returned a ready-made Response, how nice of it return result - status_code = HTTP_OK + status_code = HTTPStatus.OK if isinstance(result, tuple): result, status_code = result @@ -158,7 +160,7 @@ async def handle(request: web.Request) -> web.StreamResponse: else: assert ( False - ), f"Result should be None, string, bytes or Response. Got: {result}" + ), f"Result should be None, string, bytes or StreamResponse. Got: {result}" return web.Response(body=bresult, status=status_code) diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index d32eebf6d5fe5..6c3844d6657d4 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -1,4 +1,6 @@ """Support for HTU21D temperature and humidity sensor.""" +from __future__ import annotations + from datetime import timedelta from functools import partial import logging @@ -7,11 +9,15 @@ import smbus import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_NAME, PERCENTAGE, TEMP_FAHRENHEIT +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_NAME, PERCENTAGE, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -25,6 +31,19 @@ SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -37,7 +56,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the HTU21D sensor.""" name = config.get(CONF_NAME) bus_number = config.get(CONF_I2C_BUS) - temp_unit = hass.config.units.temperature_unit bus = smbus.SMBus(config.get(CONF_I2C_BUS)) sensor = await hass.async_add_executor_job(partial(HTU21D, bus, logger=_LOGGER)) @@ -47,12 +65,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensor_handler = await hass.async_add_executor_job(HTU21DHandler, sensor) - dev = [ - HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), - HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, PERCENTAGE), + entities = [ + HTU21DSensor(sensor_handler, name, description) for description in SENSOR_TYPES ] - async_add_entities(dev) + async_add_entities(entities) class HTU21DHandler: @@ -72,39 +89,21 @@ def update(self): class HTU21DSensor(SensorEntity): """Implementation of the HTU21D sensor.""" - def __init__(self, htu21d_client, name, variable, unit): + def __init__(self, htu21d_client, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = f"{name}_{variable}" - self._variable = variable - self._unit_of_measurement = unit + self.entity_description = description self._client = htu21d_client - self._state = None - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> int: - """Return the state of the sensor.""" - return self._state - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement + self._attr_name = f"{name}_{description.key}" async def async_update(self): """Get the latest data from the HTU21D sensor and update the state.""" await self.hass.async_add_executor_job(self._client.update) if self._client.sensor.sample_ok: - if self._variable == SENSOR_TEMPERATURE: + if self.entity_description.key == SENSOR_TEMPERATURE: value = round(self._client.sensor.temperature, 1) - if self.unit_of_measurement == TEMP_FAHRENHEIT: - value = celsius_to_fahrenheit(value) else: value = round(self._client.sensor.humidity, 1) - self._state = value + self._attr_native_value = value else: _LOGGER.warning("Bad sample") diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ebb54ab75c60f..4f4451330f6d1 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -2,17 +2,14 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from contextlib import suppress from datetime import timedelta -from functools import partial -import ipaddress import logging import time -from typing import Any, Callable, cast -from urllib.parse import urlparse +from typing import Any, cast import attr -from getmac import get_mac_address from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection @@ -25,28 +22,27 @@ from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.device_tracker.const import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, -) from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery, + entity_registry, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -56,6 +52,8 @@ from .const import ( ADMIN_SERVICES, ALL_KEYS, + ATTR_UNIQUE_ID, + CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME, @@ -81,6 +79,7 @@ SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, ) +from .utils import get_device_macs _LOGGER = logging.getLogger(__name__) @@ -119,23 +118,22 @@ SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) -CONFIG_ENTRY_PLATFORMS = ( - BINARY_SENSOR_DOMAIN, - DEVICE_TRACKER_DOMAIN, - SENSOR_DOMAIN, - SWITCH_DOMAIN, -) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SWITCH, +] @attr.s class Router: """Class for router state.""" + hass: HomeAssistant = attr.ib() config_entry: ConfigEntry = attr.ib() connection: Connection = attr.ib() url: str = attr.ib() - mac: str = attr.ib() - signal_update: CALLBACK_TYPE = attr.ib() data: dict[str, Any] = attr.ib(init=False, factory=dict) subscriptions: dict[str, set[str]] = attr.ib( @@ -165,15 +163,15 @@ def device_name(self) -> str: @property def device_identifiers(self) -> set[tuple[str, str]]: """Get router identifiers for device registry.""" - try: - return {(DOMAIN, self.data[KEY_DEVICE_INFORMATION]["SerialNumber"])} - except (KeyError, TypeError): - return set() + assert self.config_entry.unique_id is not None + return {(DOMAIN, self.config_entry.unique_id)} @property def device_connections(self) -> set[tuple[str, str]]: """Get router connections for device registry.""" - return {(dr.CONNECTION_NETWORK_MAC, self.mac)} if self.mac else set() + return { + (dr.CONNECTION_NETWORK_MAC, x) for x in self.config_entry.data[CONF_MAC] + } def _get_data(self, key: str, func: Callable[[], Any]) -> None: if not self.subscriptions.get(key): @@ -185,11 +183,6 @@ def _get_data(self, key: str, func: Callable[[], Any]) -> None: _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) try: self.data[key] = func() - except ResponseErrorNotSupportedException: - _LOGGER.info( - "%s not supported by device, excluding from future updates", key - ) - self.subscriptions.pop(key) except ResponseErrorLoginRequiredException: if isinstance(self.connection, AuthorizedConnection): _LOGGER.debug("Trying to authorize again") @@ -206,7 +199,13 @@ def _get_data(self, key: str, func: Callable[[], Any]) -> None: ) self.subscriptions.pop(key) except ResponseErrorException as exc: - if exc.code != -1: + if not isinstance( + exc, ResponseErrorNotSupportedException + ) and exc.code not in ( + # additional codes treated as unusupported + -1, + 100006, + ): raise _LOGGER.info( "%s apparently not supported by device, excluding from future updates", @@ -271,7 +270,7 @@ def update(self) -> None: KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch ) - self.signal_update() + dispatcher_send(self.hass, UPDATE_SIGNAL, self.config_entry.unique_id) def logout(self) -> None: """Log out router session.""" @@ -298,127 +297,160 @@ def cleanup(self, *_: Any) -> None: class HuaweiLteData: """Shared state.""" - hass_config: dict = attr.ib() + hass_config: ConfigType = attr.ib() # Our YAML config, keyed by router URL config: dict[str, dict[str, Any]] = attr.ib() routers: dict[str, Router] = attr.ib(init=False, factory=dict) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Huawei LTE component from config entry.""" - url = config_entry.data[CONF_URL] + url = entry.data[CONF_URL] # Override settings from YAML config, but only if they're changed in it # Old values are stored as *_from_yaml in the config entry - yaml_config = hass.data[DOMAIN].config.get(url) - if yaml_config: + if yaml_config := hass.data[DOMAIN].config.get(url): # Config values new_data = {} for key in CONF_USERNAME, CONF_PASSWORD: if key in yaml_config: value = yaml_config[key] - if value != config_entry.data.get(f"{key}_from_yaml"): + if value != entry.data.get(f"{key}_from_yaml"): new_data[f"{key}_from_yaml"] = value new_data[key] = value # Options new_options = {} yaml_recipient = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_RECIPIENT) - if yaml_recipient is not None and yaml_recipient != config_entry.options.get( + if yaml_recipient is not None and yaml_recipient != entry.options.get( f"{CONF_RECIPIENT}_from_yaml" ): new_options[f"{CONF_RECIPIENT}_from_yaml"] = yaml_recipient new_options[CONF_RECIPIENT] = yaml_recipient yaml_notify_name = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_NAME) - if ( - yaml_notify_name is not None - and yaml_notify_name != config_entry.options.get(f"{CONF_NAME}_from_yaml") + if yaml_notify_name is not None and yaml_notify_name != entry.options.get( + f"{CONF_NAME}_from_yaml" ): new_options[f"{CONF_NAME}_from_yaml"] = yaml_notify_name new_options[CONF_NAME] = yaml_notify_name # Update entry if overrides were found if new_data or new_options: hass.config_entries.async_update_entry( - config_entry, - data={**config_entry.data, **new_data}, - options={**config_entry.options, **new_options}, + entry, + data={**entry.data, **new_data}, + options={**entry.options, **new_options}, ) - # Get MAC address for use in unique ids. Being able to use something - # from the API would be nice, but all of that seems to be available only - # through authenticated calls (e.g. device_information.SerialNumber), and - # we want this available and the same when unauthenticated too. - host = urlparse(url).hostname - try: - if ipaddress.ip_address(host).version == 6: - mode = "ip6" - else: - mode = "ip" - except ValueError: - mode = "hostname" - mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - def get_connection() -> Connection: - """ - Set up a connection. - - Authorized one if username/pass specified (even if empty), unauthorized one otherwise. - """ - username = config_entry.data.get(CONF_USERNAME) - password = config_entry.data.get(CONF_PASSWORD) - if username or password: - connection: Connection = AuthorizedConnection( + """Set up a connection.""" + if entry.options.get(CONF_UNAUTHENTICATED_MODE): + _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") + connection = Connection(url, timeout=CONNECTION_TIMEOUT) + else: + _LOGGER.debug("Connecting in authenticated mode, full feature set") + username = entry.data.get(CONF_USERNAME) or "" + password = entry.data.get(CONF_PASSWORD) or "" + connection = AuthorizedConnection( url, username=username, password=password, timeout=CONNECTION_TIMEOUT ) - else: - connection = Connection(url, timeout=CONNECTION_TIMEOUT) return connection - def signal_update() -> None: - """Signal updates to data.""" - dispatcher_send(hass, UPDATE_SIGNAL, url) - try: connection = await hass.async_add_executor_job(get_connection) except Timeout as ex: raise ConfigEntryNotReady from ex - # Set up router and store reference to it - router = Router(config_entry, connection, url, mac, signal_update) - hass.data[DOMAIN].routers[url] = router + # Set up router + router = Router(hass, entry, connection, url) # Do initial data update await hass.async_add_executor_job(router.update) + # Check that we found required information + router_info = router.data.get(KEY_DEVICE_INFORMATION) + if not entry.unique_id: + # Transitional from < 2021.8: update None config entry and entity unique ids + if router_info and (serial_number := router_info.get("SerialNumber")): + hass.config_entries.async_update_entry(entry, unique_id=serial_number) + ent_reg = entity_registry.async_get(hass) + for entity_entry in entity_registry.async_entries_for_config_entry( + ent_reg, entry.entry_id + ): + if not entity_entry.unique_id.startswith("None-"): + continue + new_unique_id = ( + f"{serial_number}-{entity_entry.unique_id.split('-', 1)[1]}" + ) + ent_reg.async_update_entity( + entity_entry.entity_id, new_unique_id=new_unique_id + ) + else: + await hass.async_add_executor_job(router.cleanup) + msg = ( + "Could not resolve serial number to use as unique id for router at %s" + ", setup failed" + ) + if not entry.data.get(CONF_PASSWORD): + msg += ( + ". Try setting up credentials for the router for one startup, " + "unauthenticated mode can be enabled after that in integration " + "settings" + ) + _LOGGER.error(msg, url) + return False + + # Store reference to router + hass.data[DOMAIN].routers[entry.unique_id] = router + # Clear all subscriptions, enabled entities will push back theirs router.subscriptions.clear() + # Update device MAC addresses on record. These can change due to toggling between + # authenticated and unauthenticated modes, or likely also when enabling/disabling + # SSIDs in the router config. + try: + wlan_settings = await hass.async_add_executor_job( + router.client.wlan.multi_basic_settings + ) + except Exception: # pylint: disable=broad-except + # Assume not supported, or authentication required but in unauthenticated mode + wlan_settings = {} + macs = get_device_macs(router_info or {}, wlan_settings) + # Be careful not to overwrite a previous, more complete set with a partial one + if macs and (not entry.data[CONF_MAC] or (router_info and wlan_settings)): + new_data = dict(entry.data) + new_data[CONF_MAC] = macs + hass.config_entries.async_update_entry(entry, data=new_data) + # Set up device registry if router.device_identifiers or router.device_connections: - device_data = {} + device_info = DeviceInfo( + configuration_url=router.url, + connections=router.device_connections, + identifiers=router.device_identifiers, + name=router.device_name, + manufacturer="Huawei", + ) sw_version = None - if router.data.get(KEY_DEVICE_INFORMATION): - device_info = router.data[KEY_DEVICE_INFORMATION] - sw_version = device_info.get("SoftwareVersion") - if device_info.get("DeviceName"): - device_data["model"] = device_info["DeviceName"] + if router_info: + sw_version = router_info.get("SoftwareVersion") + if router_info.get("DeviceName"): + device_info[ATTR_MODEL] = router_info["DeviceName"] if not sw_version and router.data.get(KEY_DEVICE_BASIC_INFORMATION): sw_version = router.data[KEY_DEVICE_BASIC_INFORMATION].get( "SoftwareVersion" ) if sw_version: - device_data["sw_version"] = sw_version - device_registry = await dr.async_get_registry(hass) + device_info[ATTR_SW_VERSION] = sw_version + device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections=router.device_connections, - identifiers=router.device_identifiers, - name=router.device_name, - manufacturer="Huawei", - **device_data, + config_entry_id=entry.entry_id, + **device_info, ) # Forward config entry setup to platforms - hass.config_entries.async_setup_platforms(config_entry, CONFIG_ENTRY_PLATFORMS) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) # Notify doesn't support config entry setup yet, load with discovery for now await discovery.async_load_platform( @@ -426,9 +458,9 @@ def signal_update() -> None: NOTIFY_DOMAIN, DOMAIN, { - CONF_URL: url, - CONF_NAME: config_entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME), - CONF_RECIPIENT: config_entry.options.get(CONF_RECIPIENT), + ATTR_UNIQUE_ID: entry.unique_id, + CONF_NAME: entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME), + CONF_RECIPIENT: entry.options.get(CONF_RECIPIENT), }, hass.data[DOMAIN].hass_config, ) @@ -442,12 +474,12 @@ def _update_router(*_: Any) -> None: router.update() # Set up periodic update - config_entry.async_on_unload( + entry.async_on_unload( async_track_time_interval(hass, _update_router, SCAN_INTERVAL) ) # Clean up at end - config_entry.async_on_unload( + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) ) @@ -458,12 +490,10 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Unload config entry.""" # Forward config entry unload to platforms - await hass.config_entries.async_unload_platforms( - config_entry, CONFIG_ENTRY_PLATFORMS - ) + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) # Forget about the router and invoke its cleanup - router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) + router = hass.data[DOMAIN].routers.pop(config_entry.unique_id) await hass.async_add_executor_job(router.cleanup) return True @@ -484,10 +514,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config def service_handler(service: ServiceCall) -> None: - """Apply a service.""" + """ + Apply a service. + + We key this using the router URL instead of its unique id / serial number, + because the latter is not available anywhere in the UI. + """ routers = hass.data[DOMAIN].routers if url := service.data.get(CONF_URL): - router = routers.get(url) + router = next( + (router for router in routers.values() if router.url == url), None + ) elif not routers: _LOGGER.error("%s: no routers configured", service.service) return @@ -497,7 +534,7 @@ def service_handler(service: ServiceCall) -> None: _LOGGER.error( "%s: more than one router configured, must specify one of URLs %s", service.service, - sorted(routers), + sorted(router.url for router in routers.values()), ) return if not router: @@ -561,6 +598,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, options=options) _LOGGER.info("Migrated config entry to version %d", config_entry.version) + if config_entry.version == 2: + config_entry.version = 3 + data = dict(config_entry.data) + data[CONF_MAC] = [] + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migrated config entry to version %d", config_entry.version) return True @@ -585,7 +628,7 @@ def _device_unique_id(self) -> str: @property def unique_id(self) -> str: """Return unique ID for entity.""" - return f"{self.router.mac}-{self._device_unique_id}" + return f"{self.router.config_entry.unique_id}-{self._device_unique_id}" @property def name(self) -> str: @@ -605,10 +648,10 @@ def should_poll(self) -> bool: @property def device_info(self) -> DeviceInfo: """Get info for matching with parent router.""" - return { - "identifiers": self.router.device_identifiers, - "connections": self.router.device_connections, - } + return DeviceInfo( + connections=self.router.device_connections, + identifiers=self.router.device_identifiers, + ) async def async_update(self) -> None: """Update state.""" @@ -620,9 +663,9 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) ) - async def _async_maybe_update(self, url: str) -> None: + async def _async_maybe_update(self, config_entry_unique_id: str) -> None: """Update state if the update signal comes from our router.""" - if url == self.router.url: + if config_entry_unique_id == self.router.config_entry.unique_id: self.async_schedule_update_ha_state(True) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 556ed6f5b43cc..85cfd00d7aa52 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -12,7 +12,6 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] entities: list[Entity] = [] if router.data.get(KEY_MONITORING_STATUS): diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 3e19e18151fd5..be2a149b4d5aa 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -2,12 +2,12 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client -from huawei_lte_api.Connection import Connection +from huawei_lte_api.Connection import GetResponseType from huawei_lte_api.exceptions import ( LoginErrorPasswordWrongException, LoginErrorUsernamePasswordOverrunException, @@ -22,6 +22,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import ( + CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_RECIPIENT, @@ -30,16 +31,18 @@ ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_TRACK_WIRED_CLIENTS, + CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME, DEFAULT_TRACK_WIRED_CLIENTS, + DEFAULT_UNAUTHENTICATED_MODE, DOMAIN, ) +from .utils import get_device_macs _LOGGER = logging.getLogger(__name__) @@ -47,7 +50,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Huawei LTE config flow.""" - VERSION = 2 + VERSION = 3 @staticmethod @callback @@ -76,10 +79,10 @@ async def _async_show_user_form( ), ): str, vol.Optional( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + CONF_USERNAME, default=user_input.get(CONF_USERNAME) or "" ): str, vol.Optional( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD) or "" ): str, } ), @@ -92,15 +95,7 @@ async def async_step_import( """Handle import initiated config flow.""" return await self.async_step_user(user_input) - def _already_configured(self, user_input: dict[str, Any]) -> bool: - """See if we already have a router matching user input configured.""" - existing_urls = { - url_normalize(entry.data[CONF_URL], default_scheme="http") - for entry in self._async_current_entries() - } - return user_input[CONF_URL] in existing_urls - - async def async_step_user( # noqa: C901 + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle user initiated config flow.""" @@ -119,68 +114,46 @@ async def async_step_user( # noqa: C901 user_input=user_input, errors=errors ) - if self._already_configured(user_input): - return self.async_abort(reason="already_configured") - - conn: Connection | None = None + conn: AuthorizedConnection def logout() -> None: - if isinstance(conn, AuthorizedConnection): - try: - conn.user.logout() - except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not logout", exc_info=True) + try: + conn.user.logout() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not logout", exc_info=True) - def try_connect(user_input: dict[str, Any]) -> Connection: + def try_connect(user_input: dict[str, Any]) -> AuthorizedConnection: """Try connecting with given credentials.""" - username = user_input.get(CONF_USERNAME) - password = user_input.get(CONF_PASSWORD) - conn: Connection - if username or password: - conn = AuthorizedConnection( - user_input[CONF_URL], - username=username, - password=password, - timeout=CONNECTION_TIMEOUT, - ) - else: - try: - conn = AuthorizedConnection( - user_input[CONF_URL], - username="", - password="", - timeout=CONNECTION_TIMEOUT, - ) - user_input[CONF_USERNAME] = "" - user_input[CONF_PASSWORD] = "" - except ResponseErrorException: - _LOGGER.debug( - "Could not login with empty credentials, proceeding unauthenticated", - exc_info=True, - ) - conn = Connection(user_input[CONF_URL], timeout=CONNECTION_TIMEOUT) - del user_input[CONF_USERNAME] - del user_input[CONF_PASSWORD] + username = user_input.get(CONF_USERNAME) or "" + password = user_input.get(CONF_PASSWORD) or "" + conn = AuthorizedConnection( + user_input[CONF_URL], + username=username, + password=password, + timeout=CONNECTION_TIMEOUT, + ) return conn - def get_router_title(conn: Connection) -> str: - """Get title for router.""" - title = None + def get_device_info() -> tuple[GetResponseType, GetResponseType]: + """Get router info.""" client = Client(conn) try: - info = client.device.basic_information() + device_info = client.device.information() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not get device.basic_information", exc_info=True) - else: - title = info.get("devicename") - if not title: + _LOGGER.debug("Could not get device.information", exc_info=True) try: - info = client.device.information() + device_info = client.device.basic_information() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not get device.information", exc_info=True) - else: - title = info.get("DeviceName") - return title or DEFAULT_DEVICE_NAME + _LOGGER.debug( + "Could not get device.basic_information", exc_info=True + ) + device_info = {} + try: + wlan_settings = client.wlan.multi_basic_settings() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get wlan.multi_basic_settings", exc_info=True) + wlan_settings = {} + return device_info, wlan_settings try: conn = await self.hass.async_add_executor_job(try_connect, user_input) @@ -207,41 +180,59 @@ def get_router_title(conn: Connection) -> str: user_input=user_input, errors=errors ) - title = self.context.get("title_placeholders", {}).get( - CONF_NAME - ) or await self.hass.async_add_executor_job(get_router_title, conn) + info, wlan_settings = await self.hass.async_add_executor_job(get_device_info) await self.hass.async_add_executor_job(logout) + if not self.unique_id: + if serial_number := info.get("SerialNumber"): + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + else: + await self._async_handle_discovery_without_unique_id() + + user_input[CONF_MAC] = get_device_macs(info, wlan_settings) + + title = ( + self.context.get("title_placeholders", {}).get(CONF_NAME) + or info.get("DeviceName") # device.information + or info.get("devicename") # device.basic_information + or DEFAULT_DEVICE_NAME + ) + return self.async_create_entry(title=title, data=user_input) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle SSDP initiated config flow.""" - await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() # Attempt to distinguish from other non-LTE Huawei router devices, at least # some ones we are interested in have "Mobile Wi-Fi" friendlyName. - if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower(): + if ( + "mobile" + not in discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower() + ): return self.async_abort(reason="not_huawei_lte") - url = self.context[CONF_URL] = url_normalize( - discovery_info.get( + if TYPE_CHECKING: + assert discovery_info.ssdp_location + url = url_normalize( + discovery_info.upnp.get( ssdp.ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", + f"http://{urlparse(discovery_info.ssdp_location).hostname}/", ) ) - if any( - url == flow["context"].get(CONF_URL) for flow in self._async_in_progress() - ): - return self.async_abort(reason="already_in_progress") + if serial_number := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + else: + await self._async_handle_discovery_without_unique_id() user_input = {CONF_URL: url} - if self._already_configured(user_input): - return self.async_abort(reason="already_configured") self.context["title_placeholders"] = { - CONF_NAME: discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + CONF_NAME: discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) } return await self._async_show_user_form(user_input) @@ -249,7 +240,7 @@ async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult class OptionsFlowHandler(config_entries.OptionsFlow): """Huawei LTE options flow.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry @@ -289,6 +280,12 @@ async def async_step_init( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ), ): bool, + vol.Optional( + CONF_UNAUTHENTICATED_MODE, + default=self.config_entry.options.get( + CONF_UNAUTHENTICATED_MODE, DEFAULT_UNAUTHENTICATED_MODE + ), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 7e34b3dbd160d..b9cbf5460873e 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,11 +2,15 @@ DOMAIN = "huawei_lte" +ATTR_UNIQUE_ID = "unique_id" + CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" +CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" DEFAULT_DEVICE_NAME = "LTE" DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN DEFAULT_TRACK_WIRED_CLIENTS = True +DEFAULT_UNAUTHENTICATED_MODE = False UPDATE_SIGNAL = f"{DOMAIN}_update" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 61d2bf30fb94a..7c3f3d16c92ca 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -14,7 +14,6 @@ SOURCE_TYPE_ROUTER, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -61,7 +60,7 @@ async def async_setup_entry( # Grab hosts list once to examine whether the initial fetch has got some data for # us, i.e. if wlan host list is supported. Only set up a subscription and proceed # with adding and tracking entities if it is. - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] if (hosts := _get_hosts(router, True)) is None: return @@ -94,10 +93,10 @@ async def async_setup_entry( router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN) router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) - async def _async_maybe_add_new_entities(url: str) -> None: + async def _async_maybe_add_new_entities(unique_id: str) -> None: """Add new entities if the update signal comes from our router.""" - if url == router.url: - async_add_new_entities(hass, url, async_add_entities, tracked) + if config_entry.unique_id == unique_id: + async_add_new_entities(router, async_add_entities, tracked) # Register to handle router data updates disconnect_dispatcher = async_dispatcher_connect( @@ -106,7 +105,7 @@ async def _async_maybe_add_new_entities(url: str) -> None: config_entry.async_on_unload(disconnect_dispatcher) # Add new entities from initial scan - async_add_new_entities(hass, router.url, async_add_entities, tracked) + async_add_new_entities(router, async_add_entities, tracked) def _is_wireless(host: _HostType) -> bool: @@ -129,15 +128,12 @@ def _is_us(host: _HostType) -> bool: @callback def async_add_new_entities( - hass: HomeAssistant, - router_url: str, + router: Router, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Add new entities that are not already being tracked.""" - router = hass.data[DOMAIN].routers[router_url] - hosts = _get_hosts(router) - if not hosts: + if not (hosts := _get_hosts(router)): return track_wired_clients = router.config_entry.options.get( @@ -228,8 +224,7 @@ def extra_state_attributes(self) -> dict[str, Any]: async def async_update(self) -> None: """Update state.""" - hosts = _get_hosts(self.router) - if hosts is None: + if (hosts := _get_hosts(self.router)) is None: self._available = False return self._available = True diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index f48206a48020d..9cfc008921bf1 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -4,8 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ - "getmac==0.8.2", - "huawei-lte-api==1.4.17", + "huawei-lte-api==1.4.18", "stringcase==1.2.0", "url-normalize==1.4.1" ], diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 1b3b85b67112d..fab1942763736 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -9,11 +9,11 @@ from huawei_lte_api.exceptions import ResponseErrorException from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT, CONF_URL +from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from . import Router -from .const import DOMAIN +from .const import ATTR_UNIQUE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ async def async_get_service( if discovery_info is None: return None - router = hass.data[DOMAIN].routers[discovery_info[CONF_URL]] + router = hass.data[DOMAIN].routers[discovery_info[ATTR_UNIQUE_ID]] default_targets = discovery_info[CONF_RECIPIENT] or [] return HuaweiLteSmsNotificationService(router, default_targets) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 7396502793e2c..c894fc396593a 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -2,29 +2,30 @@ from __future__ import annotations from bisect import bisect +from collections.abc import Callable import logging import re -from typing import Callable, NamedTuple +from typing import NamedTuple import attr from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_SIGNAL_STRENGTH, DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_URL, DATA_BYTES, DATA_RATE_BYTES_PER_SECOND, + FREQUENCY_MEGAHERTZ, PERCENTAGE, STATE_UNKNOWN, TIME_SECONDS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -53,7 +54,9 @@ class SensorMeta(NamedTuple): device_class: str | None = None icon: str | Callable[[StateType], str] | None = None unit: str | None = None + state_class: str | None = None enabled_default: bool = False + entity_category: str | None = None include: re.Pattern[str] | None = None exclude: re.Pattern[str] | None = None formatter: Callable[[str], tuple[StateType, str | None]] | None = None @@ -61,19 +64,38 @@ class SensorMeta(NamedTuple): SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { KEY_DEVICE_INFORMATION: SensorMeta( - include=re.compile(r"^WanIP.*Address$", re.IGNORECASE) + include=re.compile(r"^(WanIP.*Address|uptime)$", re.IGNORECASE) ), (KEY_DEVICE_INFORMATION, "WanIPAddress"): SensorMeta( - name="WAN IP address", icon="mdi:ip", enabled_default=True + name="WAN IP address", + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, + enabled_default=True, ), (KEY_DEVICE_INFORMATION, "WanIPv6Address"): SensorMeta( - name="WAN IPv6 address", icon="mdi:ip" + name="WAN IPv6 address", + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_INFORMATION, "uptime"): SensorMeta( + name="Uptime", + icon="mdi:timer-outline", + unit=TIME_SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "band"): SensorMeta( + name="Band", + entity_category=EntityCategory.DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "band"): SensorMeta(name="Band"), (KEY_DEVICE_SIGNAL, "cell_id"): SensorMeta( - name="Cell ID", icon="mdi:transmission-tower" + name="Cell ID", + icon="mdi:transmission-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "dl_mcs"): SensorMeta( + name="Downlink MCS", + entity_category=EntityCategory.DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "dl_mcs"): SensorMeta(name="Downlink MCS"), (KEY_DEVICE_SIGNAL, "dlbandwidth"): SensorMeta( name="Downlink bandwidth", icon=lambda x: ( @@ -81,19 +103,48 @@ class SensorMeta(NamedTuple): "mdi:speedometer-medium", "mdi:speedometer", )[bisect((8, 15), x if x is not None else -1000)], + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "earfcn"): SensorMeta( + name="EARFCN", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "lac"): SensorMeta( + name="LAC", + icon="mdi:map-marker", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "plmn"): SensorMeta( + name="PLMN", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "rac"): SensorMeta( + name="RAC", + icon="mdi:map-marker", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "rrc_status"): SensorMeta( + name="RRC status", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "tac"): SensorMeta( + name="TAC", + icon="mdi:map-marker", + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "tdd"): SensorMeta( + name="TDD", + entity_category=EntityCategory.DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "earfcn"): SensorMeta(name="EARFCN"), - (KEY_DEVICE_SIGNAL, "lac"): SensorMeta(name="LAC", icon="mdi:map-marker"), - (KEY_DEVICE_SIGNAL, "plmn"): SensorMeta(name="PLMN"), - (KEY_DEVICE_SIGNAL, "rac"): SensorMeta(name="RAC", icon="mdi:map-marker"), - (KEY_DEVICE_SIGNAL, "rrc_status"): SensorMeta(name="RRC status"), - (KEY_DEVICE_SIGNAL, "tac"): SensorMeta(name="TAC", icon="mdi:map-marker"), - (KEY_DEVICE_SIGNAL, "tdd"): SensorMeta(name="TDD"), (KEY_DEVICE_SIGNAL, "txpower"): SensorMeta( name="Transmit power", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "ul_mcs"): SensorMeta( + name="Uplink MCS", + entity_category=EntityCategory.DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "ul_mcs"): SensorMeta(name="Uplink MCS"), (KEY_DEVICE_SIGNAL, "ulbandwidth"): SensorMeta( name="Uplink bandwidth", icon=lambda x: ( @@ -101,6 +152,7 @@ class SensorMeta(NamedTuple): "mdi:speedometer-medium", "mdi:speedometer", )[bisect((8, 15), x if x is not None else -1000)], + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "mode"): SensorMeta( name="Mode", @@ -110,11 +162,16 @@ class SensorMeta(NamedTuple): str(x), "mdi:signal" ) ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "pci"): SensorMeta( + name="PCI", + icon="mdi:transmission-tower", + entity_category=EntityCategory.DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "pci"): SensorMeta(name="PCI", icon="mdi:transmission-tower"), (KEY_DEVICE_SIGNAL, "rsrq"): SensorMeta( name="RSRQ", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrq.php icon=lambda x: ( "mdi:signal-cellular-outline", @@ -122,11 +179,13 @@ class SensorMeta(NamedTuple): "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-11, -8, -5), x if x is not None else -1000)], + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rsrp"): SensorMeta( name="RSRP", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrp.php icon=lambda x: ( "mdi:signal-cellular-outline", @@ -134,11 +193,13 @@ class SensorMeta(NamedTuple): "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-110, -95, -80), x if x is not None else -1000)], + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rssi"): SensorMeta( name="RSSI", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://eyesaas.com/wi-fi-signal-strength/ icon=lambda x: ( "mdi:signal-cellular-outline", @@ -146,11 +207,13 @@ class SensorMeta(NamedTuple): "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-80, -70, -60), x if x is not None else -1000)], + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "sinr"): SensorMeta( name="SINR", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/sinr.php icon=lambda x: ( "mdi:signal-cellular-outline", @@ -158,11 +221,13 @@ class SensorMeta(NamedTuple): "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((0, 5, 10), x if x is not None else -1000)], + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rscp"): SensorMeta( name="RSCP", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://wiki.teltonika.lt/view/RSCP icon=lambda x: ( "mdi:signal-cellular-outline", @@ -170,10 +235,12 @@ class SensorMeta(NamedTuple): "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-95, -85, -75), x if x is not None else -1000)], + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "ecio"): SensorMeta( name="EC/IO", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://wiki.teltonika.lt/view/EC/IO icon=lambda x: ( "mdi:signal-cellular-outline", @@ -181,23 +248,41 @@ class SensorMeta(NamedTuple): "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-20, -10, -6), x if x is not None else -1000)], + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "transmode"): SensorMeta( + name="Transmission mode", + entity_category=EntityCategory.DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "transmode"): SensorMeta(name="Transmission mode"), (KEY_DEVICE_SIGNAL, "cqi0"): SensorMeta( name="CQI 0", icon="mdi:speedometer", + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "cqi1"): SensorMeta( name="CQI 1", icon="mdi:speedometer", ), + (KEY_DEVICE_SIGNAL, "enodeb_id"): SensorMeta( + name="eNodeB ID", + entity_category=EntityCategory.DIAGNOSTIC, + ), (KEY_DEVICE_SIGNAL, "ltedlfreq"): SensorMeta( name="Downlink frequency", - formatter=lambda x: (round(int(x) / 10), "MHz"), + formatter=lambda x: ( + round(int(x) / 10) if x is not None else None, + FREQUENCY_MEGAHERTZ, + ), + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "lteulfreq"): SensorMeta( name="Uplink frequency", - formatter=lambda x: (round(int(x) / 10), "MHz"), + formatter=lambda x: ( + round(int(x) / 10) if x is not None else None, + FREQUENCY_MEGAHERTZ, + ), + entity_category=EntityCategory.DIAGNOSTIC, ), KEY_MONITORING_CHECK_NOTIFICATIONS: SensorMeta( exclude=re.compile( @@ -212,10 +297,16 @@ class SensorMeta(NamedTuple): exclude=re.compile(r"^month(duration|lastcleartime)$", re.IGNORECASE) ), (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthDownload"): SensorMeta( - name="Current month download", unit=DATA_BYTES, icon="mdi:download" + name="Current month download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=SensorStateClass.TOTAL_INCREASING, ), (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthUpload"): SensorMeta( - name="Current month upload", unit=DATA_BYTES, icon="mdi:upload" + name="Current month upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=SensorStateClass.TOTAL_INCREASING, ), KEY_MONITORING_STATUS: SensorMeta( include=re.compile( @@ -225,23 +316,36 @@ class SensorMeta(NamedTuple): ), (KEY_MONITORING_STATUS, "BatteryPercent"): SensorMeta( name="Battery", - device_class=DEVICE_CLASS_BATTERY, + device_class=SensorDeviceClass.BATTERY, unit=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "CurrentWifiUser"): SensorMeta( - name="WiFi clients connected", icon="mdi:wifi" + name="WiFi clients connected", + icon="mdi:wifi", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "PrimaryDns"): SensorMeta( - name="Primary DNS server", icon="mdi:ip" + name="Primary DNS server", + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "SecondaryDns"): SensorMeta( - name="Secondary DNS server", icon="mdi:ip" + name="Secondary DNS server", + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "PrimaryIPv6Dns"): SensorMeta( - name="Primary IPv6 DNS server", icon="mdi:ip" + name="Primary IPv6 DNS server", + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "SecondaryIPv6Dns"): SensorMeta( - name="Secondary IPv6 DNS server", icon="mdi:ip" + name="Secondary IPv6 DNS server", + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, ), KEY_MONITORING_TRAFFIC_STATISTICS: SensorMeta( exclude=re.compile(r"^showtraffic$", re.IGNORECASE) @@ -250,29 +354,46 @@ class SensorMeta(NamedTuple): name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer-outline" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): SensorMeta( - name="Current connection download", unit=DATA_BYTES, icon="mdi:download" + name="Current connection download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=SensorStateClass.TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownloadRate"): SensorMeta( name="Current download rate", unit=DATA_RATE_BYTES_PER_SECOND, icon="mdi:download", + state_class=SensorStateClass.MEASUREMENT, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUpload"): SensorMeta( - name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload" + name="Current connection upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=SensorStateClass.TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUploadRate"): SensorMeta( name="Current upload rate", unit=DATA_RATE_BYTES_PER_SECOND, icon="mdi:upload", + state_class=SensorStateClass.MEASUREMENT, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): SensorMeta( - name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer-outline" + name="Total connected duration", + unit=TIME_SECONDS, + icon="mdi:timer-outline", + state_class=SensorStateClass.TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): SensorMeta( - name="Total download", unit=DATA_BYTES, icon="mdi:download" + name="Total download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=SensorStateClass.TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): SensorMeta( - name="Total upload", unit=DATA_BYTES, icon="mdi:upload" + name="Total upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=SensorStateClass.TOTAL_INCREASING, ), KEY_NET_CURRENT_PLMN: SensorMeta( exclude=re.compile(r"^(Rat|ShortName|Spn)$", re.IGNORECASE) @@ -280,12 +401,15 @@ class SensorMeta(NamedTuple): (KEY_NET_CURRENT_PLMN, "State"): SensorMeta( name="Operator search mode", formatter=lambda x: ({"0": "Auto", "1": "Manual"}.get(x, "Unknown"), None), + entity_category=EntityCategory.CONFIG, ), (KEY_NET_CURRENT_PLMN, "FullName"): SensorMeta( name="Operator name", + entity_category=EntityCategory.DIAGNOSTIC, ), (KEY_NET_CURRENT_PLMN, "Numeric"): SensorMeta( name="Operator code", + entity_category=EntityCategory.DIAGNOSTIC, ), KEY_NET_NET_MODE: SensorMeta(include=re.compile(r"^NetworkMode$", re.IGNORECASE)), (KEY_NET_NET_MODE, "NetworkMode"): SensorMeta( @@ -302,6 +426,7 @@ class SensorMeta(NamedTuple): }.get(x, "Unknown"), None, ), + entity_category=EntityCategory.CONFIG, ), (KEY_SMS_SMS_COUNT, "LocalDeleted"): SensorMeta( name="SMS deleted (device)", @@ -360,7 +485,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] sensors: list[Entity] = [] for key in SENSOR_KEYS: if not (items := router.data.get(key)): @@ -426,7 +551,7 @@ def _device_unique_id(self) -> str: return f"{self.key}.{self.item}" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return sensor state.""" return self._state @@ -436,7 +561,7 @@ def device_class(self) -> str | None: return self.meta.device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return sensor's unit of measurement.""" return self.meta.unit or self._unit @@ -448,6 +573,11 @@ def icon(self) -> str | None: return icon(self.state) return icon + @property + def state_class(self) -> str | None: + """Return sensor state class.""" + return self.meta.state_class + @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" @@ -467,3 +597,8 @@ async def async_update(self) -> None: self._state, self._unit = formatter(value) self._available = value is not None + + @property + def entity_category(self) -> str | None: + """Return category of entity, if any.""" + return self.meta.entity_category diff --git a/homeassistant/components/huawei_lte/services.yaml b/homeassistant/components/huawei_lte/services.yaml index bcb9be33299cc..711064b435e24 100644 --- a/homeassistant/components/huawei_lte/services.yaml +++ b/homeassistant/components/huawei_lte/services.yaml @@ -1,30 +1,46 @@ clear_traffic_statistics: + name: Clear traffic statistics description: Clear traffic statistics. fields: url: + name: URL description: URL of router to clear; optional when only one is configured. example: http://192.168.100.1/ + selector: + text: reboot: + name: Reboot description: Reboot router. fields: url: + name: URL description: URL of router to reboot; optional when only one is configured. example: http://192.168.100.1/ + selector: + text: resume_integration: + name: Resume integration description: Resume suspended integration. fields: url: + name: URL description: URL of router to resume integration for; optional when only one is configured. example: http://192.168.100.1/ + selector: + text: suspend_integration: + name: Suspend integration description: > Suspend integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the resume_integration service to resume. fields: url: + name: URL description: URL of router to resume integration for; optional when only one is configured. example: http://192.168.100.1/ + selector: + text: diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 9cfa49604aec4..0c1373192c5c2 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -1,8 +1,6 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "not_huawei_lte": "Not a Huawei LTE device" }, "error": { @@ -23,7 +21,7 @@ "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]" }, - "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "description": "Enter device access details.", "title": "Configure Huawei LTE" } } @@ -34,7 +32,8 @@ "data": { "name": "Notification service name (change requires restart)", "recipient": "SMS notification recipients", - "track_wired_clients": "Track wired network clients" + "track_wired_clients": "Track wired network clients", + "unauthenticated_mode": "Unauthenticated mode (change requires reload)" } } } diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index ff4109943bc71..af2a382c4db37 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -7,12 +7,11 @@ import attr from homeassistant.components.switch import ( - DEVICE_CLASS_SWITCH, DOMAIN as SWITCH_DOMAIN, + SwitchDeviceClass, SwitchEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,7 +28,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] switches: list[Entity] = [] if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH): @@ -44,6 +43,7 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntity, SwitchEntity): key: str item: str + _attr_device_class = SwitchDeviceClass.SWITCH _raw_state: str | None = attr.ib(init=False, default=None) def _turn(self, state: bool) -> None: @@ -57,11 +57,6 @@ def turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" self._turn(state=False) - @property - def device_class(self) -> str: - """Return device class.""" - return DEVICE_CLASS_SWITCH - async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() diff --git a/homeassistant/components/huawei_lte/translations/bg.json b/homeassistant/components/huawei_lte/translations/bg.json index 997c3bc14565b..741b8ec7d4704 100644 --- a/homeassistant/components/huawei_lte/translations/bg.json +++ b/homeassistant/components/huawei_lte/translations/bg.json @@ -13,10 +13,12 @@ "login_attempts_exceeded": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u043d\u0438\u0442\u0435 \u043e\u043f\u0438\u0442\u0438 \u0437\u0430 \u0432\u043b\u0438\u0437\u0430\u043d\u0435 \u0441\u0430 \u043d\u0430\u0434\u0432\u0438\u0448\u0435\u043d\u0438. \u041c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e", "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043e\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" }, + "flow_title": "{name}", "step": { "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "URL", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" }, "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e. \u041f\u043e\u0441\u043e\u0447\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430 \u043d\u0435 \u0435 \u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e, \u043d\u043e \u0434\u0430\u0432\u0430 \u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438 \u0437\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435. \u041e\u0442 \u0434\u0440\u0443\u0433\u0430 \u0441\u0442\u0440\u0430\u043d\u0430, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0434\u043e\u0432\u0435\u0434\u0435 \u0434\u043e \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0438 \u0441 \u0434\u043e\u0441\u0442\u044a\u043f\u0430 \u0434\u043e \u0443\u0435\u0431 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u0432\u044a\u043d Home Assistant, \u0434\u043e\u043a\u0430\u0442\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0442\u043e.", diff --git a/homeassistant/components/huawei_lte/translations/ca.json b/homeassistant/components/huawei_lte/translations/ca.json index 73c5bc9b8e1cf..0347398ce8b54 100644 --- a/homeassistant/components/huawei_lte/translations/ca.json +++ b/homeassistant/components/huawei_lte/translations/ca.json @@ -15,7 +15,7 @@ "response_error": "S'ha produ\u00eft un error desconegut del dispositiu", "unknown": "Error inesperat" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Nom d'usuari" }, - "description": "Introdueix les dades d'acc\u00e9s del dispositiu. El nom d'usuari i contrasenya s\u00f3n opcionals, per\u00f2 habiliten m\u00e9s funcions de la integraci\u00f3. D'altra banda, (mentre la integraci\u00f3 estigui activa) l'\u00fas d'una connexi\u00f3 autoritzada pot causar problemes per accedir a la interf\u00edcie web del dispositiu des de fora de Home Assistant i viceversa.", + "description": "Introdueix les dades d'acc\u00e9s del dispositiu.", "title": "Configuraci\u00f3 de Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Nom del servei de notificacions (reinici necessari si canvia)", "recipient": "Destinataris de notificacions SMS", "track_new_devices": "Segueix dispositius nous", - "track_wired_clients": "Segueix els clients connectats a la xarxa per cable" + "track_wired_clients": "Segueix els clients connectats a la xarxa per cable", + "unauthenticated_mode": "Mode no autenticat (canviar requereix tornar a carregar)" } } } diff --git a/homeassistant/components/huawei_lte/translations/de.json b/homeassistant/components/huawei_lte/translations/de.json index 10a7af41a3cd9..a3b40d0c0aecc 100644 --- a/homeassistant/components/huawei_lte/translations/de.json +++ b/homeassistant/components/huawei_lte/translations/de.json @@ -15,7 +15,7 @@ "response_error": "Unbekannter Fehler vom Ger\u00e4t", "unknown": "Unerwarteter Fehler" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Benutzername" }, - "description": "Gib die Zugangsdaten zum Ger\u00e4t ein. Die Angabe von Benutzername und Passwort ist optional, erm\u00f6glicht aber die Unterst\u00fctzung weiterer Integrationsfunktionen. Andererseits kann die Verwendung einer autorisierten Verbindung zu Problemen beim Zugriff auf die Web-Schnittstelle des Ger\u00e4ts von au\u00dferhalb des Home Assistant f\u00fchren, w\u00e4hrend die Integration aktiv ist, und umgekehrt.", + "description": "Gib die Zugangsdaten f\u00fcr das Ger\u00e4t ein.", "title": "Konfiguriere Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Name des Benachrichtigungsdienstes (\u00c4nderung erfordert Neustart)", "recipient": "SMS-Benachrichtigungsempf\u00e4nger", "track_new_devices": "Neue Ger\u00e4te verfolgen", - "track_wired_clients": "Kabelgebundene Netzwerk-Clients verfolgen" + "track_wired_clients": "Kabelgebundene Netzwerk-Clients verfolgen", + "unauthenticated_mode": "Nicht authentifizierter Modus (\u00c4nderung erfordert erneutes Laden)" } } } diff --git a/homeassistant/components/huawei_lte/translations/en.json b/homeassistant/components/huawei_lte/translations/en.json index 61b8a2e50d125..ade7beed75ca4 100644 --- a/homeassistant/components/huawei_lte/translations/en.json +++ b/homeassistant/components/huawei_lte/translations/en.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Username" }, - "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "description": "Enter device access details.", "title": "Configure Huawei LTE" } } @@ -34,7 +34,9 @@ "data": { "name": "Notification service name (change requires restart)", "recipient": "SMS notification recipients", - "track_wired_clients": "Track wired network clients" + "track_new_devices": "Track new devices", + "track_wired_clients": "Track wired network clients", + "unauthenticated_mode": "Unauthenticated mode (change requires reload)" } } } diff --git a/homeassistant/components/huawei_lte/translations/es.json b/homeassistant/components/huawei_lte/translations/es.json index 00564d7282a97..5d5e72e70c315 100644 --- a/homeassistant/components/huawei_lte/translations/es.json +++ b/homeassistant/components/huawei_lte/translations/es.json @@ -35,7 +35,8 @@ "name": "Nombre del servicio de notificaci\u00f3n (el cambio requiere reiniciar)", "recipient": "Destinatarios de notificaciones por SMS", "track_new_devices": "Rastrea nuevos dispositivos", - "track_wired_clients": "Seguir clientes de red cableados" + "track_wired_clients": "Seguir clientes de red cableados", + "unauthenticated_mode": "Modo no autenticado (el cambio requiere recarga)" } } } diff --git a/homeassistant/components/huawei_lte/translations/et.json b/homeassistant/components/huawei_lte/translations/et.json index 3c674c0344c7c..955d5cc2c5a69 100644 --- a/homeassistant/components/huawei_lte/translations/et.json +++ b/homeassistant/components/huawei_lte/translations/et.json @@ -15,7 +15,7 @@ "response_error": "Seade andis tuvastamatu t\u00f5rke", "unknown": "Ootamatu t\u00f5rge" }, - "flow_title": "", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Kasutajanimi" }, - "description": "Sisesta seadmele juurdep\u00e4\u00e4su \u00fcksikasjad. Kasutajanime ja salas\u00f5na m\u00e4\u00e4ramine on valikuline kuid v\u00f5imaldab rohkemate sidumisfunktsioonide toetamist. Teisest k\u00fcljest v\u00f5ib volitatud \u00fchenduse kasutamine p\u00f5hjustada probleeme seadme veebiliidese ligip\u00e4\u00e4suga v\u00e4ljastpoolt Home assistant'i kui sidumine on aktiivne ja vastupidi.", + "description": "Sisesta seadmele juurdep\u00e4\u00e4su \u00fcksikasjad.", "title": "Huawei LTE seadistamine" } } @@ -35,7 +35,8 @@ "name": "Teavitusteenuse nimi (muudatus n\u00f5uab taask\u00e4ivitamist)", "recipient": "SMS teavituse saajad", "track_new_devices": "Uute seadmete j\u00e4lgimine", - "track_wired_clients": "J\u00e4lgi juhtmega v\u00f5rgukliente" + "track_wired_clients": "J\u00e4lgi juhtmega v\u00f5rgukliente", + "unauthenticated_mode": "Tuvastuseta re\u017eiim (muutmine n\u00f5uab taaslaadimist)" } } } diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json index df7e6c2e38085..ca360ffc07792 100644 --- a/homeassistant/components/huawei_lte/translations/fr.json +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Ce p\u00e9riph\u00e9rique est d\u00e9j\u00e0 en cours de configuration", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_huawei_lte": "Pas un appareil Huawei LTE" }, "error": { @@ -15,7 +15,7 @@ "response_error": "Erreur inconnue de l'appareil", "unknown": "Erreur inattendue" }, - "flow_title": "Huawei LTE: {nom}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -35,7 +35,8 @@ "name": "Nom du service de notification (red\u00e9marrage requis)", "recipient": "Destinataires des notifications SMS", "track_new_devices": "Suivre les nouveaux appareils", - "track_wired_clients": "Suivre les clients du r\u00e9seau filaire" + "track_wired_clients": "Suivre les clients du r\u00e9seau filaire", + "unauthenticated_mode": "Mode non authentifi\u00e9 (le changement n\u00e9cessite un rechargement)" } } } diff --git a/homeassistant/components/huawei_lte/translations/he.json b/homeassistant/components/huawei_lte/translations/he.json index 6f4191da70d53..1e4333a989aba 100644 --- a/homeassistant/components/huawei_lte/translations/he.json +++ b/homeassistant/components/huawei_lte/translations/he.json @@ -1,10 +1,24 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "error": { + "incorrect_password": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d2\u05d5\u05d9\u05d4", + "incorrect_username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9 \u05e9\u05d2\u05d5\u05d9", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}", "step": { "user": { "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" - } + }, + "description": "\u05d9\u05e9 \u05dc\u05d4\u05d6\u05d9\u05df \u05e4\u05e8\u05d8\u05d9 \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d4\u05ea\u05e7\u05df." } } } diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index 815794133d2b0..36d08438fcaf7 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -2,18 +2,20 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z" }, "error": { - "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9se", + "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9s", "incorrect_password": "Hib\u00e1s jelsz\u00f3", "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "invalid_url": "\u00c9rv\u00e9nytelen URL", + "login_attempts_exceeded": "T\u00fall\u00e9pte a maxim\u00e1lis bejelentkez\u00e9si k\u00eds\u00e9rleteket. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb", + "response_error": "Ismeretlen hiba az eszk\u00f6zr\u0151l", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -21,6 +23,7 @@ "url": "URL", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Adja meg az eszk\u00f6z hozz\u00e1f\u00e9r\u00e9si adatait.", "title": "Huawei LTE konfigur\u00e1l\u00e1sa" } } @@ -31,7 +34,9 @@ "data": { "name": "\u00c9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s neve (a m\u00f3dos\u00edt\u00e1s \u00fajraind\u00edt\u00e1st ig\u00e9nyel)", "recipient": "SMS-\u00e9rtes\u00edt\u00e9s c\u00edmzettjei", - "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomk\u00f6vet\u00e9se" + "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomk\u00f6vet\u00e9se", + "track_wired_clients": "Vezet\u00e9kes h\u00e1l\u00f3zati \u00fcgyfelek nyomon k\u00f6vet\u00e9se", + "unauthenticated_mode": "Nem hiteles\u00edtett m\u00f3d (a v\u00e1ltoztat\u00e1shoz \u00fajrat\u00f6lt\u00e9sre van sz\u00fcks\u00e9g)" } } } diff --git a/homeassistant/components/huawei_lte/translations/id.json b/homeassistant/components/huawei_lte/translations/id.json index 2077b31ccd7d6..fa586718cac6f 100644 --- a/homeassistant/components/huawei_lte/translations/id.json +++ b/homeassistant/components/huawei_lte/translations/id.json @@ -15,7 +15,7 @@ "response_error": "Kesalahan tidak dikenal dari perangkat", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Nama Pengguna" }, - "description": "Masukkan detail akses perangkat. Menentukan nama pengguna dan kata sandi bersifat opsional, tetapi memungkinkan dukungan untuk fitur integrasi lainnya. Selain itu, penggunaan koneksi resmi dapat menyebabkan masalah mengakses antarmuka web perangkat dari luar Home Assistant saat integrasi aktif, dan sebaliknya.", + "description": "Masukkan detail akses perangkat.", "title": "Konfigurasikan Huawei LTE" } } @@ -34,7 +34,9 @@ "data": { "name": "Nama layanan notifikasi (perubahan harus dimulai ulang)", "recipient": "Penerima notifikasi SMS", - "track_new_devices": "Lacak perangkat baru" + "track_new_devices": "Lacak perangkat baru", + "track_wired_clients": "Lacak klien jaringan kabel", + "unauthenticated_mode": "Mode tidak diautentikasi (perubahan memerlukan pemuatan ulang)" } } } diff --git a/homeassistant/components/huawei_lte/translations/it.json b/homeassistant/components/huawei_lte/translations/it.json index 545d3b35daf67..ad8d4a82b08af 100644 --- a/homeassistant/components/huawei_lte/translations/it.json +++ b/homeassistant/components/huawei_lte/translations/it.json @@ -15,7 +15,7 @@ "response_error": "Errore sconosciuto dal dispositivo", "unknown": "Errore imprevisto" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Nome utente" }, - "description": "Immettere i dettagli di accesso al dispositivo. La specifica di nome utente e password \u00e8 facoltativa, ma abilita il supporto per altre funzionalit\u00e0 di integrazione. D'altra parte, l'uso di una connessione autorizzata pu\u00f2 causare problemi di accesso all'interfaccia Web del dispositivo dall'esterno di Home Assistant mentre l'integrazione \u00e8 attiva e viceversa.", + "description": "Inserisci i dettagli di accesso al dispositivo.", "title": "Configura Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Nome del servizio di notifica (la modifica richiede il riavvio)", "recipient": "Destinatari della notifica SMS", "track_new_devices": "Traccia nuovi dispositivi", - "track_wired_clients": "Tieni traccia dei client di rete cablata" + "track_wired_clients": "Tieni traccia dei client di rete cablata", + "unauthenticated_mode": "Modalit\u00e0 non autenticata (la modifica richiede il ricaricamento)" } } } diff --git a/homeassistant/components/huawei_lte/translations/ja.json b/homeassistant/components/huawei_lte/translations/ja.json new file mode 100644 index 0000000000000..6c74f5a791838 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/ja.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "not_huawei_lte": "Huawei LTE\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "connection_timeout": "\u63a5\u7d9a\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", + "incorrect_password": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093", + "incorrect_username": "\u30e6\u30fc\u30b6\u30fc\u540d\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_url": "\u7121\u52b9\u306aURL", + "login_attempts_exceeded": "\u30ed\u30b0\u30a4\u30f3\u8a66\u884c\u56de\u6570\u304c\u6700\u5927\u5024\u3092\u8d85\u3048\u307e\u3057\u305f\u3001\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044", + "response_error": "\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u306e\u4e0d\u660e\u306a\u30a8\u30e9\u30fc", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Huawei LTE\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u540d(\u5909\u66f4\u306b\u306f\u518d\u8d77\u52d5\u304c\u5fc5\u8981)", + "recipient": "SMS\u901a\u77e5\u306e\u53d7\u4fe1\u8005", + "track_new_devices": "\u65b0\u3057\u3044\u30c7\u30d0\u30a4\u30b9\u306e\u8ffd\u8de1", + "track_wired_clients": "\u6709\u7dda\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3092\u8ffd\u8de1\u3059\u308b", + "unauthenticated_mode": "\u8a8d\u8a3c\u306a\u3057\u306e\u30e2\u30fc\u30c9(\u5909\u66f4\u306b\u306f\u30ea\u30ed\u30fc\u30c9\u304c\u5fc5\u8981)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json index 11d450abc3b2a..e65c261d62b48 100644 --- a/homeassistant/components/huawei_lte/translations/nl.json +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -15,7 +15,7 @@ "response_error": "Onbekende fout van het apparaat", "unknown": "Onverwachte fout" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Gebruikersnaam" }, - "description": "Voer de toegangsgegevens van het apparaat in. Opgeven van gebruikersnaam en wachtwoord is optioneel, maar biedt ondersteuning voor meer integratiefuncties. Aan de andere kant kan het gebruik van een geautoriseerde verbinding problemen veroorzaken bij het openen van het webinterface van het apparaat buiten de Home Assitant, terwijl de integratie actief is en andersom.", + "description": "Voer de toegangsgegevens van het apparaat in.", "title": "Configureer Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Naam meldingsservice (wijziging vereist opnieuw opstarten)", "recipient": "Ontvangers van sms-berichten", "track_new_devices": "Volg nieuwe apparaten", - "track_wired_clients": "Volg bekabelde netwerkclients" + "track_wired_clients": "Volg bekabelde netwerkclients", + "unauthenticated_mode": "Niet-geverifieerde modus (wijzigen vereist opnieuw laden)" } } } diff --git a/homeassistant/components/huawei_lte/translations/no.json b/homeassistant/components/huawei_lte/translations/no.json index 4a9966c9339d3..3c8b26ab0cda0 100644 --- a/homeassistant/components/huawei_lte/translations/no.json +++ b/homeassistant/components/huawei_lte/translations/no.json @@ -15,7 +15,7 @@ "response_error": "Ukjent feil fra enheten", "unknown": "Uventet feil" }, - "flow_title": "", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Brukernavn" }, - "description": "Fyll inn detaljer for enhetstilgang. Spesifisering av brukernavn og passord er valgfritt, men gir st\u00f8tte for flere integrasjonsfunksjoner. P\u00e5 en annen side kan bruk av en autorisert tilkobling f\u00f8re til problemer med tilgang til enhetens webgrensesnitt utenfor Home Assistant mens integrasjonen er aktiv, og omvendt.", + "description": "Angi enhetsadgangsdetaljer.", "title": "Konfigurer Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Navn p\u00e5 varslingstjeneste (endring krever omstart)", "recipient": "Mottakere av SMS-varsling", "track_new_devices": "Spor nye enheter", - "track_wired_clients": "Spor kablede nettverksklienter" + "track_wired_clients": "Spor kablede nettverksklienter", + "unauthenticated_mode": "Uautentisert modus (endring krever omlasting)" } } } diff --git a/homeassistant/components/huawei_lte/translations/pl.json b/homeassistant/components/huawei_lte/translations/pl.json index 2d71c097b3b29..38ee2117b9d7e 100644 --- a/homeassistant/components/huawei_lte/translations/pl.json +++ b/homeassistant/components/huawei_lte/translations/pl.json @@ -15,7 +15,7 @@ "response_error": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d w urz\u0105dzeniu", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Nazwa u\u017cytkownika" }, - "description": "Wprowad\u017a szczeg\u00f3\u0142y dost\u0119pu do urz\u0105dzenia. Okre\u015blenie nazwy u\u017cytkownika i has\u0142a jest opcjonalne, ale umo\u017cliwia obs\u0142ug\u0119 wi\u0119kszej liczby funkcji integracji. Z drugiej strony u\u017cycie autoryzowanego po\u0142\u0105czenia mo\u017ce powodowa\u0107 problemy z dost\u0119pem do interfejsu internetowego urz\u0105dzenia z zewn\u0105trz Home Assistanta, gdy integracja jest aktywna.", + "description": "Wprowad\u017a szczeg\u00f3\u0142y dost\u0119pu do urz\u0105dzenia.", "title": "Konfiguracja Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Nazwa us\u0142ugi powiadomie\u0144 (zmiana wymaga ponownego uruchomienia)", "recipient": "Odbiorcy powiadomie\u0144 SMS", "track_new_devices": "\u015aled\u017a nowe urz\u0105dzenia", - "track_wired_clients": "\u015aled\u017a klient\u00f3w sieci przewodowej" + "track_wired_clients": "\u015aled\u017a klient\u00f3w sieci przewodowej", + "unauthenticated_mode": "Tryb nieuwierzytelniony (zmiana wymaga prze\u0142adowania)" } } } diff --git a/homeassistant/components/huawei_lte/translations/ru.json b/homeassistant/components/huawei_lte/translations/ru.json index d679f8d28616e..e9ddd73191daa 100644 --- a/homeassistant/components/huawei_lte/translations/ru.json +++ b/homeassistant/components/huawei_lte/translations/ru.json @@ -15,7 +15,7 @@ "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL-\u0430\u0434\u0440\u0435\u0441", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u0423\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u043e \u044d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438. \u0421 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0442\u043e\u0440\u043e\u043d\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043c\u043e\u0436\u0435\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043e\u043c \u043a \u0432\u0435\u0431-\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437 Home Assistant, \u043a\u043e\u0433\u0434\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043d\u0430\u043e\u0431\u043e\u0440\u043e\u0442.", + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", "title": "Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0441\u043b\u0443\u0436\u0431\u044b \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 (\u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a)", "recipient": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u0438 SMS-\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439", "track_new_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", - "track_wired_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438" + "track_wired_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438", + "unauthenticated_mode": "\u0420\u0435\u0436\u0438\u043c \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 (\u0434\u043b\u044f \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0430)" } } } diff --git a/homeassistant/components/huawei_lte/translations/tr.json b/homeassistant/components/huawei_lte/translations/tr.json index ba934acc39b8c..92d40ca810b4e 100644 --- a/homeassistant/components/huawei_lte/translations/tr.json +++ b/homeassistant/components/huawei_lte/translations/tr.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "not_huawei_lte": "Huawei LTE cihaz\u0131 de\u011fil" }, "error": { "connection_timeout": "Ba\u011flant\u0131 zamana\u015f\u0131m\u0131", @@ -14,6 +15,7 @@ "response_error": "Cihazdan bilinmeyen hata", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name}", "step": { "user": { "data": { @@ -21,7 +23,8 @@ "url": "URL", "username": "Kullan\u0131c\u0131 Ad\u0131" }, - "description": "Cihaz eri\u015fim ayr\u0131nt\u0131lar\u0131n\u0131 girin. Kullan\u0131c\u0131 ad\u0131 ve parolan\u0131n belirtilmesi iste\u011fe ba\u011fl\u0131d\u0131r, ancak daha fazla entegrasyon \u00f6zelli\u011fi i\u00e7in destek sa\u011flar. \u00d6te yandan, yetkili bir ba\u011flant\u0131n\u0131n kullan\u0131lmas\u0131, entegrasyon aktifken Ev Asistan\u0131 d\u0131\u015f\u0131ndan cihaz web aray\u00fcz\u00fcne eri\u015fimde sorunlara neden olabilir ve tam tersi." + "description": "Cihaz eri\u015fim ayr\u0131nt\u0131lar\u0131n\u0131 girin.", + "title": "Huawei LTE'yi yap\u0131land\u0131r\u0131n" } } }, @@ -29,8 +32,11 @@ "step": { "init": { "data": { + "name": "Bildirim hizmeti ad\u0131 (de\u011fi\u015fiklik yeniden ba\u015flatmay\u0131 gerektirir)", "recipient": "SMS bildirimi al\u0131c\u0131lar\u0131", - "track_new_devices": "Yeni cihazlar\u0131 izle" + "track_new_devices": "Yeni cihazlar\u0131 izle", + "track_wired_clients": "Kablolu a\u011f istemcilerini izleyin", + "unauthenticated_mode": "Kimli\u011fi do\u011frulanmam\u0131\u015f mod (de\u011fi\u015fiklik yeniden y\u00fckleme gerektirir)" } } } diff --git a/homeassistant/components/huawei_lte/translations/zh-Hans.json b/homeassistant/components/huawei_lte/translations/zh-Hans.json index 987c53e4d5c91..a63ff964b626d 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hans.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hans.json @@ -1,7 +1,42 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c", + "not_huawei_lte": "\u8be5\u8bbe\u5907\u4e0d\u662f\u534e\u4e3a LTE \u8bbe\u5907" + }, "error": { - "incorrect_username": "\u7528\u6237\u540d\u9519\u8bef" + "connection_timeout": "\u8fde\u63a5\u8d85\u65f6", + "incorrect_password": "\u5bc6\u7801\u9519\u8bef", + "incorrect_username": "\u7528\u6237\u540d\u9519\u8bef", + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "invalid_url": "\u65e0\u6548\u7f51\u5740", + "login_attempts_exceeded": "\u5df2\u8d85\u8fc7\u6700\u5927\u767b\u5f55\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5", + "response_error": "\u8bbe\u5907\u51fa\u73b0\u672a\u77e5\u9519\u8bef", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "url": "\u4e3b\u673a\u5730\u5740", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u8bbe\u5907\u76f8\u5173\u4fe1\u606f\u4ee5\u4fbf\u8fde\u63a5\u81f3\u8be5\u8bbe\u5907", + "title": "\u914d\u7f6e\u534e\u4e3aLTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u63a8\u9001\u670d\u52a1\u540d\u79f0\uff08\u66f4\u6539\u540e\u9700\u8981\u91cd\u8f7d\uff09", + "recipient": "\u77ed\u4fe1\u901a\u77e5\u6536\u4ef6\u4eba", + "track_new_devices": "\u8ddf\u8e2a\u65b0\u8bbe\u5907", + "track_wired_clients": "\u8ddf\u8e2a\u6709\u7ebf\u7f51\u7edc\u5ba2\u6237\u7aef", + "unauthenticated_mode": "\u672a\u7ecf\u8eab\u4efd\u9a8c\u8bc1\u7684\u6a21\u5f0f\uff08\u66f4\u6539\u540e\u9700\u8981\u91cd\u8f7d\uff09" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/zh-Hant.json b/homeassistant/components/huawei_lte/translations/zh-Hant.json index bc929fdcbba86..0c1d2baa7a921 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hant.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hant.json @@ -15,7 +15,7 @@ "response_error": "\u4f86\u81ea\u88dd\u7f6e\u672a\u77e5\u932f\u8aa4", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "\u83ef\u70ba LTE\uff1a{name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "\u7db2\u5740", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u8a73\u7d30\u8cc7\u6599\u3002\u6307\u5b9a\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u70ba\u9078\u9805\u8f38\u5165\uff0c\u4f46\u958b\u555f\u5c07\u652f\u63f4\u66f4\u591a\u6574\u5408\u529f\u80fd\u3002\u6b64\u5916\uff0c\u4f7f\u7528\u6388\u6b0a\u9023\u7dda\uff0c\u53ef\u80fd\u5c0e\u81f4\u6574\u5408\u555f\u7528\u5f8c\uff0c\u7531\u5916\u90e8\u9023\u7dda\u81f3 Home Assistant \u88dd\u7f6e Web \u4ecb\u9762\u51fa\u73fe\u67d0\u4e9b\u554f\u984c\uff0c\u53cd\u4e4b\u4ea6\u7136\u3002", + "description": "\u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u8a73\u7d30\u8cc7\u6599\u3002", "title": "\u8a2d\u5b9a\u83ef\u70ba LTE" } } @@ -35,7 +35,8 @@ "name": "\u901a\u77e5\u670d\u52d9\u540d\u7a31\uff08\u8b8a\u66f4\u5f8c\u9700\u91cd\u555f\uff09", "recipient": "\u7c21\u8a0a\u901a\u77e5\u6536\u4ef6\u8005", "track_new_devices": "\u8ffd\u8e64\u65b0\u88dd\u7f6e", - "track_wired_clients": "\u8ffd\u8e64\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef" + "track_wired_clients": "\u8ffd\u8e64\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef", + "unauthenticated_mode": "\u672a\u6388\u6b0a\u6a21\u5f0f\uff08\u8b8a\u66f4\u5f8c\u9700\u91cd\u555f\uff09" } } } diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py new file mode 100644 index 0000000000000..69b346a58f4ed --- /dev/null +++ b/homeassistant/components/huawei_lte/utils.py @@ -0,0 +1,23 @@ +"""Utilities for the Huawei LTE integration.""" +from __future__ import annotations + +from huawei_lte_api.Connection import GetResponseType + +from homeassistant.helpers.device_registry import format_mac + + +def get_device_macs( + device_info: GetResponseType, wlan_settings: GetResponseType +) -> list[str]: + """Get list of device MAC addresses. + + :param device_info: the device.information structure for the device + :param wlan_settings: the wlan.multi_basic_settings structure for the device + """ + macs = [device_info.get("MacAddress1"), device_info.get("MacAddress2")] + try: + macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"]) + except Exception: # pylint: disable=broad-except + # Assume not supported + pass + return sorted({format_mac(str(x)) for x in macs if x}) diff --git a/homeassistant/components/huawei_router/__init__.py b/homeassistant/components/huawei_router/__init__.py deleted file mode 100644 index 861809992c687..0000000000000 --- a/homeassistant/components/huawei_router/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The huawei_router component.""" diff --git a/homeassistant/components/huawei_router/device_tracker.py b/homeassistant/components/huawei_router/device_tracker.py deleted file mode 100644 index 69278ed6574a8..0000000000000 --- a/homeassistant/components/huawei_router/device_tracker.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Support for HUAWEI routers.""" -import base64 -from collections import namedtuple -import logging -import re - -import requests -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - DOMAIN, - PLATFORM_SCHEMA, - DeviceScanner, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - } -) - - -def get_scanner(hass, config): - """Validate the configuration and return a HUAWEI scanner.""" - scanner = HuaweiDeviceScanner(config[DOMAIN]) - - return scanner - - -Device = namedtuple("Device", ["name", "ip", "mac", "state"]) - - -class HuaweiDeviceScanner(DeviceScanner): - """This class queries a router running HUAWEI firmware.""" - - ARRAY_REGEX = re.compile(r"var UserDevinfo = new Array\((.*)null\);") - DEVICE_REGEX = re.compile(r"new USERDevice\((.*?)\),") - DEVICE_ATTR_REGEX = re.compile( - '"(?P.*?)","(?P.*?)",' - '"(?P.*?)","(?P.*?)",' - '"(?P.*?)","(?P.*?)",' - '"(?P.*?)","(?P.*?)",' - '"(?P